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