diff --git a/gitplusplus.cpp b/gitplusplus.cpp index 8d48e15..85879b2 100644 --- a/gitplusplus.cpp +++ b/gitplusplus.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -39,6 +40,8 @@ #include #include #include +#include +#include #include #include #include @@ -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::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::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 ..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::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::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 // ============================================================================ diff --git a/gitplusplus.h b/gitplusplus.h index 64c2c98..6b561b7 100644 --- a/gitplusplus.h +++ b/gitplusplus.h @@ -12,6 +12,7 @@ #include #include #include +#include #include 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 m_optionalTabs; + void rebuildTabs(); + void showTabContextMenu(const QPoint &pos); + void saveTabVisibility(); + void loadTabVisibility(); + // --- Indicators --- QLabel *m_repoLabel; QLabel *m_statusBarWidget;