kate-gitplusplus/gitplusplus.cpp

1552 lines
60 KiB
C++

#include "gitplusplus.h"
#include "gitprocess.h"
#include "gitstatusmodel.h"
#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 <QCheckBox>
#include <QClipboard>
#include <QComboBox>
#include <QDir>
#include <QFile>
#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 <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);
// --- 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);
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"));
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(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);
m_tabWidget->addTab(branchTab, QIcon::fromTheme(QStringLiteral("vcs-branch")), QStringLiteral("Branches"));
// ===== 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);
m_tabWidget->addTab(stashTab, QIcon::fromTheme(QStringLiteral("archive-insert")), QStringLiteral("Stashes"));
// ===== 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);
m_tabWidget->addTab(worktreeTab, QIcon::fromTheme(QStringLiteral("folder-sync")), QStringLiteral("Worktrees"));
// --- 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(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);
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() && newDir != m_gitDir) {
m_gitDir = newDir;
setupFileWatcher();
refreshAll();
}
});
// Debounce timer for filesystem changes (avoid spamming refreshes)
m_fsDebounce.setSingleShot(true);
m_fsDebounce.setInterval(500);
connect(&m_fsDebounce, &QTimer::timeout, this, &GitPlusPlusView::updateStatus);
// Initial refresh
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, [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::refreshAll()
{
updateStatus();
refreshBranches();
refreshStashes();
refreshWorktrees();
}
void GitPlusPlusView::setupFileWatcher()
{
if (m_gitDir.isEmpty()) return;
// Remove old watcher
delete m_watcher;
m_watcher = new QFileSystemWatcher(this);
// Watch .git/index (changes on stage/unstage/commit/pull/merge)
// and .git/HEAD (changes on branch switch)
// 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;
QStringList watchPaths;
QString indexFile = gitDir + QStringLiteral("/index");
QString headFile = gitDir + QStringLiteral("/HEAD");
if (QFileInfo::exists(indexFile)) watchPaths << indexFile;
if (QFileInfo::exists(headFile)) watchPaths << headFile;
if (!watchPaths.isEmpty()) {
m_watcher->addPaths(watchPaths);
connect(m_watcher, &QFileSystemWatcher::fileChanged, this, [this](const QString &) {
// Debounce: multiple changes can fire rapidly
m_fsDebounce.start();
});
}
}
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();
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)
if (branch.startsWith(QStringLiteral("remotes/")))
branch = branch.mid(8); // len("remotes/") == 8
// Deduplicate: if local branch matches a remote, skip the remote
if (!m_allBranches.contains(branch))
m_allBranches.append(branch);
}
// Update branch list widget
m_branchList->clear();
for (const auto &b : m_allBranches) {
auto *item = new QListWidgetItem(b, m_branchList);
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::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;
}
if (QMessageBox::question(m_toolView, QStringLiteral("Delete Branch"),
QStringLiteral("Delete 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) {
QString diff = QString::fromUtf8(proc->readAllStandardOutput());
if (diff.isEmpty()) {
setMessage(QStringLiteral("No differences between %1 and %2").arg(m_currentBranch, branch));
} else {
// Open diff in a new untitled document
auto *view = m_mainWindow->openUrl(QUrl());
if (view) {
view->document()->setText(diff);
view->document()->setHighlightingMode(QStringLiteral("Diff"));
}
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();
QMenu menu;
menu.addAction(QStringLiteral("Copy branch name"), this, [this]() {
QString branch = selectedBranch();
if (!branch.isEmpty())
QApplication::clipboard()->setText(branch);
});
menu.addSeparator();
menu.addAction(QStringLiteral("Checkout"), this, &GitPlusPlusView::checkoutBranch);
menu.addAction(QStringLiteral("Compare with %1").arg(m_currentBranch), this, &GitPlusPlusView::compareBranch);
menu.addAction(QStringLiteral("Create worktree"), this, &GitPlusPlusView::createWorktree);
if (branch != m_currentBranch)
menu.addAction(QStringLiteral("Delete"), this, &GitPlusPlusView::deleteBranch);
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) {
QString output = QString::fromUtf8(proc->readAllStandardOutput());
if (output.isEmpty()) {
setMessage(QStringLiteral("Empty stash diff"));
} else {
auto *view = m_mainWindow->openUrl(QUrl());
if (view) {
view->document()->setText(output);
view->document()->setHighlightingMode(QStringLiteral("Diff"));
}
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) {
QString output = QString::fromUtf8(proc->readAllStandardOutput());
auto *view = m_mainWindow->openUrl(QUrl());
if (view) {
view->document()->setText(output);
view->document()->setHighlightingMode(QStringLiteral("Diff"));
}
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();
}
// ============================================================================
// 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));
}
#include "gitplusplus.moc"