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:
parent
7dabd4b9e6
commit
b4e28097c8
308
gitplusplus.cpp
308
gitplusplus.cpp
|
|
@ -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(),
|
||||
[¤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()
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue