2765 lines
110 KiB
C++
2765 lines
110 KiB
C++
#include "gitplusplus.h"
|
|
#include "gitprocess.h"
|
|
#include "gitstatusmodel.h"
|
|
|
|
#ifdef HAVE_KATE_DIFF
|
|
#include "ktexteditor_utils.h"
|
|
#endif
|
|
|
|
#include <KPluginFactory>
|
|
#include <KLocalizedString>
|
|
#include <ktexteditor_version.h>
|
|
|
|
#include <KTextEditor/Document>
|
|
#include <KTextEditor/Editor>
|
|
#include <KTextEditor/MainWindow>
|
|
#include <KTextEditor/View>
|
|
|
|
#include <QAction>
|
|
#include <QApplication>
|
|
#include <algorithm>
|
|
#include <QCheckBox>
|
|
#include <QClipboard>
|
|
#include <QComboBox>
|
|
#include <QDialog>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileDialog>
|
|
#include <QFileInfo>
|
|
#include <QFileSystemWatcher>
|
|
#include <QFont>
|
|
#include <QHBoxLayout>
|
|
#include <QHeaderView>
|
|
#include <QInputDialog>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QListWidget>
|
|
#include <QMainWindow>
|
|
#include <QMenu>
|
|
#include <QMessageBox>
|
|
#include <QProcess>
|
|
#include <QPushButton>
|
|
#include <QSortFilterProxyModel>
|
|
#include <QSplitter>
|
|
#include <QStatusBar>
|
|
#include <QSettings>
|
|
#include <QTabBar>
|
|
#include <QTabWidget>
|
|
#include <QTextEdit>
|
|
#include <QTimer>
|
|
#include <QToolButton>
|
|
#include <QTreeView>
|
|
#include <QUrl>
|
|
#include <QVBoxLayout>
|
|
|
|
K_PLUGIN_CLASS_WITH_JSON(GitPlusPlusPlugin, "metadata.json")
|
|
|
|
// ============================================================================
|
|
// Plugin
|
|
// ============================================================================
|
|
|
|
#if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
|
GitPlusPlusPlugin::GitPlusPlusPlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args)
|
|
: KTextEditor::Plugin(parent)
|
|
{
|
|
Q_UNUSED(data);
|
|
Q_UNUSED(args);
|
|
}
|
|
#else
|
|
GitPlusPlusPlugin::GitPlusPlusPlugin(QObject *parent, const QVariantList &args)
|
|
: KTextEditor::Plugin(parent)
|
|
{
|
|
Q_UNUSED(args);
|
|
}
|
|
#endif
|
|
|
|
QObject *GitPlusPlusPlugin::createView(KTextEditor::MainWindow *mainWindow)
|
|
{
|
|
return new GitPlusPlusView(this, mainWindow);
|
|
}
|
|
|
|
// ============================================================================
|
|
// View
|
|
// ============================================================================
|
|
|
|
static QToolButton *makeToolButton(const QString &iconName, const QString &tooltip)
|
|
{
|
|
auto *btn = new QToolButton;
|
|
btn->setAutoRaise(true);
|
|
btn->setIcon(QIcon::fromTheme(iconName));
|
|
btn->setToolTip(tooltip);
|
|
return btn;
|
|
}
|
|
|
|
GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWindow *mainWindow)
|
|
: QObject(mainWindow)
|
|
, m_plugin(plugin)
|
|
, m_mainWindow(mainWindow)
|
|
{
|
|
m_toolView = m_mainWindow->createToolView(
|
|
plugin,
|
|
QStringLiteral("gitplusplus_panel"),
|
|
KTextEditor::MainWindow::Left,
|
|
QIcon::fromTheme(QStringLiteral("vcs-commit")),
|
|
QStringLiteral("Git++"));
|
|
|
|
auto *container = new QWidget(m_toolView);
|
|
auto *mainLayout = new QVBoxLayout(container);
|
|
mainLayout->setContentsMargins(4, 4, 4, 4);
|
|
mainLayout->setSpacing(4);
|
|
|
|
// --- Repo/branch indicator ---
|
|
m_repoLabel = new QLabel(QStringLiteral("⋯ initializing"), container);
|
|
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<QMainWindow *>(m_mainWindow->window())) {
|
|
m_statusBarWidget = win->statusBar()->findChild<QLabel *>(QStringLiteral("gitpp_status"));
|
|
if (!m_statusBarWidget) {
|
|
m_statusBarWidget = new QLabel();
|
|
m_statusBarWidget->setObjectName(QStringLiteral("gitpp_status"));
|
|
QFont smallFont = QApplication::font();
|
|
smallFont.setPointSizeF(smallFont.pointSizeF() * 0.9);
|
|
m_statusBarWidget->setFont(smallFont);
|
|
win->statusBar()->addPermanentWidget(m_statusBarWidget);
|
|
}
|
|
m_statusBarWidget->setText(QStringLiteral("Git++ ⋯"));
|
|
m_statusBarWidget->setStyleSheet(QStringLiteral("color: gray; padding: 0px 6px;"));
|
|
}
|
|
|
|
// --- Tab widget ---
|
|
m_tabWidget = new QTabWidget(container);
|
|
m_tabWidget->setMovable(true);
|
|
mainLayout->addWidget(m_tabWidget, 1);
|
|
|
|
// ===== STATUS TAB =====
|
|
auto *statusTab = new QWidget;
|
|
auto *statusLayout = new QVBoxLayout(statusTab);
|
|
statusLayout->setContentsMargins(0, 0, 0, 0);
|
|
statusLayout->setSpacing(4);
|
|
|
|
// Toolbar
|
|
auto *toolbar = new QHBoxLayout;
|
|
toolbar->setSpacing(2);
|
|
toolbar->setContentsMargins(0, 0, 0, 0);
|
|
|
|
m_refreshBtn = makeToolButton(QStringLiteral("view-refresh"), QStringLiteral("Refresh"));
|
|
m_stageBtn = makeToolButton(QStringLiteral("list-add"), QStringLiteral("Stage"));
|
|
m_unstageBtn = makeToolButton(QStringLiteral("list-remove"), QStringLiteral("Unstage"));
|
|
m_discardBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Discard"));
|
|
auto *diffBtn = makeToolButton(QStringLiteral("vcs-diff"), QStringLiteral("Diff selected (or all)"));
|
|
auto *diffAllBtn = makeToolButton(QStringLiteral("view-split-left-right"), QStringLiteral("Diff entire changeset"));
|
|
m_commitBtn = makeToolButton(QStringLiteral("vcs-commit"), QStringLiteral("Commit"));
|
|
m_pushBtn = makeToolButton(QStringLiteral("vcs-push"), QStringLiteral("Push"));
|
|
m_pullBtn = makeToolButton(QStringLiteral("vcs-pull"), QStringLiteral("Pull"));
|
|
m_fetchBtn = makeToolButton(QStringLiteral("download"), QStringLiteral("Fetch"));
|
|
m_stashBtn = makeToolButton(QStringLiteral("archive-insert"), QStringLiteral("Stash…"));
|
|
auto *openCommitBtn = makeToolButton(QStringLiteral("vcs-diff"), QStringLiteral("Open commit…"));
|
|
|
|
toolbar->addWidget(m_refreshBtn);
|
|
toolbar->addWidget(m_stageBtn);
|
|
toolbar->addWidget(m_unstageBtn);
|
|
toolbar->addWidget(m_discardBtn);
|
|
toolbar->addWidget(diffBtn);
|
|
toolbar->addWidget(diffAllBtn);
|
|
toolbar->addWidget(m_commitBtn);
|
|
toolbar->addWidget(m_pushBtn);
|
|
toolbar->addWidget(m_pullBtn);
|
|
toolbar->addWidget(m_fetchBtn);
|
|
toolbar->addWidget(m_stashBtn);
|
|
toolbar->addWidget(openCommitBtn);
|
|
toolbar->addStretch();
|
|
statusLayout->addLayout(toolbar);
|
|
|
|
// Splitter: tree + commit msg
|
|
auto *splitter = new QSplitter(Qt::Vertical, statusTab);
|
|
|
|
// Tree view with filter
|
|
auto *treeContainer = new QWidget(splitter);
|
|
auto *treeLayout = new QVBoxLayout(treeContainer);
|
|
treeLayout->setContentsMargins(0, 0, 0, 0);
|
|
treeLayout->setSpacing(2);
|
|
|
|
m_filterEdit = new QLineEdit(treeContainer);
|
|
m_filterEdit->setPlaceholderText(QStringLiteral("Filter files…"));
|
|
m_filterEdit->setClearButtonEnabled(true);
|
|
treeLayout->addWidget(m_filterEdit);
|
|
|
|
m_treeView = new QTreeView(treeContainer);
|
|
m_treeView->setHeaderHidden(true);
|
|
m_treeView->setSelectionMode(QTreeView::ExtendedSelection);
|
|
m_treeView->setRootIsDecorated(true);
|
|
m_treeView->setUniformRowHeights(true);
|
|
treeLayout->addWidget(m_treeView);
|
|
|
|
// Commit area
|
|
auto *commitContainer = new QWidget(splitter);
|
|
auto *commitLayout = new QVBoxLayout(commitContainer);
|
|
commitLayout->setContentsMargins(0, 0, 0, 0);
|
|
commitLayout->setSpacing(2);
|
|
|
|
m_commitMsgEdit = new QTextEdit(commitContainer);
|
|
m_commitMsgEdit->setPlaceholderText(QStringLiteral("Commit message…"));
|
|
m_commitMsgEdit->setAcceptRichText(false);
|
|
m_commitMsgEdit->setMaximumHeight(80);
|
|
commitLayout->addWidget(m_commitMsgEdit);
|
|
|
|
m_amendCheck = new QCheckBox(QStringLiteral("Amend last commit"), commitContainer);
|
|
commitLayout->addWidget(m_amendCheck);
|
|
|
|
splitter->addWidget(treeContainer);
|
|
splitter->addWidget(commitContainer);
|
|
splitter->setStretchFactor(0, 4);
|
|
splitter->setStretchFactor(1, 1);
|
|
statusLayout->addWidget(splitter, 1);
|
|
|
|
m_tabWidget->addTab(statusTab, QIcon::fromTheme(QStringLiteral("vcs-locally-modified")), QStringLiteral("Status"));
|
|
|
|
// ===== BRANCHES TAB =====
|
|
auto *branchTab = new QWidget;
|
|
auto *branchLayout = new QVBoxLayout(branchTab);
|
|
branchLayout->setContentsMargins(0, 0, 0, 0);
|
|
branchLayout->setSpacing(4);
|
|
|
|
// Branch toolbar
|
|
auto *branchToolbar = new QHBoxLayout;
|
|
branchToolbar->setSpacing(2);
|
|
branchToolbar->setContentsMargins(0, 0, 0, 0);
|
|
|
|
m_branchCheckoutBtn = makeToolButton(QStringLiteral("vcs-branch"), QStringLiteral("Checkout"));
|
|
m_branchCreateBtn = makeToolButton(QStringLiteral("list-add"), QStringLiteral("Create branch"));
|
|
m_branchDeleteBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Delete branch"));
|
|
m_branchCompareBtn = makeToolButton(QStringLiteral("view-split-left-right"), QStringLiteral("Compare with current"));
|
|
auto *worktreeBtn = makeToolButton(QStringLiteral("folder-new"), QStringLiteral("Create worktree from branch"));
|
|
|
|
branchToolbar->addWidget(m_branchCheckoutBtn);
|
|
branchToolbar->addWidget(m_branchCreateBtn);
|
|
branchToolbar->addWidget(m_branchDeleteBtn);
|
|
branchToolbar->addWidget(m_branchCompareBtn);
|
|
branchToolbar->addWidget(worktreeBtn);
|
|
branchToolbar->addStretch();
|
|
branchLayout->addLayout(branchToolbar);
|
|
|
|
// Filter
|
|
m_branchFilterEdit = new QLineEdit(branchTab);
|
|
m_branchFilterEdit->setPlaceholderText(QStringLiteral("Filter branches…"));
|
|
m_branchFilterEdit->setClearButtonEnabled(true);
|
|
branchLayout->addWidget(m_branchFilterEdit);
|
|
|
|
// Branch list
|
|
m_branchList = new QListWidget(branchTab);
|
|
m_branchList->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
branchLayout->addWidget(m_branchList, 1);
|
|
|
|
OptionalTab branchOpt; branchOpt.widget = branchTab; branchOpt.icon = QIcon::fromTheme(QStringLiteral("vcs-branch")); branchOpt.label = QStringLiteral("Branches"); branchOpt.visible = true;
|
|
m_optionalTabs.append(branchOpt);
|
|
|
|
// ===== STASHES TAB =====
|
|
auto *stashTab = new QWidget;
|
|
auto *stashLayout = new QVBoxLayout(stashTab);
|
|
stashLayout->setContentsMargins(0, 0, 0, 0);
|
|
stashLayout->setSpacing(4);
|
|
|
|
auto *stashToolbar = new QHBoxLayout;
|
|
stashToolbar->setSpacing(2);
|
|
stashToolbar->setContentsMargins(0, 0, 0, 0);
|
|
|
|
m_stashSaveBtn = makeToolButton(QStringLiteral("list-add"), QStringLiteral("Stash save"));
|
|
m_stashApplyBtn = makeToolButton(QStringLiteral("dialog-ok-apply"), QStringLiteral("Apply selected stash"));
|
|
m_stashPopBtn = makeToolButton(QStringLiteral("archive-extract"), QStringLiteral("Pop selected stash"));
|
|
m_stashDropBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Drop selected stash"));
|
|
auto *stashDiffBtn = makeToolButton(QStringLiteral("vcs-diff"), QStringLiteral("Show diff of selected stash"));
|
|
|
|
stashToolbar->addWidget(m_stashSaveBtn);
|
|
stashToolbar->addWidget(m_stashApplyBtn);
|
|
stashToolbar->addWidget(m_stashPopBtn);
|
|
stashToolbar->addWidget(m_stashDropBtn);
|
|
stashToolbar->addWidget(stashDiffBtn);
|
|
stashToolbar->addStretch();
|
|
stashLayout->addLayout(stashToolbar);
|
|
|
|
m_stashList = new QListWidget(stashTab);
|
|
m_stashList->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
stashLayout->addWidget(m_stashList, 1);
|
|
|
|
OptionalTab stashOpt; stashOpt.widget = stashTab; stashOpt.icon = QIcon::fromTheme(QStringLiteral("archive-insert")); stashOpt.label = QStringLiteral("Stashes"); stashOpt.visible = true;
|
|
m_optionalTabs.append(stashOpt);
|
|
|
|
// ===== WORKTREES TAB =====
|
|
auto *worktreeTab = new QWidget;
|
|
auto *worktreeLayout = new QVBoxLayout(worktreeTab);
|
|
worktreeLayout->setContentsMargins(0, 0, 0, 0);
|
|
worktreeLayout->setSpacing(4);
|
|
|
|
auto *wtToolbar = new QHBoxLayout;
|
|
wtToolbar->setSpacing(2);
|
|
wtToolbar->setContentsMargins(0, 0, 0, 0);
|
|
|
|
auto *wtRefreshBtn = makeToolButton(QStringLiteral("view-refresh"), QStringLiteral("Refresh worktrees"));
|
|
auto *wtCreateBtn = makeToolButton(QStringLiteral("folder-new"), QStringLiteral("Create worktree"));
|
|
auto *wtRemoveBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Remove selected worktree"));
|
|
auto *wtPruneBtn = makeToolButton(QStringLiteral("edit-clear-all"), QStringLiteral("Prune stale worktrees"));
|
|
|
|
wtToolbar->addWidget(wtRefreshBtn);
|
|
wtToolbar->addWidget(wtCreateBtn);
|
|
wtToolbar->addWidget(wtRemoveBtn);
|
|
wtToolbar->addWidget(wtPruneBtn);
|
|
wtToolbar->addStretch();
|
|
worktreeLayout->addLayout(wtToolbar);
|
|
|
|
m_worktreeList = new QListWidget(worktreeTab);
|
|
m_worktreeList->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
worktreeLayout->addWidget(m_worktreeList, 1);
|
|
|
|
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);
|
|
logLayout->setContentsMargins(0, 0, 0, 0);
|
|
logLayout->setSpacing(4);
|
|
|
|
auto *logToolbar = new QHBoxLayout;
|
|
logToolbar->setSpacing(2);
|
|
logToolbar->setContentsMargins(0, 0, 0, 0);
|
|
|
|
auto *logRefreshBtn = makeToolButton(QStringLiteral("view-refresh"), QStringLiteral("Refresh log"));
|
|
auto *logDiffHeadBtn = makeToolButton(QStringLiteral("vcs-diff"), QStringLiteral("Diff selected commit against HEAD"));
|
|
auto *logShowBtn = makeToolButton(QStringLiteral("document-preview"), QStringLiteral("Show commit"));
|
|
|
|
logToolbar->addWidget(logRefreshBtn);
|
|
logToolbar->addWidget(logDiffHeadBtn);
|
|
logToolbar->addWidget(logShowBtn);
|
|
logToolbar->addStretch();
|
|
logLayout->addLayout(logToolbar);
|
|
|
|
m_logList = new QListWidget(logTab);
|
|
m_logList->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
QFont logFont = m_logList->font();
|
|
logFont.setFamily(QStringLiteral("monospace"));
|
|
logFont.setPointSizeF(logFont.pointSizeF() * 0.9);
|
|
m_logList->setFont(logFont);
|
|
logLayout->addWidget(m_logList, 1);
|
|
|
|
OptionalTab logOpt; logOpt.widget = logTab; logOpt.icon = QIcon::fromTheme(QStringLiteral("view-history")); logOpt.label = QStringLiteral("Log"); logOpt.visible = true;
|
|
m_optionalTabs.append(logOpt);
|
|
|
|
// --- Load saved tab visibility and build tabs ---
|
|
loadTabVisibility();
|
|
rebuildTabs();
|
|
|
|
// --- Tab bar context menu for toggling optional tabs ---
|
|
m_tabWidget->tabBar()->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
connect(m_tabWidget->tabBar(), &QWidget::customContextMenuRequested, this, &GitPlusPlusView::showTabContextMenu);
|
|
|
|
// --- Persist tab order on drag ---
|
|
connect(m_tabWidget->tabBar(), &QTabBar::tabMoved, this, [this]() { saveTabVisibility(); });
|
|
|
|
// --- Status label at bottom ---
|
|
m_statusLabel = new QLabel(container);
|
|
m_statusLabel->setWordWrap(true);
|
|
mainLayout->addWidget(m_statusLabel);
|
|
|
|
// --- Model setup ---
|
|
m_model = new GitStatusModel(this);
|
|
m_proxy = new QSortFilterProxyModel(this);
|
|
m_proxy->setSourceModel(m_model);
|
|
m_proxy->setFilterCaseSensitivity(Qt::CaseInsensitive);
|
|
m_proxy->setRecursiveFilteringEnabled(true);
|
|
m_treeView->setModel(m_proxy);
|
|
|
|
// --- Connections: Status tab ---
|
|
connect(m_refreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::updateStatus);
|
|
connect(m_stageBtn, &QToolButton::clicked, this, &GitPlusPlusView::stageSelected);
|
|
connect(m_unstageBtn, &QToolButton::clicked, this, &GitPlusPlusView::unstageSelected);
|
|
connect(m_discardBtn, &QToolButton::clicked, this, &GitPlusPlusView::discardSelected);
|
|
connect(diffBtn, &QToolButton::clicked, this, &GitPlusPlusView::diffSelected);
|
|
connect(diffAllBtn, &QToolButton::clicked, this, &GitPlusPlusView::diffAll);
|
|
connect(m_commitBtn, &QToolButton::clicked, this, &GitPlusPlusView::commitChanges);
|
|
connect(m_pushBtn, &QToolButton::clicked, this, &GitPlusPlusView::push);
|
|
connect(m_pullBtn, &QToolButton::clicked, this, &GitPlusPlusView::pull);
|
|
connect(m_fetchBtn, &QToolButton::clicked, this, &GitPlusPlusView::fetch);
|
|
connect(m_filterEdit, &QLineEdit::textChanged, m_proxy, &QSortFilterProxyModel::setFilterFixedString);
|
|
connect(m_filterEdit, &QLineEdit::textChanged, m_treeView, &QTreeView::expandAll);
|
|
|
|
// Stash toolbar button: quick stash save
|
|
connect(m_stashBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashSave);
|
|
|
|
// Open commit
|
|
connect(openCommitBtn, &QToolButton::clicked, this, &GitPlusPlusView::openCommit);
|
|
|
|
// --- Connections: Stashes tab ---
|
|
connect(m_stashSaveBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashSave);
|
|
connect(m_stashApplyBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashApply);
|
|
connect(m_stashPopBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashPop);
|
|
connect(m_stashDropBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashDrop);
|
|
connect(stashDiffBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashDiff);
|
|
connect(m_stashList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::stashContextMenu);
|
|
|
|
// --- Connections: Worktrees tab ---
|
|
connect(wtRefreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::refreshWorktrees);
|
|
connect(wtCreateBtn, &QToolButton::clicked, this, &GitPlusPlusView::createWorktreeFromTab);
|
|
connect(wtRemoveBtn, &QToolButton::clicked, this, &GitPlusPlusView::removeWorktree);
|
|
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<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 ---
|
|
connect(logRefreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::refreshLog);
|
|
connect(logDiffHeadBtn, &QToolButton::clicked, this, &GitPlusPlusView::logDiffAgainstHead);
|
|
connect(logShowBtn, &QToolButton::clicked, this, &GitPlusPlusView::logShowCommit);
|
|
connect(m_logList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { logDiffAgainstHead(); });
|
|
connect(m_logList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::logContextMenu);
|
|
connect(m_worktreeList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) {
|
|
if (!item) return;
|
|
QString path = item->text().section(QLatin1Char(' '), 0, 0).trimmed();
|
|
if (!path.isEmpty()) {
|
|
// Open .kateproject file from within the worktree to trigger project detection
|
|
QString kateProjectFile = path + QStringLiteral("/.kateproject");
|
|
if (QFileInfo::exists(kateProjectFile)) {
|
|
m_mainWindow->openUrl(QUrl::fromLocalFile(kateProjectFile));
|
|
} else {
|
|
// Fall back: open any existing file in the directory
|
|
QDir dir(path);
|
|
QStringList entries = dir.entryList(QDir::Files, QDir::Name);
|
|
if (!entries.isEmpty()) {
|
|
m_mainWindow->openUrl(QUrl::fromLocalFile(dir.absoluteFilePath(entries.first())));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Double-click to stage/unstage
|
|
connect(m_treeView, &QTreeView::doubleClicked, this, [this](const QModelIndex &idx) {
|
|
QModelIndex srcIdx = m_proxy->mapToSource(idx);
|
|
if (srcIdx.internalId() == 0xFFFFFFFF)
|
|
return;
|
|
int nodeType = int(srcIdx.internalId());
|
|
QString file = srcIdx.data(GitStatusModel::FilePathRole).toString();
|
|
if (file.isEmpty())
|
|
return;
|
|
if (nodeType == GitStatusModel::NodeStaged) {
|
|
runSimpleGit({QStringLiteral("reset"), QStringLiteral("HEAD"), QStringLiteral("--"), file},
|
|
QStringLiteral("Unstaged: ") + file, QStringLiteral("Failed to unstage: ") + file);
|
|
} else {
|
|
runSimpleGit({QStringLiteral("add"), QStringLiteral("--"), file},
|
|
QStringLiteral("Staged: ") + file, QStringLiteral("Failed to stage: ") + file);
|
|
}
|
|
});
|
|
|
|
// Amend checkbox: load last commit message when checked
|
|
connect(m_amendCheck, &QCheckBox::toggled, this, [this](bool checked) {
|
|
if (checked && m_commitMsgEdit->toPlainText().trimmed().isEmpty()) {
|
|
QByteArray msg = GitProcess::run(m_gitDir, {QStringLiteral("log"), QStringLiteral("-1"), QStringLiteral("--format=%B")});
|
|
if (!msg.isEmpty())
|
|
m_commitMsgEdit->setPlainText(QString::fromUtf8(msg).trimmed());
|
|
}
|
|
});
|
|
|
|
// --- Connections: Branches tab ---
|
|
connect(m_branchCheckoutBtn, &QToolButton::clicked, this, &GitPlusPlusView::checkoutBranch);
|
|
connect(m_branchCreateBtn, &QToolButton::clicked, this, &GitPlusPlusView::createBranch);
|
|
connect(m_branchDeleteBtn, &QToolButton::clicked, this, &GitPlusPlusView::deleteBranch);
|
|
connect(m_branchCompareBtn, &QToolButton::clicked, this, &GitPlusPlusView::compareBranch);
|
|
connect(worktreeBtn, &QToolButton::clicked, this, &GitPlusPlusView::createWorktree);
|
|
connect(m_branchList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::branchContextMenu);
|
|
connect(m_branchList, &QListWidget::itemDoubleClicked, this, &GitPlusPlusView::checkoutBranch);
|
|
|
|
// Branch filter
|
|
connect(m_branchFilterEdit, &QLineEdit::textChanged, this, [this](const QString &text) {
|
|
for (int i = 0; i < m_branchList->count(); ++i) {
|
|
auto *item = m_branchList->item(i);
|
|
item->setHidden(!item->text().contains(text, Qt::CaseInsensitive));
|
|
}
|
|
});
|
|
|
|
// Refresh everything when the active view changes (project might have changed)
|
|
connect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, [this]() {
|
|
QString newDir = projectBaseDir();
|
|
if (newDir.isEmpty() && !m_gitDir.isEmpty()) {
|
|
// Project closed — clear git state
|
|
m_gitDir.clear();
|
|
m_isWorkspace = false;
|
|
m_workspaceDir.clear();
|
|
m_workspaceSourceDir.clear();
|
|
m_workspaceRepos.clear();
|
|
m_repoCombo->hide();
|
|
m_repoCombo->clear();
|
|
m_model->parseStatus({});
|
|
m_worktreeList->clear();
|
|
m_workspaceList->clear();
|
|
m_branchList->clear();
|
|
m_stashList->clear();
|
|
m_logList->clear();
|
|
m_currentBranch.clear();
|
|
m_allBranches.clear();
|
|
m_remoteBranches.clear();
|
|
m_repoLabel->setText(QStringLiteral("No project"));
|
|
m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px; color: gray;"));
|
|
if (m_statusBarWidget)
|
|
m_statusBarWidget->setText(QStringLiteral("Git++"));
|
|
delete m_watcher;
|
|
m_watcher = nullptr;
|
|
return;
|
|
}
|
|
if (!newDir.isEmpty() && newDir != m_gitDir) {
|
|
m_gitDir = newDir;
|
|
detectWorkspace();
|
|
setupFileWatcher();
|
|
refreshAll();
|
|
}
|
|
});
|
|
|
|
// Debounce timer for filesystem/document changes (avoid spamming refreshes)
|
|
m_fsDebounce.setSingleShot(true);
|
|
m_fsDebounce.setInterval(500);
|
|
connect(&m_fsDebounce, &QTimer::timeout, this, &GitPlusPlusView::updateStatus);
|
|
|
|
// Refresh status when any document is saved — this is a concrete event
|
|
// that likely changes the working tree state.
|
|
auto connectDocSave = [this](KTextEditor::Document *doc) {
|
|
connect(doc, &KTextEditor::Document::documentSavedOrUploaded, this, [this]() {
|
|
m_fsDebounce.start();
|
|
});
|
|
};
|
|
connect(KTextEditor::Editor::instance(), &KTextEditor::Editor::documentCreated,
|
|
this, [this, connectDocSave](KTextEditor::Editor *, KTextEditor::Document *doc) {
|
|
connectDocSave(doc);
|
|
});
|
|
// Connect already-open documents
|
|
const auto docs = KTextEditor::Editor::instance()->documents();
|
|
for (auto *doc : docs) {
|
|
connectDocSave(doc);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
GitPlusPlusView::~GitPlusPlusView()
|
|
{
|
|
delete m_toolView;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helpers
|
|
// ============================================================================
|
|
|
|
QString GitPlusPlusView::projectBaseDir() const
|
|
{
|
|
QObject *projectView = m_mainWindow->pluginView(QStringLiteral("kateprojectplugin"));
|
|
if (projectView) {
|
|
QString dir = projectView->property("projectBaseDir").toString();
|
|
if (!dir.isEmpty())
|
|
return dir;
|
|
}
|
|
auto *view = m_mainWindow->activeView();
|
|
if (view && view->document() && !view->document()->url().toLocalFile().isEmpty())
|
|
return QFileInfo(view->document()->url().toLocalFile()).absolutePath();
|
|
return {};
|
|
}
|
|
|
|
QProcess *GitPlusPlusView::gitProcess(const QStringList &args) const
|
|
{
|
|
QString dir = m_gitDir.isEmpty() ? projectBaseDir() : m_gitDir;
|
|
if (dir.isEmpty())
|
|
return nullptr;
|
|
return GitProcess::create(dir, args, const_cast<GitPlusPlusView *>(this));
|
|
}
|
|
|
|
void GitPlusPlusView::setMessage(const QString &text, bool isError)
|
|
{
|
|
m_statusLabel->setText(text);
|
|
m_statusLabel->setStyleSheet(isError ? QStringLiteral("color: red;") : QStringLiteral("color: green;"));
|
|
}
|
|
|
|
void GitPlusPlusView::showDiffOutput(const QByteArray &diff, const QString &title, const QStringList &gitArgs, int extraFlags)
|
|
{
|
|
#ifdef HAVE_KATE_DIFF
|
|
DiffParams params;
|
|
params.tabTitle = title;
|
|
params.workingDir = m_gitDir;
|
|
params.arguments = gitArgs;
|
|
params.flags = DiffParams::Flag(DiffParams::ShowFileName | extraFlags);
|
|
params.updateStatusCallback = [this]() { updateStatus(); };
|
|
Utils::showDiff(diff, params, m_mainWindow);
|
|
#else
|
|
Q_UNUSED(title);
|
|
Q_UNUSED(gitArgs);
|
|
Q_UNUSED(extraFlags);
|
|
auto *view = m_mainWindow->openUrl(QUrl());
|
|
if (view) {
|
|
view->document()->setText(QString::fromUtf8(diff));
|
|
view->document()->setHighlightingMode(QStringLiteral("Diff"));
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void GitPlusPlusView::refreshAll()
|
|
{
|
|
updateStatus();
|
|
refreshBranches();
|
|
refreshStashes();
|
|
refreshWorktrees();
|
|
refreshWorkspaces();
|
|
refreshLog();
|
|
}
|
|
|
|
void GitPlusPlusView::rebuildTabs()
|
|
{
|
|
// Capture current visual order before rebuilding
|
|
QStringList currentOrder;
|
|
for (int i = 0; i < m_tabWidget->count(); ++i)
|
|
currentOrder.append(m_tabWidget->tabText(i));
|
|
|
|
// Re-sort m_optionalTabs to match current visual order (if any)
|
|
if (!currentOrder.isEmpty()) {
|
|
std::sort(m_optionalTabs.begin(), m_optionalTabs.end(),
|
|
[¤tOrder](const OptionalTab &a, const OptionalTab &b) {
|
|
int ia = currentOrder.indexOf(a.label);
|
|
int ib = currentOrder.indexOf(b.label);
|
|
if (ia < 0) ia = currentOrder.size();
|
|
if (ib < 0) ib = currentOrder.size();
|
|
return ia < ib;
|
|
});
|
|
}
|
|
|
|
// Remove all optional tabs (keep Status at index 0)
|
|
while (m_tabWidget->count() > 1)
|
|
m_tabWidget->removeTab(1);
|
|
|
|
for (const auto &tab : m_optionalTabs) {
|
|
if (tab.visible)
|
|
m_tabWidget->addTab(tab.widget, tab.icon, tab.label);
|
|
}
|
|
}
|
|
|
|
void GitPlusPlusView::showTabContextMenu(const QPoint &pos)
|
|
{
|
|
QMenu menu;
|
|
menu.setTitle(QStringLiteral("Visible Tabs"));
|
|
|
|
for (int i = 0; i < m_optionalTabs.size(); ++i) {
|
|
auto *action = menu.addAction(m_optionalTabs[i].label);
|
|
action->setCheckable(true);
|
|
action->setChecked(m_optionalTabs[i].visible);
|
|
connect(action, &QAction::toggled, this, [this, i](bool checked) {
|
|
m_optionalTabs[i].visible = checked;
|
|
rebuildTabs();
|
|
saveTabVisibility();
|
|
});
|
|
}
|
|
|
|
menu.exec(m_tabWidget->tabBar()->mapToGlobal(pos));
|
|
}
|
|
|
|
void GitPlusPlusView::saveTabVisibility()
|
|
{
|
|
QSettings settings(QStringLiteral("gitplusplus"), QStringLiteral("gitplusplus"));
|
|
settings.beginGroup(QStringLiteral("tabs"));
|
|
for (const auto &tab : m_optionalTabs)
|
|
settings.setValue(tab.label, tab.visible);
|
|
|
|
// Save tab order from current widget state
|
|
QStringList order;
|
|
for (int i = 0; i < m_tabWidget->count(); ++i)
|
|
order.append(m_tabWidget->tabText(i));
|
|
// Also append hidden tabs in their m_optionalTabs order (to keep stable ordering)
|
|
for (const auto &tab : m_optionalTabs) {
|
|
if (!tab.visible && !order.contains(tab.label))
|
|
order.append(tab.label);
|
|
}
|
|
settings.setValue(QStringLiteral("order"), order);
|
|
settings.endGroup();
|
|
}
|
|
|
|
void GitPlusPlusView::loadTabVisibility()
|
|
{
|
|
QSettings settings(QStringLiteral("gitplusplus"), QStringLiteral("gitplusplus"));
|
|
settings.beginGroup(QStringLiteral("tabs"));
|
|
for (auto &tab : m_optionalTabs) {
|
|
if (settings.contains(tab.label))
|
|
tab.visible = settings.value(tab.label).toBool();
|
|
}
|
|
|
|
// Restore tab order
|
|
QStringList order = settings.value(QStringLiteral("order")).toStringList();
|
|
if (!order.isEmpty()) {
|
|
std::sort(m_optionalTabs.begin(), m_optionalTabs.end(),
|
|
[&order](const OptionalTab &a, const OptionalTab &b) {
|
|
int ia = order.indexOf(a.label);
|
|
int ib = order.indexOf(b.label);
|
|
// Tabs not found in saved order go to the end
|
|
if (ia < 0) ia = order.size();
|
|
if (ib < 0) ib = order.size();
|
|
return ia < ib;
|
|
});
|
|
}
|
|
settings.endGroup();
|
|
}
|
|
|
|
void GitPlusPlusView::setupFileWatcher()
|
|
{
|
|
if (m_gitDir.isEmpty()) return;
|
|
|
|
// Remove old watcher
|
|
delete m_watcher;
|
|
m_watcher = new QFileSystemWatcher(this);
|
|
|
|
// Determine the actual .git directory
|
|
// For worktrees, .git is a file pointing to the real git dir
|
|
QString gitDir;
|
|
QString dotGit = m_gitDir + QStringLiteral("/.git");
|
|
QFileInfo dotGitInfo(dotGit);
|
|
if (dotGitInfo.isDir()) {
|
|
gitDir = dotGit;
|
|
} else if (dotGitInfo.isFile()) {
|
|
// Worktree: read gitdir path from .git file
|
|
QFile f(dotGit);
|
|
if (f.open(QIODevice::ReadOnly)) {
|
|
QString line = QString::fromUtf8(f.readLine()).trimmed();
|
|
if (line.startsWith(QStringLiteral("gitdir: ")))
|
|
gitDir = line.mid(8);
|
|
f.close();
|
|
}
|
|
}
|
|
|
|
if (gitDir.isEmpty()) return;
|
|
|
|
// Only watch refs/heads for branch creation/deletion — a concrete event.
|
|
// Do NOT watch the .git directory itself or the index file:
|
|
// those fire on every git operation (index.lock create/delete) and cause
|
|
// cascading git-status calls that leave index.lock files behind.
|
|
QString refsHeads = gitDir + QStringLiteral("/refs/heads");
|
|
if (QFileInfo::exists(refsHeads))
|
|
m_watcher->addPath(refsHeads);
|
|
|
|
connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString &) {
|
|
m_fsDebounce.start();
|
|
});
|
|
|
|
// Watch HEAD file only — changes indicate a branch switch (concrete event).
|
|
QString headFile = gitDir + QStringLiteral("/HEAD");
|
|
if (QFileInfo::exists(headFile)) {
|
|
m_watcher->addPath(headFile);
|
|
connect(m_watcher, &QFileSystemWatcher::fileChanged, this, [this, headFile](const QString &path) {
|
|
// Re-add: atomic renames remove the watch
|
|
if (!m_watcher->files().contains(path)) {
|
|
QTimer::singleShot(200, this, [this, path]() {
|
|
if (QFileInfo::exists(path) && m_watcher)
|
|
m_watcher->addPath(path);
|
|
});
|
|
}
|
|
if (path == headFile) {
|
|
// Branch switch — refresh branches and status after a short delay
|
|
m_fsDebounce.start();
|
|
QTimer::singleShot(600, this, [this]() { refreshBranches(); });
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void GitPlusPlusView::updateIndicators()
|
|
{
|
|
QString repoName;
|
|
if (!m_gitDir.isEmpty())
|
|
repoName = QFileInfo(m_gitDir).fileName();
|
|
|
|
m_isDirty = !m_model->entries(GitStatusModel::NodeStaged).isEmpty()
|
|
|| !m_model->entries(GitStatusModel::NodeChanged).isEmpty();
|
|
|
|
int stagedCount = m_model->entries(GitStatusModel::NodeStaged).size();
|
|
int changedCount = m_model->entries(GitStatusModel::NodeChanged).size();
|
|
int untrackedCount = m_model->entries(GitStatusModel::NodeUntracked).size();
|
|
|
|
bool detached = m_currentBranch.startsWith(QLatin1Char('('));
|
|
|
|
QString branchStr = m_currentBranch.isEmpty() ? QStringLiteral("(no branch)") : m_currentBranch;
|
|
|
|
if (detached) {
|
|
// Detached HEAD: prominent red/warning indicator
|
|
QString repoText = QStringLiteral("⚠ ") + repoName + QStringLiteral(" DETACHED ") + branchStr;
|
|
if (m_isDirty)
|
|
repoText += QStringLiteral(" (+%1 ~%2 ?%3)").arg(stagedCount).arg(changedCount).arg(untrackedCount);
|
|
m_repoLabel->setText(repoText);
|
|
m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px; color: #d04040; background: #402020;"));
|
|
|
|
if (m_statusBarWidget) {
|
|
QString statusText = QStringLiteral("⚠ DETACHED ") + branchStr;
|
|
if (m_isDirty)
|
|
statusText += QStringLiteral(" +%1 ~%2").arg(stagedCount).arg(changedCount);
|
|
m_statusBarWidget->setText(statusText);
|
|
m_statusBarWidget->setStyleSheet(QStringLiteral("color: #d04040; padding: 0px 6px; font-weight: bold;"));
|
|
}
|
|
} else {
|
|
QString dot = m_isDirty ? QStringLiteral("● ") : QStringLiteral("○ ");
|
|
QString repoText = dot + repoName + QStringLiteral(" ⎇ ") + branchStr;
|
|
if (m_isDirty)
|
|
repoText += QStringLiteral(" (+%1 ~%2 ?%3)").arg(stagedCount).arg(changedCount).arg(untrackedCount);
|
|
|
|
m_repoLabel->setText(repoText);
|
|
m_repoLabel->setStyleSheet(m_isDirty
|
|
? QStringLiteral("font-weight: bold; padding: 2px 4px; color: #e0a030;")
|
|
: QStringLiteral("font-weight: bold; padding: 2px 4px; color: #40a040;"));
|
|
|
|
if (m_statusBarWidget) {
|
|
QString statusText = QStringLiteral("⎇ ") + branchStr;
|
|
if (m_isDirty)
|
|
statusText += QStringLiteral(" +%1 ~%2").arg(stagedCount).arg(changedCount);
|
|
m_statusBarWidget->setText(statusText);
|
|
m_statusBarWidget->setStyleSheet(m_isDirty
|
|
? QStringLiteral("color: #e0a030; padding: 0px 6px;")
|
|
: QStringLiteral("color: #40a040; padding: 0px 6px;"));
|
|
}
|
|
}
|
|
}
|
|
|
|
QStringList GitPlusPlusView::selectedFiles(bool staged) const
|
|
{
|
|
QStringList files;
|
|
const auto indexes = m_treeView->selectionModel()->selectedIndexes();
|
|
for (const auto &proxyIdx : indexes) {
|
|
QModelIndex idx = m_proxy->mapToSource(proxyIdx);
|
|
if (idx.internalId() == 0xFFFFFFFF)
|
|
continue;
|
|
int nodeType = int(idx.internalId());
|
|
if (staged && nodeType != GitStatusModel::NodeStaged)
|
|
continue;
|
|
if (!staged && nodeType == GitStatusModel::NodeStaged)
|
|
continue;
|
|
QString file = idx.data(GitStatusModel::FilePathRole).toString();
|
|
if (!file.isEmpty())
|
|
files.append(file);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
QString GitPlusPlusView::selectedBranch() const
|
|
{
|
|
auto *item = m_branchList->currentItem();
|
|
if (!item)
|
|
return {};
|
|
QString text = item->text();
|
|
// Remove leading "* " for current branch
|
|
if (text.startsWith(QStringLiteral("* ")))
|
|
text = text.mid(2);
|
|
return text.trimmed();
|
|
}
|
|
|
|
QString GitPlusPlusView::selectedStash() const
|
|
{
|
|
auto *item = m_stashList->currentItem();
|
|
if (!item)
|
|
return {};
|
|
// Extract stash ref (e.g. "stash@{0}") from the line
|
|
QString text = item->text();
|
|
int colonIdx = text.indexOf(QLatin1Char(':'));
|
|
if (colonIdx > 0)
|
|
return text.left(colonIdx).trimmed();
|
|
return text.trimmed();
|
|
}
|
|
|
|
void GitPlusPlusView::runSimpleGit(const QStringList &args, const QString &successMsg, const QString &errorMsg)
|
|
{
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) {
|
|
setMessage(QStringLiteral("No git directory found"), true);
|
|
return;
|
|
}
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, successMsg, errorMsg](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(successMsg);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(errorMsg + QStringLiteral(": ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::setRemoteBusy(bool busy, const QString &opName)
|
|
{
|
|
static const QString spinChars = QStringLiteral("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏");
|
|
|
|
m_pushBtn->setEnabled(!busy);
|
|
m_pullBtn->setEnabled(!busy);
|
|
m_fetchBtn->setEnabled(!busy);
|
|
|
|
if (busy) {
|
|
QChar frame = spinChars.at(m_spinnerFrame % spinChars.size());
|
|
QString text = QStringLiteral("%1 %2…").arg(frame).arg(opName);
|
|
m_statusLabel->setText(text);
|
|
m_statusLabel->setStyleSheet(QStringLiteral("color: #4090d0; font-weight: bold;"));
|
|
if (m_statusBarWidget) {
|
|
m_statusBarWidget->setText(QStringLiteral("%1 %2…").arg(frame).arg(opName));
|
|
m_statusBarWidget->setStyleSheet(QStringLiteral("color: #4090d0; padding: 0px 6px; font-weight: bold;"));
|
|
}
|
|
}
|
|
}
|
|
|
|
void GitPlusPlusView::runRemoteGit(const QStringList &args, const QString &opName)
|
|
{
|
|
if (m_remoteProc) {
|
|
setMessage(QStringLiteral("A remote operation is already running"), true);
|
|
return;
|
|
}
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) {
|
|
setMessage(QStringLiteral("No git directory found"), true);
|
|
return;
|
|
}
|
|
|
|
m_remoteProc = proc;
|
|
m_spinnerFrame = 0;
|
|
setRemoteBusy(true, opName);
|
|
|
|
auto *timer = new QTimer(proc);
|
|
timer->setInterval(100);
|
|
connect(timer, &QTimer::timeout, this, [this, opName]() {
|
|
m_spinnerFrame++;
|
|
setRemoteBusy(true, opName);
|
|
});
|
|
timer->start();
|
|
|
|
proc->setProcessChannelMode(QProcess::MergedChannels);
|
|
connect(proc, &QProcess::readyReadStandardError, this, [this, proc]() {
|
|
QString progress = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
if (!progress.isEmpty()) {
|
|
int lastCr = progress.lastIndexOf(QLatin1Char('\r'));
|
|
if (lastCr >= 0) progress = progress.mid(lastCr + 1);
|
|
int lastNl = progress.lastIndexOf(QLatin1Char('\n'));
|
|
if (lastNl >= 0) progress = progress.mid(lastNl + 1);
|
|
m_statusLabel->setText(progress);
|
|
}
|
|
});
|
|
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, opName](int exitCode, QProcess::ExitStatus) {
|
|
m_remoteProc = nullptr;
|
|
setRemoteBusy(false);
|
|
if (exitCode == 0) {
|
|
QString output = QString::fromUtf8(proc->readAll()).trimmed();
|
|
QString summary = opName + QStringLiteral(" ✓");
|
|
if (!output.isEmpty()) {
|
|
QStringList lines = output.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
|
|
if (!lines.isEmpty())
|
|
summary = opName + QStringLiteral(" ✓ ") + lines.last().trimmed();
|
|
}
|
|
setMessage(summary);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAll()).trimmed();
|
|
QStringList lines = err.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
|
|
QString lastLine = lines.isEmpty() ? err : lines.last().trimmed();
|
|
setMessage(opName + QStringLiteral(" ✗ ") + lastLine, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
|
|
proc->start();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Git operations
|
|
// ============================================================================
|
|
|
|
void GitPlusPlusView::updateStatus()
|
|
{
|
|
m_gitDir = projectBaseDir();
|
|
if (m_gitDir.isEmpty()) {
|
|
setMessage(QStringLiteral("No project directory found"), true);
|
|
return;
|
|
}
|
|
auto *proc = GitProcess::create(m_gitDir, {QStringLiteral("status"), QStringLiteral("--porcelain=v1")}, this);
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, &GitPlusPlusView::onStatusFinished);
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::onStatusFinished(int exitCode, QProcess::ExitStatus)
|
|
{
|
|
auto *proc = qobject_cast<QProcess *>(sender());
|
|
if (!proc) return;
|
|
|
|
if (exitCode == 0) {
|
|
m_model->parseStatus(proc->readAllStandardOutput());
|
|
m_treeView->expandAll();
|
|
updateIndicators();
|
|
setMessage(QStringLiteral("Status updated"));
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("git status failed: ") + err, true);
|
|
m_repoLabel->setText(QStringLiteral("✗ not a git repository"));
|
|
m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px; color: red;"));
|
|
if (m_statusBarWidget) {
|
|
m_statusBarWidget->setText(QStringLiteral("Git++ ✗"));
|
|
m_statusBarWidget->setStyleSheet(QStringLiteral("color: red; padding: 0px 6px;"));
|
|
}
|
|
}
|
|
proc->deleteLater();
|
|
}
|
|
|
|
void GitPlusPlusView::refreshBranches()
|
|
{
|
|
if (m_gitDir.isEmpty()) m_gitDir = projectBaseDir();
|
|
if (m_gitDir.isEmpty()) return;
|
|
|
|
auto *proc = GitProcess::create(m_gitDir, {QStringLiteral("branch"), QStringLiteral("-a"), QStringLiteral("--no-color")}, this);
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, &GitPlusPlusView::onBranchFinished);
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::onBranchFinished(int exitCode, QProcess::ExitStatus)
|
|
{
|
|
auto *proc = qobject_cast<QProcess *>(sender());
|
|
if (!proc) return;
|
|
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
m_allBranches.clear();
|
|
m_remoteBranches.clear();
|
|
QString current;
|
|
|
|
const auto lines = output.split('\n');
|
|
for (const auto &line : lines) {
|
|
QString branch = QString::fromUtf8(line).trimmed();
|
|
if (branch.isEmpty()) continue;
|
|
if (branch.startsWith(QLatin1Char('*'))) {
|
|
current = branch.mid(2);
|
|
branch = branch.mid(2);
|
|
}
|
|
// Skip HEAD pointer in remotes
|
|
if (branch.contains(QStringLiteral(" -> "))) continue;
|
|
// Strip "remotes/" prefix for display (show as origin/foo)
|
|
bool isRemote = false;
|
|
if (branch.startsWith(QStringLiteral("remotes/"))) {
|
|
branch = branch.mid(8); // len("remotes/") == 8
|
|
isRemote = true;
|
|
}
|
|
// Deduplicate: if local branch matches a remote, skip the remote
|
|
if (!m_allBranches.contains(branch)) {
|
|
m_allBranches.append(branch);
|
|
if (isRemote)
|
|
m_remoteBranches.append(branch);
|
|
}
|
|
}
|
|
|
|
// Update branch list widget
|
|
m_branchList->clear();
|
|
for (const auto &b : m_allBranches) {
|
|
auto *item = new QListWidgetItem(b, m_branchList);
|
|
// Mark remote branches (those that came from remotes/ prefix)
|
|
item->setData(Qt::UserRole, m_remoteBranches.contains(b));
|
|
if (b == current) {
|
|
item->setText(QStringLiteral("* ") + b);
|
|
QFont f = item->font();
|
|
f.setBold(true);
|
|
item->setFont(f);
|
|
}
|
|
}
|
|
|
|
// Re-apply filter
|
|
QString filter = m_branchFilterEdit->text();
|
|
if (!filter.isEmpty()) {
|
|
for (int i = 0; i < m_branchList->count(); ++i) {
|
|
auto *item = m_branchList->item(i);
|
|
item->setHidden(!item->text().contains(filter, Qt::CaseInsensitive));
|
|
}
|
|
}
|
|
|
|
m_currentBranch = current;
|
|
updateIndicators();
|
|
}
|
|
proc->deleteLater();
|
|
}
|
|
|
|
void GitPlusPlusView::stageSelected()
|
|
{
|
|
QStringList files = selectedFiles(false);
|
|
if (files.isEmpty()) {
|
|
setMessage(QStringLiteral("No unstaged files selected"), true);
|
|
return;
|
|
}
|
|
QStringList args = {QStringLiteral("add"), QStringLiteral("--")};
|
|
args.append(files);
|
|
runSimpleGit(args, QStringLiteral("Staged %1 file(s)").arg(files.size()),
|
|
QStringLiteral("Failed to stage"));
|
|
}
|
|
|
|
void GitPlusPlusView::unstageSelected()
|
|
{
|
|
QStringList files = selectedFiles(true);
|
|
if (files.isEmpty()) {
|
|
setMessage(QStringLiteral("No staged files selected"), true);
|
|
return;
|
|
}
|
|
QStringList args = {QStringLiteral("reset"), QStringLiteral("HEAD"), QStringLiteral("--")};
|
|
args.append(files);
|
|
runSimpleGit(args, QStringLiteral("Unstaged %1 file(s)").arg(files.size()),
|
|
QStringLiteral("Failed to unstage"));
|
|
}
|
|
|
|
void GitPlusPlusView::discardSelected()
|
|
{
|
|
QStringList files = selectedFiles(false);
|
|
if (files.isEmpty()) {
|
|
setMessage(QStringLiteral("No changed files selected"), true);
|
|
return;
|
|
}
|
|
if (QMessageBox::question(m_toolView, QStringLiteral("Discard Changes"),
|
|
QStringLiteral("Discard changes in %1 file(s)? This cannot be undone.").arg(files.size()),
|
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes)
|
|
return;
|
|
|
|
QStringList args = {QStringLiteral("checkout"), QStringLiteral("--")};
|
|
args.append(files);
|
|
runSimpleGit(args, QStringLiteral("Discarded %1 file(s)").arg(files.size()),
|
|
QStringLiteral("Failed to discard"));
|
|
}
|
|
|
|
void GitPlusPlusView::diffSelected()
|
|
{
|
|
// Get all selected file entries (from any section)
|
|
QStringList stagedFiles;
|
|
QStringList unstagedFiles;
|
|
const auto indexes = m_treeView->selectionModel()->selectedIndexes();
|
|
for (const auto &proxyIdx : indexes) {
|
|
QModelIndex idx = m_proxy->mapToSource(proxyIdx);
|
|
if (idx.internalId() == 0xFFFFFFFF)
|
|
continue; // skip top-level category nodes
|
|
int nodeType = int(idx.internalId());
|
|
QString file = idx.data(GitStatusModel::FilePathRole).toString();
|
|
if (file.isEmpty())
|
|
continue;
|
|
if (nodeType == GitStatusModel::NodeStaged)
|
|
stagedFiles.append(file);
|
|
else if (nodeType == GitStatusModel::NodeChanged)
|
|
unstagedFiles.append(file);
|
|
// Untracked files: show as new file diff
|
|
else if (nodeType == GitStatusModel::NodeUntracked)
|
|
unstagedFiles.append(file);
|
|
}
|
|
|
|
// If nothing selected, fall through to diffAll
|
|
if (stagedFiles.isEmpty() && unstagedFiles.isEmpty()) {
|
|
diffAll();
|
|
return;
|
|
}
|
|
|
|
// Show staged diff for selected staged files
|
|
if (!stagedFiles.isEmpty()) {
|
|
QStringList args = {QStringLiteral("diff"), QStringLiteral("--cached"), QStringLiteral("--")};
|
|
args.append(stagedFiles);
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, stagedFiles, args](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
if (!output.trimmed().isEmpty()) {
|
|
QString title = stagedFiles.size() == 1
|
|
? QStringLiteral("Staged: %1").arg(stagedFiles.first())
|
|
: QStringLiteral("Staged changes (%1 files)").arg(stagedFiles.size());
|
|
#ifdef HAVE_KATE_DIFF
|
|
showDiffOutput(output, title, args,
|
|
DiffParams::ShowUnstage | DiffParams::ReloadOnShow);
|
|
#else
|
|
showDiffOutput(output, title, args);
|
|
#endif
|
|
} else {
|
|
setMessage(QStringLiteral("No staged diff for selection"));
|
|
}
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
// Show unstaged diff for selected changed/untracked files
|
|
if (!unstagedFiles.isEmpty()) {
|
|
QStringList args = {QStringLiteral("diff"), QStringLiteral("--")};
|
|
args.append(unstagedFiles);
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, unstagedFiles, args](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
if (!output.trimmed().isEmpty()) {
|
|
QString title = unstagedFiles.size() == 1
|
|
? QStringLiteral("Unstaged: %1").arg(unstagedFiles.first())
|
|
: QStringLiteral("Unstaged changes (%1 files)").arg(unstagedFiles.size());
|
|
#ifdef HAVE_KATE_DIFF
|
|
showDiffOutput(output, title, args,
|
|
DiffParams::ShowStage | DiffParams::ShowDiscard | DiffParams::ReloadOnShow);
|
|
#else
|
|
showDiffOutput(output, title, args);
|
|
#endif
|
|
} else {
|
|
setMessage(QStringLiteral("No unstaged diff for selection"));
|
|
}
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
}
|
|
|
|
void GitPlusPlusView::diffAll()
|
|
{
|
|
if (m_gitDir.isEmpty()) {
|
|
setMessage(QStringLiteral("No git directory found"), true);
|
|
return;
|
|
}
|
|
|
|
// Show combined diff: staged + unstaged in one view
|
|
// Use "git diff HEAD" which shows all changes (staged and unstaged) relative to HEAD
|
|
QStringList args = {QStringLiteral("diff"), QStringLiteral("HEAD")};
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, args](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
if (output.trimmed().isEmpty()) {
|
|
setMessage(QStringLiteral("No changes to diff"));
|
|
} else {
|
|
showDiffOutput(output, QStringLiteral("All changes"), args);
|
|
setMessage(QStringLiteral("Showing all changes"));
|
|
}
|
|
} else {
|
|
// HEAD might not exist (initial commit) — fall back to staged + unstaged separately
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
if (err.contains(QStringLiteral("unknown revision"))) {
|
|
// No commits yet — show diff of staged (index vs empty)
|
|
auto *proc2 = gitProcess({QStringLiteral("diff"), QStringLiteral("--cached")});
|
|
if (proc2) {
|
|
connect(proc2, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc2](int exitCode2, QProcess::ExitStatus) {
|
|
if (exitCode2 == 0) {
|
|
QByteArray out2 = proc2->readAllStandardOutput();
|
|
if (!out2.trimmed().isEmpty()) {
|
|
showDiffOutput(out2, QStringLiteral("All changes (initial commit)"),
|
|
{QStringLiteral("diff"), QStringLiteral("--cached")});
|
|
} else {
|
|
setMessage(QStringLiteral("No changes to diff"));
|
|
}
|
|
}
|
|
proc2->deleteLater();
|
|
});
|
|
proc2->start();
|
|
}
|
|
} else {
|
|
setMessage(QStringLiteral("Diff failed: ") + err, true);
|
|
}
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::commitChanges()
|
|
{
|
|
QString msg = m_commitMsgEdit->toPlainText().trimmed();
|
|
if (msg.isEmpty()) {
|
|
setMessage(QStringLiteral("Commit message is empty"), true);
|
|
return;
|
|
}
|
|
|
|
bool amend = m_amendCheck->isChecked();
|
|
if (!amend && m_model->entries(GitStatusModel::NodeStaged).isEmpty()) {
|
|
setMessage(QStringLiteral("Nothing staged to commit"), true);
|
|
return;
|
|
}
|
|
|
|
QStringList args = {QStringLiteral("commit")};
|
|
if (amend)
|
|
args.append(QStringLiteral("--amend"));
|
|
args.append({QStringLiteral("-m"), msg});
|
|
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) {
|
|
setMessage(QStringLiteral("No git directory found"), true);
|
|
return;
|
|
}
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, amend](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(amend ? QStringLiteral("Amended successfully") : QStringLiteral("Committed successfully"));
|
|
m_commitMsgEdit->clear();
|
|
m_amendCheck->setChecked(false);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Commit failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::push()
|
|
{
|
|
runRemoteGit({QStringLiteral("push"), QStringLiteral("--progress")}, QStringLiteral("Push"));
|
|
}
|
|
|
|
void GitPlusPlusView::pull()
|
|
{
|
|
runRemoteGit({QStringLiteral("pull"), QStringLiteral("--progress")}, QStringLiteral("Pull"));
|
|
}
|
|
|
|
void GitPlusPlusView::fetch()
|
|
{
|
|
runRemoteGit({QStringLiteral("fetch"), QStringLiteral("--all"), QStringLiteral("--progress")}, QStringLiteral("Fetch"));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Branch operations
|
|
// ============================================================================
|
|
|
|
void GitPlusPlusView::checkoutBranch()
|
|
{
|
|
QString branch = selectedBranch();
|
|
if (branch.isEmpty()) {
|
|
setMessage(QStringLiteral("No branch selected"), true);
|
|
return;
|
|
}
|
|
if (branch == m_currentBranch) {
|
|
setMessage(QStringLiteral("Already on ") + branch, true);
|
|
return;
|
|
}
|
|
|
|
// For remote branches (origin/...), create a local tracking branch
|
|
QStringList args;
|
|
if (branch.startsWith(QStringLiteral("origin/")) && branch != m_currentBranch) {
|
|
// origin/feature -> feature
|
|
QString localName = branch.section(QLatin1Char('/'), 1);
|
|
args << QStringLiteral("checkout") << QStringLiteral("-b") << localName << QStringLiteral("--track") << branch;
|
|
} else {
|
|
args << QStringLiteral("checkout") << branch;
|
|
}
|
|
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, branch](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Checked out: ") + branch);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Checkout failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::createBranch()
|
|
{
|
|
bool ok;
|
|
QString name = QInputDialog::getText(m_toolView, QStringLiteral("Create Branch"),
|
|
QStringLiteral("New branch from '%1':").arg(m_currentBranch),
|
|
QLineEdit::Normal, QString(), &ok);
|
|
if (!ok || name.trimmed().isEmpty()) return;
|
|
name = name.trimmed();
|
|
|
|
auto *proc = gitProcess({QStringLiteral("checkout"), QStringLiteral("-b"), name});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, name](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Created and switched to: ") + name);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Create failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::deleteBranch()
|
|
{
|
|
QString branch = selectedBranch();
|
|
if (branch.isEmpty()) {
|
|
setMessage(QStringLiteral("No branch selected"), true);
|
|
return;
|
|
}
|
|
if (branch == m_currentBranch) {
|
|
setMessage(QStringLiteral("Cannot delete current branch"), true);
|
|
return;
|
|
}
|
|
|
|
// Detect if this is a remote branch via the UserRole flag
|
|
auto *item = m_branchList->currentItem();
|
|
bool isRemote = item && item->data(Qt::UserRole).toBool();
|
|
|
|
if (isRemote) {
|
|
// Remote branch: extract remote name and branch name (e.g. "origin/feature" -> "origin", "feature")
|
|
int sep = branch.indexOf(QLatin1Char('/'));
|
|
if (sep <= 0) {
|
|
setMessage(QStringLiteral("Cannot determine remote for branch: ") + branch, true);
|
|
return;
|
|
}
|
|
QString remote = branch.left(sep);
|
|
QString remoteBranch = branch.mid(sep + 1);
|
|
|
|
if (QMessageBox::question(m_toolView, QStringLiteral("Delete Remote Branch"),
|
|
QStringLiteral("Delete remote branch '%1' from '%2'?\n\nThis will remove it from the remote server.").arg(remoteBranch, remote),
|
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes)
|
|
return;
|
|
|
|
auto *proc = gitProcess({QStringLiteral("push"), QStringLiteral("--delete"), remote, remoteBranch});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, branch](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Deleted remote branch: ") + branch);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Delete failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
} else {
|
|
// Local branch
|
|
if (QMessageBox::question(m_toolView, QStringLiteral("Delete Branch"),
|
|
QStringLiteral("Delete local branch '%1'?").arg(branch),
|
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes)
|
|
return;
|
|
|
|
// Use -D for force delete (handles unmerged branches)
|
|
auto *proc = gitProcess({QStringLiteral("branch"), QStringLiteral("-D"), branch});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, branch](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Deleted branch: ") + branch);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Delete failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
}
|
|
|
|
void GitPlusPlusView::compareBranch()
|
|
{
|
|
QString branch = selectedBranch();
|
|
if (branch.isEmpty()) {
|
|
setMessage(QStringLiteral("No branch selected"), true);
|
|
return;
|
|
}
|
|
if (branch == m_currentBranch) {
|
|
setMessage(QStringLiteral("Select a different branch to compare"), true);
|
|
return;
|
|
}
|
|
|
|
// Run git diff and show in a new document
|
|
auto *proc = gitProcess({QStringLiteral("diff"), m_currentBranch + QStringLiteral("...") + branch});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, branch](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray diff = proc->readAllStandardOutput();
|
|
if (diff.trimmed().isEmpty()) {
|
|
setMessage(QStringLiteral("No differences between %1 and %2").arg(m_currentBranch, branch));
|
|
} else {
|
|
showDiffOutput(diff, QStringLiteral("Diff: %1...%2").arg(m_currentBranch, branch),
|
|
{QStringLiteral("diff"), m_currentBranch + QStringLiteral("...") + branch});
|
|
setMessage(QStringLiteral("Comparing %1...%2").arg(m_currentBranch, branch));
|
|
}
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Compare failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::createWorktree()
|
|
{
|
|
QString branch = selectedBranch();
|
|
if (branch.isEmpty()) {
|
|
setMessage(QStringLiteral("No branch selected"), true);
|
|
return;
|
|
}
|
|
|
|
// Worktree path: sibling directory named {repo}.{branch}
|
|
// e.g. repo at /home/user/src/myproject -> /home/user/src/myproject.branchname
|
|
QDir repoDir(m_gitDir);
|
|
QString repoName = repoDir.dirName();
|
|
QDir parentDir(m_gitDir);
|
|
parentDir.cdUp();
|
|
|
|
// Sanitize branch name for directory (replace / with -)
|
|
QString branchDir = branch;
|
|
branchDir.replace(QLatin1Char('/'), QLatin1Char('-'));
|
|
|
|
QString dirName = repoName + QLatin1Char('.') + branchDir;
|
|
QString worktreePath = parentDir.absoluteFilePath(dirName);
|
|
|
|
// Check if already exists
|
|
if (QFileInfo::exists(worktreePath)) {
|
|
setMessage(QStringLiteral("Directory already exists: ") + worktreePath, true);
|
|
return;
|
|
}
|
|
|
|
auto *proc = gitProcess({QStringLiteral("worktree"), QStringLiteral("add"), worktreePath, branch});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, worktreePath, branch](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
// Create .kateproject so Kate's project plugin detects the worktree
|
|
QFile kateProject(worktreePath + QStringLiteral("/.kateproject"));
|
|
if (!QFileInfo::exists(kateProject.fileName())) {
|
|
if (kateProject.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
|
kateProject.write("{\n \"name\": \"" + QFileInfo(worktreePath).fileName().toUtf8() + "\",\n \"files\": [ { \"git\": true } ]\n}\n");
|
|
kateProject.close();
|
|
}
|
|
}
|
|
setMessage(QStringLiteral("Worktree created: ") + worktreePath);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Worktree failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::branchContextMenu(const QPoint &pos)
|
|
{
|
|
auto *item = m_branchList->itemAt(pos);
|
|
if (!item) return;
|
|
|
|
m_branchList->setCurrentItem(item);
|
|
QString branch = selectedBranch();
|
|
if (branch.isEmpty()) return;
|
|
|
|
QMenu menu;
|
|
menu.addAction(QStringLiteral("Copy branch name"), this, [branch]() {
|
|
QApplication::clipboard()->setText(branch);
|
|
});
|
|
menu.addSeparator();
|
|
menu.addAction(QStringLiteral("Checkout"), this, [this]() { checkoutBranch(); });
|
|
menu.addAction(QStringLiteral("Compare with %1").arg(m_currentBranch), this, [this]() { compareBranch(); });
|
|
menu.addAction(QStringLiteral("Create worktree"), this, [this, branch]() {
|
|
// Use the captured branch directly — don't rely on currentItem()
|
|
if (branch.isEmpty()) return;
|
|
|
|
QDir repoDir(m_gitDir);
|
|
QString repoName = repoDir.dirName();
|
|
QDir parentDir(m_gitDir);
|
|
parentDir.cdUp();
|
|
|
|
QString branchDir = branch;
|
|
branchDir.replace(QLatin1Char('/'), QLatin1Char('-'));
|
|
QString worktreePath = parentDir.absoluteFilePath(repoName + QLatin1Char('.') + branchDir);
|
|
|
|
if (QFileInfo::exists(worktreePath)) {
|
|
setMessage(QStringLiteral("Directory already exists: ") + worktreePath, true);
|
|
return;
|
|
}
|
|
|
|
auto *proc = gitProcess({QStringLiteral("worktree"), QStringLiteral("add"), worktreePath, branch});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, worktreePath](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QFile kateProject(worktreePath + QStringLiteral("/.kateproject"));
|
|
if (!QFileInfo::exists(kateProject.fileName())) {
|
|
if (kateProject.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
|
kateProject.write("{\n \"name\": \"" + QFileInfo(worktreePath).fileName().toUtf8() + "\",\n \"files\": [ { \"git\": true } ]\n}\n");
|
|
kateProject.close();
|
|
}
|
|
}
|
|
setMessage(QStringLiteral("Worktree created: ") + worktreePath);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Worktree failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
});
|
|
if (branch != m_currentBranch)
|
|
menu.addAction(QStringLiteral("Delete"), this, [this]() { deleteBranch(); });
|
|
|
|
// "Soft Reset to Merge Base" — available when the selected branch is an ancestor of HEAD
|
|
// (i.e., current branch was forked from the selected branch)
|
|
if (branch != m_currentBranch && !m_gitDir.isEmpty()) {
|
|
QByteArray mergeBase = GitProcess::run(m_gitDir, {QStringLiteral("merge-base"), QStringLiteral("HEAD"), branch});
|
|
QString mergeBaseHash = QString::fromUtf8(mergeBase).trimmed();
|
|
if (!mergeBaseHash.isEmpty()) {
|
|
// Verify the merge-base is an ancestor of HEAD (not HEAD itself)
|
|
QByteArray headHash = GitProcess::run(m_gitDir, {QStringLiteral("rev-parse"), QStringLiteral("HEAD")});
|
|
QString head = QString::fromUtf8(headHash).trimmed();
|
|
if (mergeBaseHash != head) {
|
|
menu.addSeparator();
|
|
menu.addAction(QStringLiteral("Soft Reset to Merge Base"), this, [this, mergeBaseHash]() {
|
|
auto *proc = gitProcess({QStringLiteral("reset"), QStringLiteral("--soft"), mergeBaseHash});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, mergeBaseHash](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Soft reset to merge-base: ") + mergeBaseHash.left(10));
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Reset failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
menu.addSeparator();
|
|
menu.addAction(QStringLiteral("Create new branch…"), this, &GitPlusPlusView::createBranch);
|
|
|
|
menu.exec(m_branchList->mapToGlobal(pos));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Stash operations
|
|
// ============================================================================
|
|
|
|
void GitPlusPlusView::stashSave()
|
|
{
|
|
bool ok;
|
|
QString msg = QInputDialog::getText(m_toolView, QStringLiteral("Stash Save"),
|
|
QStringLiteral("Stash message (optional):"),
|
|
QLineEdit::Normal, QString(), &ok);
|
|
if (!ok) return;
|
|
|
|
QStringList args;
|
|
args << QStringLiteral("stash") << QStringLiteral("push");
|
|
if (!msg.trimmed().isEmpty())
|
|
args << QStringLiteral("-m") << msg.trimmed();
|
|
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Stashed changes"));
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Stash failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::stashApply()
|
|
{
|
|
QString stash = selectedStash();
|
|
if (stash.isEmpty()) {
|
|
setMessage(QStringLiteral("No stash selected"), true);
|
|
return;
|
|
}
|
|
|
|
auto *proc = gitProcess({QStringLiteral("stash"), QStringLiteral("apply"), stash});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, stash](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Applied: ") + stash);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Apply failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::stashPop()
|
|
{
|
|
QString stash = selectedStash();
|
|
if (stash.isEmpty()) {
|
|
// Default to top stash
|
|
stash = QStringLiteral("stash@{0}");
|
|
}
|
|
|
|
auto *proc = gitProcess({QStringLiteral("stash"), QStringLiteral("pop"), stash});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, stash](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Popped: ") + stash);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Pop failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::stashDrop()
|
|
{
|
|
QString stash = selectedStash();
|
|
if (stash.isEmpty()) {
|
|
setMessage(QStringLiteral("No stash selected"), true);
|
|
return;
|
|
}
|
|
|
|
if (QMessageBox::question(m_toolView, QStringLiteral("Drop Stash"),
|
|
QStringLiteral("Drop %1?").arg(stash),
|
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes)
|
|
return;
|
|
|
|
auto *proc = gitProcess({QStringLiteral("stash"), QStringLiteral("drop"), stash});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, stash](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Dropped: ") + stash);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Drop failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::refreshStashes()
|
|
{
|
|
if (m_gitDir.isEmpty()) m_gitDir = projectBaseDir();
|
|
if (m_gitDir.isEmpty()) return;
|
|
|
|
auto *proc = GitProcess::create(m_gitDir, {QStringLiteral("stash"), QStringLiteral("list")}, this);
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc](int exitCode, QProcess::ExitStatus) {
|
|
m_stashList->clear();
|
|
if (exitCode == 0) {
|
|
QString output = QString::fromUtf8(proc->readAllStandardOutput()).trimmed();
|
|
if (!output.isEmpty()) {
|
|
const auto lines = output.split(QLatin1Char('\n'));
|
|
for (const auto &line : lines) {
|
|
if (!line.trimmed().isEmpty())
|
|
m_stashList->addItem(line.trimmed());
|
|
}
|
|
}
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::stashDiff()
|
|
{
|
|
QString stash = selectedStash();
|
|
if (stash.isEmpty()) {
|
|
setMessage(QStringLiteral("No stash selected"), true);
|
|
return;
|
|
}
|
|
|
|
auto *proc = gitProcess({QStringLiteral("stash"), QStringLiteral("show"), QStringLiteral("-p"), stash});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, stash](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
if (output.trimmed().isEmpty()) {
|
|
setMessage(QStringLiteral("Empty stash diff"));
|
|
} else {
|
|
showDiffOutput(output, QStringLiteral("Diff - %1").arg(stash),
|
|
{QStringLiteral("stash"), QStringLiteral("show"), QStringLiteral("-p"), stash});
|
|
setMessage(QStringLiteral("Diff: ") + stash);
|
|
}
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Diff failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::stashContextMenu(const QPoint &pos)
|
|
{
|
|
auto *item = m_stashList->itemAt(pos);
|
|
if (!item) return;
|
|
m_stashList->setCurrentItem(item);
|
|
|
|
QMenu menu;
|
|
menu.addAction(QStringLiteral("Diff"), this, &GitPlusPlusView::stashDiff);
|
|
menu.addAction(QStringLiteral("Apply"), this, &GitPlusPlusView::stashApply);
|
|
menu.addAction(QStringLiteral("Pop"), this, &GitPlusPlusView::stashPop);
|
|
menu.addAction(QStringLiteral("Drop"), this, &GitPlusPlusView::stashDrop);
|
|
menu.exec(m_stashList->mapToGlobal(pos));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Open commit (log + diff)
|
|
// ============================================================================
|
|
|
|
void GitPlusPlusView::openCommit()
|
|
{
|
|
bool ok;
|
|
QString ref = QInputDialog::getText(m_toolView, QStringLiteral("Open Commit"),
|
|
QStringLiteral("Commit hash or ref (e.g. HEAD, HEAD~1):"),
|
|
QLineEdit::Normal, QStringLiteral("HEAD"), &ok);
|
|
if (!ok || ref.trimmed().isEmpty()) return;
|
|
ref = ref.trimmed();
|
|
|
|
auto *proc = gitProcess({QStringLiteral("show"), QStringLiteral("--stat"), QStringLiteral("--patch"), ref});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, ref](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
showDiffOutput(output, QStringLiteral("Commit: %1").arg(ref),
|
|
{QStringLiteral("show"), QStringLiteral("--stat"), QStringLiteral("--patch"), ref});
|
|
setMessage(QStringLiteral("Showing commit: ") + ref);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Failed to show commit: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Log operations
|
|
// ============================================================================
|
|
|
|
void GitPlusPlusView::refreshLog()
|
|
{
|
|
if (m_gitDir.isEmpty()) m_gitDir = projectBaseDir();
|
|
if (m_gitDir.isEmpty()) return;
|
|
|
|
auto *proc = GitProcess::create(m_gitDir, {
|
|
QStringLiteral("log"),
|
|
QStringLiteral("--oneline"),
|
|
QStringLiteral("--decorate=short"),
|
|
QStringLiteral("-200")
|
|
}, this);
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc](int exitCode, QProcess::ExitStatus) {
|
|
m_logList->clear();
|
|
if (exitCode == 0) {
|
|
QString output = QString::fromUtf8(proc->readAllStandardOutput()).trimmed();
|
|
if (!output.isEmpty()) {
|
|
const auto lines = output.split(QLatin1Char('\n'));
|
|
for (const auto &line : lines) {
|
|
QString entry = line.trimmed();
|
|
if (!entry.isEmpty()) {
|
|
auto *item = new QListWidgetItem(entry, m_logList);
|
|
// Store the short hash in UserRole for easy access
|
|
QString hash = entry.section(QLatin1Char(' '), 0, 0);
|
|
item->setData(Qt::UserRole, hash);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::logDiffAgainstHead()
|
|
{
|
|
auto *item = m_logList->currentItem();
|
|
if (!item) {
|
|
setMessage(QStringLiteral("No commit selected"), true);
|
|
return;
|
|
}
|
|
|
|
QString hash = item->data(Qt::UserRole).toString();
|
|
if (hash.isEmpty()) return;
|
|
|
|
// git diff <commit>..HEAD — shows what changed between that commit and HEAD
|
|
QStringList args = {QStringLiteral("diff"), hash + QStringLiteral("..HEAD")};
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, hash, args](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
if (output.trimmed().isEmpty()) {
|
|
setMessage(QStringLiteral("No differences between %1 and HEAD").arg(hash));
|
|
} else {
|
|
showDiffOutput(output, QStringLiteral("Diff: %1..HEAD").arg(hash), args);
|
|
setMessage(QStringLiteral("Diff: %1..HEAD").arg(hash));
|
|
}
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Diff failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::logShowCommit()
|
|
{
|
|
auto *item = m_logList->currentItem();
|
|
if (!item) {
|
|
setMessage(QStringLiteral("No commit selected"), true);
|
|
return;
|
|
}
|
|
|
|
QString hash = item->data(Qt::UserRole).toString();
|
|
if (hash.isEmpty()) return;
|
|
|
|
auto *proc = gitProcess({QStringLiteral("show"), QStringLiteral("--stat"), QStringLiteral("--patch"), hash});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, hash](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
QByteArray output = proc->readAllStandardOutput();
|
|
showDiffOutput(output, QStringLiteral("Commit: %1").arg(hash),
|
|
{QStringLiteral("show"), QStringLiteral("--stat"), QStringLiteral("--patch"), hash});
|
|
setMessage(QStringLiteral("Showing commit: ") + hash);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Failed to show commit: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::logContextMenu(const QPoint &pos)
|
|
{
|
|
auto *item = m_logList->itemAt(pos);
|
|
if (!item) return;
|
|
m_logList->setCurrentItem(item);
|
|
|
|
QString hash = item->data(Qt::UserRole).toString();
|
|
|
|
QMenu menu;
|
|
menu.addAction(QStringLiteral("Diff against HEAD"), this, &GitPlusPlusView::logDiffAgainstHead);
|
|
menu.addAction(QStringLiteral("Show commit"), this, &GitPlusPlusView::logShowCommit);
|
|
menu.addSeparator();
|
|
menu.addAction(QStringLiteral("Copy hash"), this, [hash]() {
|
|
QApplication::clipboard()->setText(hash);
|
|
});
|
|
menu.exec(m_logList->mapToGlobal(pos));
|
|
}
|
|
|
|
// ============================================================================
|
|
// Worktree operations
|
|
// ============================================================================
|
|
|
|
void GitPlusPlusView::createWorktreeFromTab()
|
|
{
|
|
bool ok;
|
|
QString branch = QInputDialog::getItem(m_toolView, QStringLiteral("Create Worktree"),
|
|
QStringLiteral("Branch (select existing or type new):"),
|
|
m_allBranches, 0, true, &ok);
|
|
if (!ok || branch.trimmed().isEmpty()) return;
|
|
branch = branch.trimmed();
|
|
|
|
// Determine worktree path: sibling of repo, named {repo}.{branch}
|
|
QDir repoDir(m_gitDir);
|
|
QString repoName = repoDir.dirName();
|
|
QDir parentDir(m_gitDir);
|
|
parentDir.cdUp();
|
|
|
|
QString branchDir = branch;
|
|
branchDir.replace(QLatin1Char('/'), QLatin1Char('-'));
|
|
QString worktreePath = parentDir.absoluteFilePath(repoName + QLatin1Char('.') + branchDir);
|
|
|
|
if (QFileInfo::exists(worktreePath)) {
|
|
setMessage(QStringLiteral("Directory already exists: ") + worktreePath, true);
|
|
return;
|
|
}
|
|
|
|
// Check if the branch exists
|
|
bool branchExists = m_allBranches.contains(branch);
|
|
|
|
// git worktree add <path> <branch> -- if branch exists
|
|
// git worktree add -b <branch> <path> -- if branch doesn't exist (creates it)
|
|
QStringList args;
|
|
if (branchExists) {
|
|
args << QStringLiteral("worktree") << QStringLiteral("add") << worktreePath << branch;
|
|
} else {
|
|
args << QStringLiteral("worktree") << QStringLiteral("add") << QStringLiteral("-b") << branch << worktreePath;
|
|
}
|
|
|
|
auto *proc = gitProcess(args);
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, worktreePath, branch, branchExists](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
// Create .kateproject for Kate detection
|
|
QFile kateProject(worktreePath + QStringLiteral("/.kateproject"));
|
|
if (!QFileInfo::exists(kateProject.fileName())) {
|
|
if (kateProject.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
|
kateProject.write("{\n \"name\": \"" + QFileInfo(worktreePath).fileName().toUtf8() + "\",\n \"files\": [ { \"git\": true } ]\n}\n");
|
|
kateProject.close();
|
|
}
|
|
}
|
|
QString msg = branchExists
|
|
? QStringLiteral("Worktree created: ") + worktreePath
|
|
: QStringLiteral("Worktree created (new branch '%1'): %2").arg(branch, worktreePath);
|
|
setMessage(msg);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Worktree failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::refreshWorktrees()
|
|
{
|
|
if (m_gitDir.isEmpty()) m_gitDir = projectBaseDir();
|
|
if (m_gitDir.isEmpty()) return;
|
|
|
|
auto *proc = GitProcess::create(m_gitDir, {QStringLiteral("worktree"), QStringLiteral("list")}, this);
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc](int exitCode, QProcess::ExitStatus) {
|
|
m_worktreeList->clear();
|
|
if (exitCode == 0) {
|
|
QString output = QString::fromUtf8(proc->readAllStandardOutput()).trimmed();
|
|
if (!output.isEmpty()) {
|
|
const auto lines = output.split(QLatin1Char('\n'));
|
|
for (const auto &line : lines) {
|
|
QString entry = line.trimmed();
|
|
if (!entry.isEmpty()) {
|
|
auto *item = new QListWidgetItem(entry, m_worktreeList);
|
|
// Mark the main worktree (bare) differently
|
|
if (entry.contains(QStringLiteral("(bare)"))) {
|
|
QFont f = item->font();
|
|
f.setItalic(true);
|
|
item->setFont(f);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
proc->deleteLater();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::removeWorktree()
|
|
{
|
|
auto *item = m_worktreeList->currentItem();
|
|
if (!item) {
|
|
setMessage(QStringLiteral("No worktree selected"), true);
|
|
return;
|
|
}
|
|
|
|
// Parse path from the worktree list line (first space-separated token)
|
|
QString path = item->text().section(QLatin1Char(' '), 0, 0).trimmed();
|
|
if (path.isEmpty()) return;
|
|
|
|
// Don't allow removing the main worktree
|
|
if (path == m_gitDir || item->text().contains(QStringLiteral("(bare)"))) {
|
|
setMessage(QStringLiteral("Cannot remove the main worktree"), true);
|
|
return;
|
|
}
|
|
|
|
if (QMessageBox::question(m_toolView, QStringLiteral("Remove Worktree"),
|
|
QStringLiteral("Remove worktree at:\n%1\n\nThis will delete the directory.").arg(path),
|
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes)
|
|
return;
|
|
|
|
auto *proc = gitProcess({QStringLiteral("worktree"), QStringLiteral("remove"), QStringLiteral("--force"), path});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc, path](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Removed worktree: ") + path);
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Remove failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::pruneWorktrees()
|
|
{
|
|
auto *proc = gitProcess({QStringLiteral("worktree"), QStringLiteral("prune")});
|
|
if (!proc) return;
|
|
connect(proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
|
|
this, [this, proc](int exitCode, QProcess::ExitStatus) {
|
|
if (exitCode == 0) {
|
|
setMessage(QStringLiteral("Pruned stale worktrees"));
|
|
} else {
|
|
QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed();
|
|
setMessage(QStringLiteral("Prune failed: ") + err, true);
|
|
}
|
|
proc->deleteLater();
|
|
refreshAll();
|
|
});
|
|
proc->start();
|
|
}
|
|
|
|
void GitPlusPlusView::worktreeContextMenu(const QPoint &pos)
|
|
{
|
|
auto *item = m_worktreeList->itemAt(pos);
|
|
if (!item) return;
|
|
m_worktreeList->setCurrentItem(item);
|
|
|
|
QString path = item->text().section(QLatin1Char(' '), 0, 0).trimmed();
|
|
bool isMain = item->text().contains(QStringLiteral("(bare)")) || path == m_gitDir;
|
|
|
|
QMenu menu;
|
|
menu.addAction(QStringLiteral("Open project"), this, [this, path]() {
|
|
QString kateProjectFile = path + QStringLiteral("/.kateproject");
|
|
if (QFileInfo::exists(kateProjectFile)) {
|
|
m_mainWindow->openUrl(QUrl::fromLocalFile(kateProjectFile));
|
|
} else {
|
|
QDir dir(path);
|
|
QStringList entries = dir.entryList(QDir::Files, QDir::Name);
|
|
if (!entries.isEmpty())
|
|
m_mainWindow->openUrl(QUrl::fromLocalFile(dir.absoluteFilePath(entries.first())));
|
|
}
|
|
});
|
|
menu.addAction(QStringLiteral("Copy path"), this, [path]() {
|
|
QApplication::clipboard()->setText(path);
|
|
});
|
|
if (!isMain) {
|
|
menu.addAction(QStringLiteral("Remove"), this, &GitPlusPlusView::removeWorktree);
|
|
}
|
|
menu.addSeparator();
|
|
menu.addAction(QStringLiteral("Prune stale"), this, &GitPlusPlusView::pruneWorktrees);
|
|
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();
|
|
m_repoLabel->setText(QStringLiteral("No git repository"));
|
|
m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px; color: gray;"));
|
|
if (m_statusBarWidget)
|
|
m_statusBarWidget->setText(QStringLiteral("Git++"));
|
|
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();
|
|
}
|
|
|
|
// Auto-generate .kateworkspace if missing
|
|
wsFile = m_workspaceDir + QStringLiteral("/.kateworkspace");
|
|
if (!QFileInfo::exists(wsFile)) {
|
|
QSettings ws(wsFile, QSettings::IniFormat);
|
|
ws.beginGroup(QStringLiteral("workspace"));
|
|
ws.setValue(QStringLiteral("source"), QStringLiteral("."));
|
|
ws.endGroup();
|
|
ws.sync();
|
|
}
|
|
|
|
// Auto-generate .kateproject in each repo subdir that lacks one
|
|
for (const auto &repoPath : qAsConst(m_workspaceRepos)) {
|
|
QString kateProjectFile = repoPath + QStringLiteral("/.kateproject");
|
|
if (!QFileInfo::exists(kateProjectFile)) {
|
|
QFile kp(kateProjectFile);
|
|
if (kp.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
|
QString repoName = QFileInfo(repoPath).fileName();
|
|
kp.write("{\n \"name\": \"" + repoName.toUtf8() + "\",\n \"files\": [ { \"git\": true } ]\n}\n");
|
|
kp.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
// --- Build dialog ---
|
|
QDialog dlg(m_toolView);
|
|
dlg.setWindowTitle(QStringLiteral("Create Workspace"));
|
|
dlg.setMinimumWidth(500);
|
|
|
|
auto *layout = new QVBoxLayout(&dlg);
|
|
|
|
// Source row
|
|
auto *sourceLabel = new QLabel(QStringLiteral("Source:"), &dlg);
|
|
auto *sourceEdit = new QLineEdit(&dlg);
|
|
sourceEdit->setText(m_workspaceSourceDir.isEmpty() ? m_workspaceDir : m_workspaceSourceDir);
|
|
sourceEdit->setPlaceholderText(QStringLiteral("/path/to/source/workspace"));
|
|
auto *browseBtn = new QPushButton(QStringLiteral("Browse..."), &dlg);
|
|
connect(browseBtn, &QPushButton::clicked, &dlg, [&dlg, sourceEdit]() {
|
|
QString dir = QFileDialog::getExistingDirectory(&dlg, QStringLiteral("Select source workspace"), sourceEdit->text());
|
|
if (!dir.isEmpty())
|
|
sourceEdit->setText(dir);
|
|
});
|
|
|
|
auto *sourceRow = new QHBoxLayout;
|
|
sourceRow->addWidget(sourceLabel);
|
|
sourceRow->addWidget(sourceEdit, 1);
|
|
sourceRow->addWidget(browseBtn);
|
|
layout->addLayout(sourceRow);
|
|
|
|
// Destination row
|
|
auto *destLabel = new QLabel(QStringLiteral("Destination:"), &dlg);
|
|
auto *destEdit = new QLineEdit(&dlg);
|
|
destEdit->setPlaceholderText(QStringLiteral("workspace-name (also used as branch name)"));
|
|
|
|
auto *destRow = new QHBoxLayout;
|
|
destRow->addWidget(destLabel);
|
|
destRow->addWidget(destEdit, 1);
|
|
layout->addLayout(destRow);
|
|
|
|
// Buttons
|
|
auto *btnLayout = new QHBoxLayout;
|
|
btnLayout->addStretch();
|
|
auto *okBtn = new QPushButton(QStringLiteral("OK"), &dlg);
|
|
auto *cancelBtn = new QPushButton(QStringLiteral("Cancel"), &dlg);
|
|
okBtn->setDefault(true);
|
|
btnLayout->addWidget(okBtn);
|
|
btnLayout->addWidget(cancelBtn);
|
|
layout->addLayout(btnLayout);
|
|
|
|
connect(okBtn, &QPushButton::clicked, &dlg, &QDialog::accept);
|
|
connect(cancelBtn, &QPushButton::clicked, &dlg, &QDialog::reject);
|
|
|
|
if (dlg.exec() != QDialog::Accepted) return;
|
|
|
|
QString sourceDir = sourceEdit->text().trimmed();
|
|
QString name = destEdit->text().trimmed();
|
|
if (sourceDir.isEmpty() || name.isEmpty()) {
|
|
setMessage(QStringLiteral("Source and destination are required"), true);
|
|
return;
|
|
}
|
|
|
|
// Verify source 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;
|
|
}
|
|
|
|
// 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 each repo subdir
|
|
for (const auto &repoName : sourceRepos) {
|
|
QString targetRepoPath = newWsPath + QLatin1Char('/') + repoName;
|
|
QString kpFile = targetRepoPath + QStringLiteral("/.kateproject");
|
|
if (QFileInfo::exists(targetRepoPath) && !QFileInfo::exists(kpFile)) {
|
|
QFile kp(kpFile);
|
|
if (kp.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
|
kp.write("{\n \"name\": \"" + repoName.toUtf8() + "\",\n \"files\": [ { \"git\": true } ]\n}\n");
|
|
kp.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"
|