Add git log tab, configurable tab visibility, and drag-reorderable tabs

- Log tab: shows last 200 commits, double-click to diff against HEAD,
  context menu with show commit and copy hash
- Tab visibility: right-click tab bar to toggle optional tabs
  (Branches, Stashes, Worktrees, Log); Status always visible
- Tabs are drag-reorderable; order and visibility persist via QSettings
- Branches: add Soft Reset to Merge Base context menu action
This commit is contained in:
Levi Neely 2026-07-16 08:58:47 +02:00
parent 7dabd4b9e6
commit b4e28097c8
2 changed files with 326 additions and 3 deletions

View File

@ -17,6 +17,7 @@
#include <QAction>
#include <QApplication>
#include <algorithm>
#include <QCheckBox>
#include <QClipboard>
#include <QComboBox>
@ -39,6 +40,8 @@
#include <QSortFilterProxyModel>
#include <QSplitter>
#include <QStatusBar>
#include <QSettings>
#include <QTabBar>
#include <QTabWidget>
#include <QTextEdit>
#include <QTimer>
@ -126,6 +129,7 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
// --- Tab widget ---
m_tabWidget = new QTabWidget(container);
m_tabWidget->setMovable(true);
mainLayout->addWidget(m_tabWidget, 1);
// ===== STATUS TAB =====
@ -247,7 +251,8 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
m_branchList->setContextMenuPolicy(Qt::CustomContextMenu);
branchLayout->addWidget(m_branchList, 1);
m_tabWidget->addTab(branchTab, QIcon::fromTheme(QStringLiteral("vcs-branch")), QStringLiteral("Branches"));
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;
@ -277,7 +282,8 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
m_stashList->setContextMenuPolicy(Qt::CustomContextMenu);
stashLayout->addWidget(m_stashList, 1);
m_tabWidget->addTab(stashTab, QIcon::fromTheme(QStringLiteral("archive-insert")), QStringLiteral("Stashes"));
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;
@ -305,7 +311,50 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
m_worktreeList->setContextMenuPolicy(Qt::CustomContextMenu);
worktreeLayout->addWidget(m_worktreeList, 1);
m_tabWidget->addTab(worktreeTab, QIcon::fromTheme(QStringLiteral("folder-sync")), QStringLiteral("Worktrees"));
OptionalTab wtOpt; wtOpt.widget = worktreeTab; wtOpt.icon = QIcon::fromTheme(QStringLiteral("folder-sync")); wtOpt.label = QStringLiteral("Worktrees"); wtOpt.visible = true;
m_optionalTabs.append(wtOpt);
// ===== 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);
@ -354,6 +403,13 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
connect(wtRemoveBtn, &QToolButton::clicked, this, &GitPlusPlusView::removeWorktree);
connect(wtPruneBtn, &QToolButton::clicked, this, &GitPlusPlusView::pruneWorktrees);
connect(m_worktreeList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::worktreeContextMenu);
// --- 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();
@ -437,6 +493,7 @@ GitPlusPlusView::GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWin
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::refreshLog, Qt::QueuedConnection);
QMetaObject::invokeMethod(this, [this]() { setupFileWatcher(); }, Qt::QueuedConnection);
}
@ -505,6 +562,100 @@ void GitPlusPlusView::refreshAll()
refreshBranches();
refreshStashes();
refreshWorktrees();
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(),
[&currentOrder](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()
@ -1376,6 +1527,38 @@ void GitPlusPlusView::branchContextMenu(const QPoint &pos)
menu.addAction(QStringLiteral("Create worktree"), this, &GitPlusPlusView::createWorktree);
if (branch != m_currentBranch)
menu.addAction(QStringLiteral("Delete"), this, &GitPlusPlusView::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, branch, 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);
@ -1591,6 +1774,125 @@ void GitPlusPlusView::openCommit()
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
// ============================================================================

View File

@ -12,6 +12,7 @@
#include <QPointer>
#include <QProcess>
#include <QWidget>
#include <QIcon>
#include <QTimer>
class GitPlusPlusView;
@ -103,6 +104,10 @@ private Q_SLOTS:
// Log/commit
void openCommit();
void refreshLog();
void logDiffAgainstHead();
void logShowCommit();
void logContextMenu(const QPoint &pos);
// Branch context menu
void branchContextMenu(const QPoint &pos);
@ -165,6 +170,22 @@ private:
// --- Worktrees tab ---
QListWidget *m_worktreeList;
// --- Log tab ---
QListWidget *m_logList;
// --- Tab visibility ---
struct OptionalTab {
QWidget *widget;
QIcon icon;
QString label;
bool visible;
};
QVector<OptionalTab> m_optionalTabs;
void rebuildTabs();
void showTabContextMenu(const QPoint &pos);
void saveTabVisibility();
void loadTabVisibility();
// --- Indicators ---
QLabel *m_repoLabel;
QLabel *m_statusBarWidget;