From ab2d531240a51d8548e74804ab695f6aa47d1725 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 15 Jul 2026 15:59:37 +0200 Subject: [PATCH] Git++ Kate plugin: initial release Full-featured Git integration for Kate with tabbed side panel: Status tab: - Live file status tree (staged/changed/untracked) - Stage, unstage, discard with double-click support - Commit with inline message editor and amend support - Push, pull, fetch with progress spinner - Filesystem watcher for auto-refresh Branches tab: - Filterable list of local and remote branches - Checkout, create, delete, compare - Create worktree from branch - Copy branch name to clipboard Stashes tab: - List all stashes with apply, pop, drop, diff Worktrees tab: - List active worktrees - Create (with branch picker), remove, prune - Open worktree as Kate project Indicators: - Panel header with repo/branch/dirty state - Status bar widget always visible - Detached HEAD warning - Remote operation progress --- .gitignore | 1 + CMakeLists.txt | 34 + Makefile | 15 + README.md | 137 ++++ gitplusplus.cpp | 1551 ++++++++++++++++++++++++++++++++++++++++++++ gitplusplus.h | 180 +++++ gitprocess.h | 68 ++ gitstatusmodel.cpp | 194 ++++++ gitstatusmodel.h | 69 ++ metadata.json | 14 + 10 files changed, 2263 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 gitplusplus.cpp create mode 100644 gitplusplus.h create mode 100644 gitprocess.h create mode 100644 gitstatusmodel.cpp create mode 100644 gitstatusmodel.h create mode 100644 metadata.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..7f470a6 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,34 @@ +cmake_minimum_required(VERSION 3.16) +project(gitplusplus VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(ECM REQUIRED NO_MODULE) +set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) + +include(KDEInstallDirs) +include(KDECMakeSettings) +include(KDECompilerSettings NO_POLICY_SCOPE) + +find_package(Qt5 REQUIRED COMPONENTS Core Widgets) +find_package(KF5TextEditor REQUIRED) +find_package(KF5I18n REQUIRED) +find_package(KF5CoreAddons REQUIRED) + +kcoreaddons_add_plugin(gitplusplus + SOURCES + gitplusplus.cpp + gitstatusmodel.cpp + INSTALL_NAMESPACE "ktexteditor" +) + +target_link_libraries(gitplusplus + KF5::TextEditor + KF5::I18n + Qt5::Core + Qt5::Widgets +) + +target_include_directories(gitplusplus PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..668018c --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: all clean install + +BUILD_DIR := build + +all: $(BUILD_DIR)/gitplusplus.so + +$(BUILD_DIR)/gitplusplus.so: gitplusplus.cpp gitplusplus.h gitprocess.h gitstatusmodel.cpp gitstatusmodel.h metadata.json + @mkdir -p $(BUILD_DIR) + @cd $(BUILD_DIR) && cmake .. -DCMAKE_INSTALL_PREFIX=/usr -Wno-dev 2>/dev/null && make -j$$(nproc) + +install: all + cd $(BUILD_DIR) && sudo make install + +clean: + rm -rf $(BUILD_DIR) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9537109 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Git++ Kate Plugin + +A comprehensive Git integration plugin for the Kate text editor. Provides a +tabbed side panel with status tracking, branch management, stash operations, +and worktree support — all integrated with Kate's project module. + +## Features + +### Status Tab + +- **Live file status** — tree view showing Staged, Changed, and Untracked files +- **Stage / Unstage** — select files and click, or double-click to toggle +- **Discard changes** — revert selected files (with confirmation) +- **Commit** — inline message editor with amend support +- **Amend** — checkbox loads last commit message for editing +- **Push / Pull / Fetch** — with real-time progress spinner and git transfer output +- **Stash** — quick-save button in the toolbar +- **Open commit** — view any commit's diff in the editor +- **Auto-refresh** — watches `.git/index` and `HEAD` for external changes + +### Branches Tab + +- **Filterable list** of all local and remote branches +- **Checkout** — double-click or button; remote branches auto-create local tracking branch +- **Create branch** — dialog shows source branch name +- **Delete branch** — with confirmation +- **Compare** — diff between selected branch and current, opens in editor with syntax highlighting +- **Create worktree** — from any branch (see Worktrees below) +- **Copy branch name** — right-click to clipboard + +### Stashes Tab + +- **List** of all stashes with their messages +- **Save** — with optional message +- **Apply** — applies selected stash (keeps it) +- **Pop** — applies and removes selected stash +- **Drop** — removes selected stash (with confirmation) +- **Diff** — view stash contents in the editor + +### Worktrees Tab + +- **List** of all active worktrees with path, commit, and branch +- **Create** — editable combo box lets you pick an existing branch or type a new one +- **Remove** — deletes the worktree directory (with confirmation) +- **Prune** — clean up stale worktree references +- **Open project** — double-click to open worktree in Kate +- **Copy path** — right-click to clipboard + +### Indicators + +- **Panel header** — shows repo name, branch, dirty state with color coding + - Green `○` when clean + - Orange `●` with file counts when dirty + - Red `⚠ DETACHED` with dark background when HEAD is detached +- **Status bar** — persistent branch + dirty indicator visible even when panel is hidden +- **Remote operations** — animated spinner with progress streaming during push/pull/fetch + +## Worktree Workflow + +Git worktrees let you have multiple branches checked out simultaneously in +separate directories, without needing multiple clones. Git++ makes this +practical for daily use. + +### How It Works + +When you create a worktree from a branch, Git++ creates a sibling directory +next to your main repo using the naming convention: + +``` +{repo-name}.{branch-name} +``` + +For example, if your repo is at: +``` +~/projects/myproject/ +``` + +And you create a worktree for branch `ISSUE-123_my-feature`, it creates: +``` +~/projects/myproject.ISSUE-123_my-feature/ +``` + +This convention means: +- Worktrees are always next to the main repo (easy to find) +- The name tells you both the repo and the branch +- No collisions if you have worktrees from multiple repos in the same parent directory + +### Recommended Workflow + +1. **Start a feature** — go to the Branches tab, select the base branch, + right-click → "Create worktree" (or use the Worktrees tab → Create button) +2. **Work in isolation** — double-click the worktree entry to open it as a + Kate project. Each worktree has its own working directory, so you can + switch between features without stashing. +3. **Review another branch** — create a worktree for a colleague's branch + to review their code without disrupting your current work. +4. **Clean up** — when done with a feature branch, remove the worktree from + the Worktrees tab. The branch remains intact for merging. + +### Kate Project Detection + +Git++ automatically creates a `.kateproject` file in each new worktree so +that Kate's project plugin recognizes it immediately. The project uses git +for file listing, so the file tree shows exactly what git tracks. + +## Building + +Requires KF5 (or KF6) development packages: + +```sh +mkdir build && cd build +cmake .. -DCMAKE_INSTALL_PREFIX=/usr -Wno-dev +make -j$(nproc) +sudo make install +``` + +Or use the provided Makefile: + +```sh +make install +``` + +## Installation Path + +The plugin installs to Kate's KTextEditor plugin directory: +``` +/usr/lib/x86_64-linux-gnu/qt5/plugins/ktexteditor/gitplusplus.so +``` + +Enable it in Kate → Settings → Configure Kate → Plugins → "Git++". + +## Project Integration + +Git++ reads the project base directory from Kate's project plugin +(`kateprojectplugin`). When you switch projects or open a new one, all tabs +refresh automatically. If no project is open, it falls back to the directory +of the active file. diff --git a/gitplusplus.cpp b/gitplusplus.cpp new file mode 100644 index 0000000..3958f13 --- /dev/null +++ b/gitplusplus.cpp @@ -0,0 +1,1551 @@ +#include "gitplusplus.h" +#include "gitprocess.h" +#include "gitstatusmodel.h" + +#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); + + // --- 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); + mainLayout->addWidget(m_tabWidget, 1); + + // ===== STATUS TAB ===== + auto *statusTab = new QWidget; + auto *statusLayout = new QVBoxLayout(statusTab); + statusLayout->setContentsMargins(0, 0, 0, 0); + statusLayout->setSpacing(4); + + // Toolbar + auto *toolbar = new QHBoxLayout; + toolbar->setSpacing(2); + toolbar->setContentsMargins(0, 0, 0, 0); + + m_refreshBtn = makeToolButton(QStringLiteral("view-refresh"), QStringLiteral("Refresh")); + m_stageBtn = makeToolButton(QStringLiteral("list-add"), QStringLiteral("Stage")); + m_unstageBtn = makeToolButton(QStringLiteral("list-remove"), QStringLiteral("Unstage")); + m_discardBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Discard")); + m_commitBtn = makeToolButton(QStringLiteral("vcs-commit"), QStringLiteral("Commit")); + m_pushBtn = makeToolButton(QStringLiteral("vcs-push"), QStringLiteral("Push")); + m_pullBtn = makeToolButton(QStringLiteral("vcs-pull"), QStringLiteral("Pull")); + m_fetchBtn = makeToolButton(QStringLiteral("download"), QStringLiteral("Fetch")); + m_stashBtn = makeToolButton(QStringLiteral("archive-insert"), QStringLiteral("Stash…")); + auto *openCommitBtn = makeToolButton(QStringLiteral("vcs-diff"), QStringLiteral("Open commit…")); + + toolbar->addWidget(m_refreshBtn); + toolbar->addWidget(m_stageBtn); + toolbar->addWidget(m_unstageBtn); + toolbar->addWidget(m_discardBtn); + toolbar->addWidget(m_commitBtn); + toolbar->addWidget(m_pushBtn); + toolbar->addWidget(m_pullBtn); + toolbar->addWidget(m_fetchBtn); + toolbar->addWidget(m_stashBtn); + toolbar->addWidget(openCommitBtn); + toolbar->addStretch(); + statusLayout->addLayout(toolbar); + + // Splitter: tree + commit msg + auto *splitter = new QSplitter(Qt::Vertical, statusTab); + + // Tree view with filter + auto *treeContainer = new QWidget(splitter); + auto *treeLayout = new QVBoxLayout(treeContainer); + treeLayout->setContentsMargins(0, 0, 0, 0); + treeLayout->setSpacing(2); + + m_filterEdit = new QLineEdit(treeContainer); + m_filterEdit->setPlaceholderText(QStringLiteral("Filter files…")); + m_filterEdit->setClearButtonEnabled(true); + treeLayout->addWidget(m_filterEdit); + + m_treeView = new QTreeView(treeContainer); + m_treeView->setHeaderHidden(true); + m_treeView->setSelectionMode(QTreeView::ExtendedSelection); + m_treeView->setRootIsDecorated(true); + m_treeView->setUniformRowHeights(true); + treeLayout->addWidget(m_treeView); + + // Commit area + auto *commitContainer = new QWidget(splitter); + auto *commitLayout = new QVBoxLayout(commitContainer); + commitLayout->setContentsMargins(0, 0, 0, 0); + commitLayout->setSpacing(2); + + m_commitMsgEdit = new QTextEdit(commitContainer); + m_commitMsgEdit->setPlaceholderText(QStringLiteral("Commit message…")); + m_commitMsgEdit->setAcceptRichText(false); + m_commitMsgEdit->setMaximumHeight(80); + commitLayout->addWidget(m_commitMsgEdit); + + m_amendCheck = new QCheckBox(QStringLiteral("Amend last commit"), commitContainer); + commitLayout->addWidget(m_amendCheck); + + splitter->addWidget(treeContainer); + splitter->addWidget(commitContainer); + splitter->setStretchFactor(0, 4); + splitter->setStretchFactor(1, 1); + statusLayout->addWidget(splitter, 1); + + m_tabWidget->addTab(statusTab, QIcon::fromTheme(QStringLiteral("vcs-locally-modified")), QStringLiteral("Status")); + + // ===== BRANCHES TAB ===== + auto *branchTab = new QWidget; + auto *branchLayout = new QVBoxLayout(branchTab); + branchLayout->setContentsMargins(0, 0, 0, 0); + branchLayout->setSpacing(4); + + // Branch toolbar + auto *branchToolbar = new QHBoxLayout; + branchToolbar->setSpacing(2); + branchToolbar->setContentsMargins(0, 0, 0, 0); + + m_branchCheckoutBtn = makeToolButton(QStringLiteral("vcs-branch"), QStringLiteral("Checkout")); + m_branchCreateBtn = makeToolButton(QStringLiteral("list-add"), QStringLiteral("Create branch")); + m_branchDeleteBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Delete branch")); + m_branchCompareBtn = makeToolButton(QStringLiteral("view-split-left-right"), QStringLiteral("Compare with current")); + auto *worktreeBtn = makeToolButton(QStringLiteral("folder-new"), QStringLiteral("Create worktree from branch")); + + branchToolbar->addWidget(m_branchCheckoutBtn); + branchToolbar->addWidget(m_branchCreateBtn); + branchToolbar->addWidget(m_branchDeleteBtn); + branchToolbar->addWidget(m_branchCompareBtn); + branchToolbar->addWidget(worktreeBtn); + branchToolbar->addStretch(); + branchLayout->addLayout(branchToolbar); + + // Filter + m_branchFilterEdit = new QLineEdit(branchTab); + m_branchFilterEdit->setPlaceholderText(QStringLiteral("Filter branches…")); + m_branchFilterEdit->setClearButtonEnabled(true); + branchLayout->addWidget(m_branchFilterEdit); + + // Branch list + m_branchList = new QListWidget(branchTab); + m_branchList->setContextMenuPolicy(Qt::CustomContextMenu); + branchLayout->addWidget(m_branchList, 1); + + m_tabWidget->addTab(branchTab, QIcon::fromTheme(QStringLiteral("vcs-branch")), QStringLiteral("Branches")); + + // ===== STASHES TAB ===== + auto *stashTab = new QWidget; + auto *stashLayout = new QVBoxLayout(stashTab); + stashLayout->setContentsMargins(0, 0, 0, 0); + stashLayout->setSpacing(4); + + auto *stashToolbar = new QHBoxLayout; + stashToolbar->setSpacing(2); + stashToolbar->setContentsMargins(0, 0, 0, 0); + + m_stashSaveBtn = makeToolButton(QStringLiteral("list-add"), QStringLiteral("Stash save")); + m_stashApplyBtn = makeToolButton(QStringLiteral("dialog-ok-apply"), QStringLiteral("Apply selected stash")); + m_stashPopBtn = makeToolButton(QStringLiteral("archive-extract"), QStringLiteral("Pop selected stash")); + m_stashDropBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Drop selected stash")); + auto *stashDiffBtn = makeToolButton(QStringLiteral("vcs-diff"), QStringLiteral("Show diff of selected stash")); + + stashToolbar->addWidget(m_stashSaveBtn); + stashToolbar->addWidget(m_stashApplyBtn); + stashToolbar->addWidget(m_stashPopBtn); + stashToolbar->addWidget(m_stashDropBtn); + stashToolbar->addWidget(stashDiffBtn); + stashToolbar->addStretch(); + stashLayout->addLayout(stashToolbar); + + m_stashList = new QListWidget(stashTab); + m_stashList->setContextMenuPolicy(Qt::CustomContextMenu); + stashLayout->addWidget(m_stashList, 1); + + m_tabWidget->addTab(stashTab, QIcon::fromTheme(QStringLiteral("archive-insert")), QStringLiteral("Stashes")); + + // ===== WORKTREES TAB ===== + auto *worktreeTab = new QWidget; + auto *worktreeLayout = new QVBoxLayout(worktreeTab); + worktreeLayout->setContentsMargins(0, 0, 0, 0); + worktreeLayout->setSpacing(4); + + auto *wtToolbar = new QHBoxLayout; + wtToolbar->setSpacing(2); + wtToolbar->setContentsMargins(0, 0, 0, 0); + + auto *wtRefreshBtn = makeToolButton(QStringLiteral("view-refresh"), QStringLiteral("Refresh worktrees")); + auto *wtCreateBtn = makeToolButton(QStringLiteral("folder-new"), QStringLiteral("Create worktree")); + auto *wtRemoveBtn = makeToolButton(QStringLiteral("edit-delete"), QStringLiteral("Remove selected worktree")); + auto *wtPruneBtn = makeToolButton(QStringLiteral("edit-clear-all"), QStringLiteral("Prune stale worktrees")); + + wtToolbar->addWidget(wtRefreshBtn); + wtToolbar->addWidget(wtCreateBtn); + wtToolbar->addWidget(wtRemoveBtn); + wtToolbar->addWidget(wtPruneBtn); + wtToolbar->addStretch(); + worktreeLayout->addLayout(wtToolbar); + + m_worktreeList = new QListWidget(worktreeTab); + m_worktreeList->setContextMenuPolicy(Qt::CustomContextMenu); + worktreeLayout->addWidget(m_worktreeList, 1); + + m_tabWidget->addTab(worktreeTab, QIcon::fromTheme(QStringLiteral("folder-sync")), QStringLiteral("Worktrees")); + + // --- Status label at bottom --- + m_statusLabel = new QLabel(container); + m_statusLabel->setWordWrap(true); + mainLayout->addWidget(m_statusLabel); + + // --- Model setup --- + m_model = new GitStatusModel(this); + m_proxy = new QSortFilterProxyModel(this); + m_proxy->setSourceModel(m_model); + m_proxy->setFilterCaseSensitivity(Qt::CaseInsensitive); + m_proxy->setRecursiveFilteringEnabled(true); + m_treeView->setModel(m_proxy); + + // --- Connections: Status tab --- + connect(m_refreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::updateStatus); + connect(m_stageBtn, &QToolButton::clicked, this, &GitPlusPlusView::stageSelected); + connect(m_unstageBtn, &QToolButton::clicked, this, &GitPlusPlusView::unstageSelected); + connect(m_discardBtn, &QToolButton::clicked, this, &GitPlusPlusView::discardSelected); + connect(m_commitBtn, &QToolButton::clicked, this, &GitPlusPlusView::commitChanges); + connect(m_pushBtn, &QToolButton::clicked, this, &GitPlusPlusView::push); + connect(m_pullBtn, &QToolButton::clicked, this, &GitPlusPlusView::pull); + connect(m_fetchBtn, &QToolButton::clicked, this, &GitPlusPlusView::fetch); + connect(m_filterEdit, &QLineEdit::textChanged, m_proxy, &QSortFilterProxyModel::setFilterFixedString); + connect(m_filterEdit, &QLineEdit::textChanged, m_treeView, &QTreeView::expandAll); + + // Stash toolbar button: quick stash save + connect(m_stashBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashSave); + + // Open commit + connect(openCommitBtn, &QToolButton::clicked, this, &GitPlusPlusView::openCommit); + + // --- Connections: Stashes tab --- + connect(m_stashSaveBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashSave); + connect(m_stashApplyBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashApply); + connect(m_stashPopBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashPop); + connect(m_stashDropBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashDrop); + connect(stashDiffBtn, &QToolButton::clicked, this, &GitPlusPlusView::stashDiff); + connect(m_stashList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::stashContextMenu); + + // --- Connections: Worktrees tab --- + connect(wtRefreshBtn, &QToolButton::clicked, this, &GitPlusPlusView::refreshWorktrees); + connect(wtCreateBtn, &QToolButton::clicked, this, &GitPlusPlusView::createWorktreeFromTab); + connect(wtRemoveBtn, &QToolButton::clicked, this, &GitPlusPlusView::removeWorktree); + connect(wtPruneBtn, &QToolButton::clicked, this, &GitPlusPlusView::pruneWorktrees); + connect(m_worktreeList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::worktreeContextMenu); + connect(m_worktreeList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) { + if (!item) return; + QString path = item->text().section(QLatin1Char(' '), 0, 0).trimmed(); + if (!path.isEmpty()) { + // Open .kateproject file from within the worktree to trigger project detection + QString kateProjectFile = path + QStringLiteral("/.kateproject"); + if (QFileInfo::exists(kateProjectFile)) { + m_mainWindow->openUrl(QUrl::fromLocalFile(kateProjectFile)); + } else { + // Fall back: open any existing file in the directory + QDir dir(path); + QStringList entries = dir.entryList(QDir::Files, QDir::Name); + if (!entries.isEmpty()) { + m_mainWindow->openUrl(QUrl::fromLocalFile(dir.absoluteFilePath(entries.first()))); + } + } + } + }); + + // Double-click to stage/unstage + connect(m_treeView, &QTreeView::doubleClicked, this, [this](const QModelIndex &idx) { + QModelIndex srcIdx = m_proxy->mapToSource(idx); + if (srcIdx.internalId() == 0xFFFFFFFF) + return; + int nodeType = int(srcIdx.internalId()); + QString file = srcIdx.data(GitStatusModel::FilePathRole).toString(); + if (file.isEmpty()) + return; + if (nodeType == GitStatusModel::NodeStaged) { + runSimpleGit({QStringLiteral("reset"), QStringLiteral("HEAD"), QStringLiteral("--"), file}, + QStringLiteral("Unstaged: ") + file, QStringLiteral("Failed to unstage: ") + file); + } else { + runSimpleGit({QStringLiteral("add"), QStringLiteral("--"), file}, + QStringLiteral("Staged: ") + file, QStringLiteral("Failed to stage: ") + file); + } + }); + + // Amend checkbox: load last commit message when checked + connect(m_amendCheck, &QCheckBox::toggled, this, [this](bool checked) { + if (checked && m_commitMsgEdit->toPlainText().trimmed().isEmpty()) { + QByteArray msg = GitProcess::run(m_gitDir, {QStringLiteral("log"), QStringLiteral("-1"), QStringLiteral("--format=%B")}); + if (!msg.isEmpty()) + m_commitMsgEdit->setPlainText(QString::fromUtf8(msg).trimmed()); + } + }); + + // --- Connections: Branches tab --- + connect(m_branchCheckoutBtn, &QToolButton::clicked, this, &GitPlusPlusView::checkoutBranch); + connect(m_branchCreateBtn, &QToolButton::clicked, this, &GitPlusPlusView::createBranch); + connect(m_branchDeleteBtn, &QToolButton::clicked, this, &GitPlusPlusView::deleteBranch); + connect(m_branchCompareBtn, &QToolButton::clicked, this, &GitPlusPlusView::compareBranch); + connect(worktreeBtn, &QToolButton::clicked, this, &GitPlusPlusView::createWorktree); + connect(m_branchList, &QListWidget::customContextMenuRequested, this, &GitPlusPlusView::branchContextMenu); + connect(m_branchList, &QListWidget::itemDoubleClicked, this, &GitPlusPlusView::checkoutBranch); + + // Branch filter + connect(m_branchFilterEdit, &QLineEdit::textChanged, this, [this](const QString &text) { + for (int i = 0; i < m_branchList->count(); ++i) { + auto *item = m_branchList->item(i); + item->setHidden(!item->text().contains(text, Qt::CaseInsensitive)); + } + }); + + // Refresh everything when the active view changes (project might have changed) + connect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, [this]() { + QString newDir = projectBaseDir(); + if (!newDir.isEmpty() && newDir != m_gitDir) { + m_gitDir = newDir; + setupFileWatcher(); + refreshAll(); + } + }); + + // Debounce timer for filesystem changes (avoid spamming refreshes) + m_fsDebounce.setSingleShot(true); + m_fsDebounce.setInterval(500); + connect(&m_fsDebounce, &QTimer::timeout, this, &GitPlusPlusView::updateStatus); + + // Initial refresh + QMetaObject::invokeMethod(this, &GitPlusPlusView::updateStatus, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshBranches, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshStashes, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, &GitPlusPlusView::refreshWorktrees, Qt::QueuedConnection); + QMetaObject::invokeMethod(this, [this]() { setupFileWatcher(); }, Qt::QueuedConnection); +} + +GitPlusPlusView::~GitPlusPlusView() +{ + delete m_toolView; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +QString GitPlusPlusView::projectBaseDir() const +{ + QObject *projectView = m_mainWindow->pluginView(QStringLiteral("kateprojectplugin")); + if (projectView) { + QString dir = projectView->property("projectBaseDir").toString(); + if (!dir.isEmpty()) + return dir; + } + auto *view = m_mainWindow->activeView(); + if (view && view->document() && !view->document()->url().toLocalFile().isEmpty()) + return QFileInfo(view->document()->url().toLocalFile()).absolutePath(); + return {}; +} + +QProcess *GitPlusPlusView::gitProcess(const QStringList &args) const +{ + QString dir = m_gitDir.isEmpty() ? projectBaseDir() : m_gitDir; + if (dir.isEmpty()) + return nullptr; + return GitProcess::create(dir, args, const_cast(this)); +} + +void GitPlusPlusView::setMessage(const QString &text, bool isError) +{ + m_statusLabel->setText(text); + m_statusLabel->setStyleSheet(isError ? QStringLiteral("color: red;") : QStringLiteral("color: green;")); +} + +void GitPlusPlusView::refreshAll() +{ + updateStatus(); + refreshBranches(); + refreshStashes(); + refreshWorktrees(); +} + +void GitPlusPlusView::setupFileWatcher() +{ + if (m_gitDir.isEmpty()) return; + + // Remove old watcher + delete m_watcher; + m_watcher = new QFileSystemWatcher(this); + + // Watch .git/index (changes on stage/unstage/commit/pull/merge) + // and .git/HEAD (changes on branch switch) + // For worktrees, .git is a file pointing to the real git dir + QString gitDir; + QString dotGit = m_gitDir + QStringLiteral("/.git"); + QFileInfo dotGitInfo(dotGit); + if (dotGitInfo.isDir()) { + gitDir = dotGit; + } else if (dotGitInfo.isFile()) { + // Worktree: read gitdir path from .git file + QFile f(dotGit); + if (f.open(QIODevice::ReadOnly)) { + QString line = QString::fromUtf8(f.readLine()).trimmed(); + if (line.startsWith(QStringLiteral("gitdir: "))) + gitDir = line.mid(8); + f.close(); + } + } + + if (gitDir.isEmpty()) return; + + QStringList watchPaths; + QString indexFile = gitDir + QStringLiteral("/index"); + QString headFile = gitDir + QStringLiteral("/HEAD"); + if (QFileInfo::exists(indexFile)) watchPaths << indexFile; + if (QFileInfo::exists(headFile)) watchPaths << headFile; + + if (!watchPaths.isEmpty()) { + m_watcher->addPaths(watchPaths); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, [this](const QString &) { + // Debounce: multiple changes can fire rapidly + m_fsDebounce.start(); + }); + } +} + +void GitPlusPlusView::updateIndicators() +{ + QString repoName; + if (!m_gitDir.isEmpty()) + repoName = QFileInfo(m_gitDir).fileName(); + + m_isDirty = !m_model->entries(GitStatusModel::NodeStaged).isEmpty() + || !m_model->entries(GitStatusModel::NodeChanged).isEmpty(); + + int stagedCount = m_model->entries(GitStatusModel::NodeStaged).size(); + int changedCount = m_model->entries(GitStatusModel::NodeChanged).size(); + int untrackedCount = m_model->entries(GitStatusModel::NodeUntracked).size(); + + bool detached = m_currentBranch.startsWith(QLatin1Char('(')); + + QString branchStr = m_currentBranch.isEmpty() ? QStringLiteral("(no branch)") : m_currentBranch; + + if (detached) { + // Detached HEAD: prominent red/warning indicator + QString repoText = QStringLiteral("⚠ ") + repoName + QStringLiteral(" DETACHED ") + branchStr; + if (m_isDirty) + repoText += QStringLiteral(" (+%1 ~%2 ?%3)").arg(stagedCount).arg(changedCount).arg(untrackedCount); + m_repoLabel->setText(repoText); + m_repoLabel->setStyleSheet(QStringLiteral("font-weight: bold; padding: 2px 4px; color: #d04040; background: #402020;")); + + if (m_statusBarWidget) { + QString statusText = QStringLiteral("⚠ DETACHED ") + branchStr; + if (m_isDirty) + statusText += QStringLiteral(" +%1 ~%2").arg(stagedCount).arg(changedCount); + m_statusBarWidget->setText(statusText); + m_statusBarWidget->setStyleSheet(QStringLiteral("color: #d04040; padding: 0px 6px; font-weight: bold;")); + } + } else { + QString dot = m_isDirty ? QStringLiteral("● ") : QStringLiteral("○ "); + QString repoText = dot + repoName + QStringLiteral(" ⎇ ") + branchStr; + if (m_isDirty) + repoText += QStringLiteral(" (+%1 ~%2 ?%3)").arg(stagedCount).arg(changedCount).arg(untrackedCount); + + m_repoLabel->setText(repoText); + m_repoLabel->setStyleSheet(m_isDirty + ? QStringLiteral("font-weight: bold; padding: 2px 4px; color: #e0a030;") + : QStringLiteral("font-weight: bold; padding: 2px 4px; color: #40a040;")); + + if (m_statusBarWidget) { + QString statusText = QStringLiteral("⎇ ") + branchStr; + if (m_isDirty) + statusText += QStringLiteral(" +%1 ~%2").arg(stagedCount).arg(changedCount); + m_statusBarWidget->setText(statusText); + m_statusBarWidget->setStyleSheet(m_isDirty + ? QStringLiteral("color: #e0a030; padding: 0px 6px;") + : QStringLiteral("color: #40a040; padding: 0px 6px;")); + } + } +} + +QStringList GitPlusPlusView::selectedFiles(bool staged) const +{ + QStringList files; + const auto indexes = m_treeView->selectionModel()->selectedIndexes(); + for (const auto &proxyIdx : indexes) { + QModelIndex idx = m_proxy->mapToSource(proxyIdx); + if (idx.internalId() == 0xFFFFFFFF) + continue; + int nodeType = int(idx.internalId()); + if (staged && nodeType != GitStatusModel::NodeStaged) + continue; + if (!staged && nodeType == GitStatusModel::NodeStaged) + continue; + QString file = idx.data(GitStatusModel::FilePathRole).toString(); + if (!file.isEmpty()) + files.append(file); + } + return files; +} + +QString GitPlusPlusView::selectedBranch() const +{ + auto *item = m_branchList->currentItem(); + if (!item) + return {}; + QString text = item->text(); + // Remove leading "* " for current branch + if (text.startsWith(QStringLiteral("* "))) + text = text.mid(2); + return text.trimmed(); +} + +QString GitPlusPlusView::selectedStash() const +{ + auto *item = m_stashList->currentItem(); + if (!item) + return {}; + // Extract stash ref (e.g. "stash@{0}") from the line + QString text = item->text(); + int colonIdx = text.indexOf(QLatin1Char(':')); + if (colonIdx > 0) + return text.left(colonIdx).trimmed(); + return text.trimmed(); +} + +void GitPlusPlusView::runSimpleGit(const QStringList &args, const QString &successMsg, const QString &errorMsg) +{ + auto *proc = gitProcess(args); + if (!proc) { + setMessage(QStringLiteral("No git directory found"), true); + return; + } + connect(proc, QOverload::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(); + QString current; + + const auto lines = output.split('\n'); + for (const auto &line : lines) { + QString branch = QString::fromUtf8(line).trimmed(); + if (branch.isEmpty()) continue; + if (branch.startsWith(QLatin1Char('*'))) { + current = branch.mid(2); + branch = branch.mid(2); + } + // Skip HEAD pointer in remotes + if (branch.contains(QStringLiteral(" -> "))) continue; + // Strip "remotes/" prefix for display (show as origin/foo) + if (branch.startsWith(QStringLiteral("remotes/"))) + branch = branch.mid(8); // len("remotes/") == 8 + // Deduplicate: if local branch matches a remote, skip the remote + if (!m_allBranches.contains(branch)) + m_allBranches.append(branch); + } + + // Update branch list widget + m_branchList->clear(); + for (const auto &b : m_allBranches) { + auto *item = new QListWidgetItem(b, m_branchList); + if (b == current) { + item->setText(QStringLiteral("* ") + b); + QFont f = item->font(); + f.setBold(true); + item->setFont(f); + } + } + + // Re-apply filter + QString filter = m_branchFilterEdit->text(); + if (!filter.isEmpty()) { + for (int i = 0; i < m_branchList->count(); ++i) { + auto *item = m_branchList->item(i); + item->setHidden(!item->text().contains(filter, Qt::CaseInsensitive)); + } + } + + m_currentBranch = current; + updateIndicators(); + } + proc->deleteLater(); +} + +void GitPlusPlusView::stageSelected() +{ + QStringList files = selectedFiles(false); + if (files.isEmpty()) { + setMessage(QStringLiteral("No unstaged files selected"), true); + return; + } + QStringList args = {QStringLiteral("add"), QStringLiteral("--")}; + args.append(files); + runSimpleGit(args, QStringLiteral("Staged %1 file(s)").arg(files.size()), + QStringLiteral("Failed to stage")); +} + +void GitPlusPlusView::unstageSelected() +{ + QStringList files = selectedFiles(true); + if (files.isEmpty()) { + setMessage(QStringLiteral("No staged files selected"), true); + return; + } + QStringList args = {QStringLiteral("reset"), QStringLiteral("HEAD"), QStringLiteral("--")}; + args.append(files); + runSimpleGit(args, QStringLiteral("Unstaged %1 file(s)").arg(files.size()), + QStringLiteral("Failed to unstage")); +} + +void GitPlusPlusView::discardSelected() +{ + QStringList files = selectedFiles(false); + if (files.isEmpty()) { + setMessage(QStringLiteral("No changed files selected"), true); + return; + } + if (QMessageBox::question(m_toolView, QStringLiteral("Discard Changes"), + QStringLiteral("Discard changes in %1 file(s)? This cannot be undone.").arg(files.size()), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) + return; + + QStringList args = {QStringLiteral("checkout"), QStringLiteral("--")}; + args.append(files); + runSimpleGit(args, QStringLiteral("Discarded %1 file(s)").arg(files.size()), + QStringLiteral("Failed to discard")); +} + +void GitPlusPlusView::commitChanges() +{ + QString msg = m_commitMsgEdit->toPlainText().trimmed(); + if (msg.isEmpty()) { + setMessage(QStringLiteral("Commit message is empty"), true); + return; + } + + bool amend = m_amendCheck->isChecked(); + if (!amend && m_model->entries(GitStatusModel::NodeStaged).isEmpty()) { + setMessage(QStringLiteral("Nothing staged to commit"), true); + return; + } + + QStringList args = {QStringLiteral("commit")}; + if (amend) + args.append(QStringLiteral("--amend")); + args.append({QStringLiteral("-m"), msg}); + + auto *proc = gitProcess(args); + if (!proc) { + setMessage(QStringLiteral("No git directory found"), true); + return; + } + connect(proc, QOverload::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; + } + + if (QMessageBox::question(m_toolView, QStringLiteral("Delete Branch"), + QStringLiteral("Delete branch '%1'?").arg(branch), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) + return; + + // Use -D for force delete (handles unmerged branches) + auto *proc = gitProcess({QStringLiteral("branch"), QStringLiteral("-D"), branch}); + if (!proc) return; + connect(proc, QOverload::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) { + QString diff = QString::fromUtf8(proc->readAllStandardOutput()); + if (diff.isEmpty()) { + setMessage(QStringLiteral("No differences between %1 and %2").arg(m_currentBranch, branch)); + } else { + // Open diff in a new untitled document + auto *view = m_mainWindow->openUrl(QUrl()); + if (view) { + view->document()->setText(diff); + view->document()->setHighlightingMode(QStringLiteral("Diff")); + } + setMessage(QStringLiteral("Comparing %1...%2").arg(m_currentBranch, branch)); + } + } else { + QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed(); + setMessage(QStringLiteral("Compare failed: ") + err, true); + } + proc->deleteLater(); + }); + proc->start(); +} + +void GitPlusPlusView::createWorktree() +{ + QString branch = selectedBranch(); + if (branch.isEmpty()) { + setMessage(QStringLiteral("No branch selected"), true); + return; + } + + // Worktree path: sibling directory named {repo}.{branch} + // e.g. repo at /home/user/src/myproject -> /home/user/src/myproject.branchname + QDir repoDir(m_gitDir); + QString repoName = repoDir.dirName(); + QDir parentDir(m_gitDir); + parentDir.cdUp(); + + // Sanitize branch name for directory (replace / with -) + QString branchDir = branch; + branchDir.replace(QLatin1Char('/'), QLatin1Char('-')); + + QString dirName = repoName + QLatin1Char('.') + branchDir; + QString worktreePath = parentDir.absoluteFilePath(dirName); + + // Check if already exists + if (QFileInfo::exists(worktreePath)) { + setMessage(QStringLiteral("Directory already exists: ") + worktreePath, true); + return; + } + + auto *proc = gitProcess({QStringLiteral("worktree"), QStringLiteral("add"), worktreePath, branch}); + if (!proc) return; + connect(proc, QOverload::of(&QProcess::finished), + this, [this, proc, worktreePath, branch](int exitCode, QProcess::ExitStatus) { + if (exitCode == 0) { + // Create .kateproject so Kate's project plugin detects the worktree + QFile kateProject(worktreePath + QStringLiteral("/.kateproject")); + if (!QFileInfo::exists(kateProject.fileName())) { + if (kateProject.open(QIODevice::WriteOnly | QIODevice::Text)) { + kateProject.write("{\n \"name\": \"" + QFileInfo(worktreePath).fileName().toUtf8() + "\",\n \"files\": [ { \"git\": true } ]\n}\n"); + kateProject.close(); + } + } + setMessage(QStringLiteral("Worktree created: ") + worktreePath); + } else { + QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed(); + setMessage(QStringLiteral("Worktree failed: ") + err, true); + } + proc->deleteLater(); + refreshAll(); + }); + proc->start(); +} + +void GitPlusPlusView::branchContextMenu(const QPoint &pos) +{ + auto *item = m_branchList->itemAt(pos); + if (!item) return; + + m_branchList->setCurrentItem(item); + QString branch = selectedBranch(); + + QMenu menu; + menu.addAction(QStringLiteral("Copy branch name"), this, [this]() { + QString branch = selectedBranch(); + if (!branch.isEmpty()) + QApplication::clipboard()->setText(branch); + }); + menu.addSeparator(); + menu.addAction(QStringLiteral("Checkout"), this, &GitPlusPlusView::checkoutBranch); + menu.addAction(QStringLiteral("Compare with %1").arg(m_currentBranch), this, &GitPlusPlusView::compareBranch); + menu.addAction(QStringLiteral("Create worktree"), this, &GitPlusPlusView::createWorktree); + if (branch != m_currentBranch) + menu.addAction(QStringLiteral("Delete"), this, &GitPlusPlusView::deleteBranch); + menu.addSeparator(); + menu.addAction(QStringLiteral("Create new branch…"), this, &GitPlusPlusView::createBranch); + + menu.exec(m_branchList->mapToGlobal(pos)); +} + +// ============================================================================ +// Stash operations +// ============================================================================ + +void GitPlusPlusView::stashSave() +{ + bool ok; + QString msg = QInputDialog::getText(m_toolView, QStringLiteral("Stash Save"), + QStringLiteral("Stash message (optional):"), + QLineEdit::Normal, QString(), &ok); + if (!ok) return; + + QStringList args; + args << QStringLiteral("stash") << QStringLiteral("push"); + if (!msg.trimmed().isEmpty()) + args << QStringLiteral("-m") << msg.trimmed(); + + auto *proc = gitProcess(args); + if (!proc) return; + connect(proc, QOverload::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) { + QString output = QString::fromUtf8(proc->readAllStandardOutput()); + if (output.isEmpty()) { + setMessage(QStringLiteral("Empty stash diff")); + } else { + auto *view = m_mainWindow->openUrl(QUrl()); + if (view) { + view->document()->setText(output); + view->document()->setHighlightingMode(QStringLiteral("Diff")); + } + setMessage(QStringLiteral("Diff: ") + stash); + } + } else { + QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed(); + setMessage(QStringLiteral("Diff failed: ") + err, true); + } + proc->deleteLater(); + }); + proc->start(); +} + +void GitPlusPlusView::stashContextMenu(const QPoint &pos) +{ + auto *item = m_stashList->itemAt(pos); + if (!item) return; + m_stashList->setCurrentItem(item); + + QMenu menu; + menu.addAction(QStringLiteral("Diff"), this, &GitPlusPlusView::stashDiff); + menu.addAction(QStringLiteral("Apply"), this, &GitPlusPlusView::stashApply); + menu.addAction(QStringLiteral("Pop"), this, &GitPlusPlusView::stashPop); + menu.addAction(QStringLiteral("Drop"), this, &GitPlusPlusView::stashDrop); + menu.exec(m_stashList->mapToGlobal(pos)); +} + +// ============================================================================ +// Open commit (log + diff) +// ============================================================================ + +void GitPlusPlusView::openCommit() +{ + bool ok; + QString ref = QInputDialog::getText(m_toolView, QStringLiteral("Open Commit"), + QStringLiteral("Commit hash or ref (e.g. HEAD, HEAD~1):"), + QLineEdit::Normal, QStringLiteral("HEAD"), &ok); + if (!ok || ref.trimmed().isEmpty()) return; + ref = ref.trimmed(); + + auto *proc = gitProcess({QStringLiteral("show"), QStringLiteral("--stat"), QStringLiteral("--patch"), ref}); + if (!proc) return; + connect(proc, QOverload::of(&QProcess::finished), + this, [this, proc, ref](int exitCode, QProcess::ExitStatus) { + if (exitCode == 0) { + QString output = QString::fromUtf8(proc->readAllStandardOutput()); + auto *view = m_mainWindow->openUrl(QUrl()); + if (view) { + view->document()->setText(output); + view->document()->setHighlightingMode(QStringLiteral("Diff")); + } + setMessage(QStringLiteral("Showing commit: ") + ref); + } else { + QString err = QString::fromUtf8(proc->readAllStandardError()).trimmed(); + setMessage(QStringLiteral("Failed to show commit: ") + err, true); + } + proc->deleteLater(); + }); + proc->start(); +} + +// ============================================================================ +// Worktree operations +// ============================================================================ + +void GitPlusPlusView::createWorktreeFromTab() +{ + bool ok; + QString branch = QInputDialog::getItem(m_toolView, QStringLiteral("Create Worktree"), + QStringLiteral("Branch (select existing or type new):"), + m_allBranches, 0, true, &ok); + if (!ok || branch.trimmed().isEmpty()) return; + branch = branch.trimmed(); + + // Determine worktree path: sibling of repo, named {repo}.{branch} + QDir repoDir(m_gitDir); + QString repoName = repoDir.dirName(); + QDir parentDir(m_gitDir); + parentDir.cdUp(); + + QString branchDir = branch; + branchDir.replace(QLatin1Char('/'), QLatin1Char('-')); + QString worktreePath = parentDir.absoluteFilePath(repoName + QLatin1Char('.') + branchDir); + + if (QFileInfo::exists(worktreePath)) { + setMessage(QStringLiteral("Directory already exists: ") + worktreePath, true); + return; + } + + // Check if the branch exists + bool branchExists = m_allBranches.contains(branch); + + // git worktree add -- 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)); +} + +#include "gitplusplus.moc" diff --git a/gitplusplus.h b/gitplusplus.h new file mode 100644 index 0000000..102b929 --- /dev/null +++ b/gitplusplus.h @@ -0,0 +1,180 @@ +#ifndef GITPLUSPLUS_H +#define GITPLUSPLUS_H + +#include +#include +#include +#include +#if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#include +#endif + +#include +#include +#include +#include + +class GitPlusPlusView; +class GitStatusModel; +class QTreeView; +class QListWidget; +class QLineEdit; +class QTextEdit; +class QToolButton; +class QLabel; +class QComboBox; +class QCheckBox; +class QTabWidget; +class QSortFilterProxyModel; +class QStringListModel; +class QFileSystemWatcher; + +/** + * Git++ Plugin - standalone KTextEditor plugin providing git operations. + */ +class GitPlusPlusPlugin : public KTextEditor::Plugin +{ + Q_OBJECT + +public: +#if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(6, 0, 0) + explicit GitPlusPlusPlugin(QObject *parent, const KPluginMetaData &data, const QVariantList &args = {}); +#else + explicit GitPlusPlusPlugin(QObject *parent, const QVariantList &args = {}); +#endif + ~GitPlusPlusPlugin() override = default; + + QObject *createView(KTextEditor::MainWindow *mainWindow) override; +}; + +/** + * Per-main-window view providing the Git++ tool view. + */ +class GitPlusPlusView : public QObject +{ + Q_OBJECT + +public: + explicit GitPlusPlusView(GitPlusPlusPlugin *plugin, KTextEditor::MainWindow *mainWindow); + ~GitPlusPlusView() override; + +private Q_SLOTS: + // Status + void updateStatus(); + void onStatusFinished(int exitCode, QProcess::ExitStatus exitStatus); + + // Stage/commit + void stageSelected(); + void unstageSelected(); + void discardSelected(); + void commitChanges(); + + // Remote + void push(); + void pull(); + void fetch(); + + // Branches + void refreshBranches(); + void onBranchFinished(int exitCode, QProcess::ExitStatus exitStatus); + void checkoutBranch(); + void createBranch(); + void deleteBranch(); + void compareBranch(); + void createWorktree(); + + // Worktrees + void refreshWorktrees(); + void createWorktreeFromTab(); + void removeWorktree(); + void pruneWorktrees(); + void worktreeContextMenu(const QPoint &pos); + + // Stash + void stashSave(); + void stashApply(); + void stashPop(); + void stashDrop(); + void stashDiff(); + void refreshStashes(); + void stashContextMenu(const QPoint &pos); + + // Log/commit + void openCommit(); + + // Branch context menu + void branchContextMenu(const QPoint &pos); + +private: + QString projectBaseDir() const; + QProcess *gitProcess(const QStringList &args) const; + void runSimpleGit(const QStringList &args, const QString &successMsg, const QString &errorMsg); + void runRemoteGit(const QStringList &args, const QString &opName); + void setMessage(const QString &text, bool isError = false); + void updateIndicators(); + void setRemoteBusy(bool busy, const QString &opName = {}); + void refreshAll(); + void setupFileWatcher(); + QStringList selectedFiles(bool staged) const; + QString selectedBranch() const; + QString selectedStash() const; + + GitPlusPlusPlugin *m_plugin; + KTextEditor::MainWindow *m_mainWindow; + QWidget *m_toolView; + + // Tab widget + QTabWidget *m_tabWidget; + + // --- Status tab --- + QTreeView *m_treeView; + GitStatusModel *m_model; + QSortFilterProxyModel *m_proxy; + QLineEdit *m_filterEdit; + QTextEdit *m_commitMsgEdit; + QCheckBox *m_amendCheck; + QLabel *m_statusLabel; + QToolButton *m_commitBtn; + QToolButton *m_pushBtn; + QToolButton *m_pullBtn; + QToolButton *m_fetchBtn; + QToolButton *m_stageBtn; + QToolButton *m_unstageBtn; + QToolButton *m_discardBtn; + QToolButton *m_refreshBtn; + QToolButton *m_stashBtn; + + // --- Branches tab --- + QLineEdit *m_branchFilterEdit; + QListWidget *m_branchList; + QToolButton *m_branchCheckoutBtn; + QToolButton *m_branchCreateBtn; + QToolButton *m_branchDeleteBtn; + QToolButton *m_branchCompareBtn; + + // --- Stashes tab --- + QListWidget *m_stashList; + QToolButton *m_stashSaveBtn; + QToolButton *m_stashApplyBtn; + QToolButton *m_stashPopBtn; + QToolButton *m_stashDropBtn; + + // --- Worktrees tab --- + QListWidget *m_worktreeList; + + // --- Indicators --- + QLabel *m_repoLabel; + QLabel *m_statusBarWidget; + + // State + QString m_gitDir; + QString m_currentBranch; + QStringList m_allBranches; + bool m_isDirty = false; + QPointer m_remoteProc; + int m_spinnerFrame = 0; + QTimer m_fsDebounce; + QFileSystemWatcher *m_watcher = nullptr; +}; + +#endif // GITPLUSPLUS_H diff --git a/gitprocess.h b/gitprocess.h new file mode 100644 index 0000000..0e56aa2 --- /dev/null +++ b/gitprocess.h @@ -0,0 +1,68 @@ +#ifndef GITPROCESS_H +#define GITPROCESS_H + +#include +#include +#include +#include + +/** + * Helper to create a QProcess configured for running git in a given working directory. + * Sets up SSH_ASKPASS for password prompts (ksshaskpass if available). + */ +namespace GitProcess +{ + +/** + * Create a QProcess ready to run git with the given arguments in workingDir. + * The caller owns the returned QProcess. + */ +inline QProcess *create(const QString &workingDir, const QStringList &args, QObject *parent = nullptr) +{ + auto *proc = new QProcess(parent); + proc->setWorkingDirectory(workingDir); + proc->setProgram(QStringLiteral("git")); + proc->setArguments(args); + + // Set up environment for SSH password prompts + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + // ksshaskpass for KDE integration + const QString ksshaskpass = QStandardPaths::findExecutable(QStringLiteral("ksshaskpass")); + if (!ksshaskpass.isEmpty()) { + env.insert(QStringLiteral("SSH_ASKPASS"), ksshaskpass); + env.insert(QStringLiteral("GIT_ASKPASS"), ksshaskpass); + } + // Avoid git pager + env.insert(QStringLiteral("GIT_TERMINAL_PROMPT"), QStringLiteral("0")); + proc->setProcessEnvironment(env); + + return proc; +} + +/** + * Run git synchronously and return stdout. Returns empty QByteArray on failure. + */ +inline QByteArray run(const QString &workingDir, const QStringList &args) +{ + QProcess proc; + proc.setWorkingDirectory(workingDir); + proc.setProgram(QStringLiteral("git")); + proc.setArguments(args); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("GIT_TERMINAL_PROMPT"), QStringLiteral("0")); + proc.setProcessEnvironment(env); + + proc.start(); + if (!proc.waitForFinished(10000)) { + return {}; + } + if (proc.exitCode() != 0) { + return {}; + } + return proc.readAllStandardOutput(); +} + +} // namespace GitProcess + +#endif // GITPROCESS_H diff --git a/gitstatusmodel.cpp b/gitstatusmodel.cpp new file mode 100644 index 0000000..d89f7e4 --- /dev/null +++ b/gitstatusmodel.cpp @@ -0,0 +1,194 @@ +#include "gitstatusmodel.h" + +#include +#include + +GitStatusModel::GitStatusModel(QObject *parent) + : QAbstractItemModel(parent) +{ + m_nodeNames[NodeStaged] = QStringLiteral("Staged"); + m_nodeNames[NodeChanged] = QStringLiteral("Changed"); + m_nodeNames[NodeUntracked] = QStringLiteral("Untracked"); + + m_nodeIcons[NodeStaged] = QIcon::fromTheme(QStringLiteral("vcs-added")); + m_nodeIcons[NodeChanged] = QIcon::fromTheme(QStringLiteral("vcs-locally-modified")); + m_nodeIcons[NodeUntracked] = QIcon::fromTheme(QStringLiteral("vcs-added")); +} + +QModelIndex GitStatusModel::index(int row, int column, const QModelIndex &parent) const +{ + if (column != 0) + return {}; + + if (!parent.isValid()) { + // Top-level nodes + if (row >= 0 && row < NodeCount) + return createIndex(row, 0, TopLevelId); + return {}; + } + + // Child items — parent's internalId is TopLevelId, parent's row gives the node type + if (parent.internalId() == TopLevelId) { + int nodeType = parent.row(); + if (nodeType >= 0 && nodeType < NodeCount && row >= 0 && row < m_entries[nodeType].size()) + return createIndex(row, 0, quintptr(nodeType)); + } + + return {}; +} + +QModelIndex GitStatusModel::parent(const QModelIndex &child) const +{ + if (!child.isValid()) + return {}; + + if (child.internalId() == TopLevelId) + return {}; // top-level has no parent + + // internalId is the node type + int nodeType = int(child.internalId()); + if (nodeType >= 0 && nodeType < NodeCount) + return createIndex(nodeType, 0, TopLevelId); + + return {}; +} + +int GitStatusModel::rowCount(const QModelIndex &parent) const +{ + if (!parent.isValid()) + return NodeCount; + + if (parent.internalId() == TopLevelId) { + int nodeType = parent.row(); + if (nodeType >= 0 && nodeType < NodeCount) + return m_entries[nodeType].size(); + } + + return 0; +} + +int GitStatusModel::columnCount(const QModelIndex &) const +{ + return 1; +} + +QVariant GitStatusModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) + return {}; + + // Top-level nodes + if (index.internalId() == TopLevelId) { + int nodeType = index.row(); + if (nodeType < 0 || nodeType >= NodeCount) + return {}; + + switch (role) { + case Qt::DisplayRole: + return QStringLiteral("%1 (%2)").arg(m_nodeNames[nodeType]).arg(m_entries[nodeType].size()); + case Qt::DecorationRole: + return m_nodeIcons[nodeType]; + case Qt::FontRole: { + QFont f; + f.setBold(true); + return f; + } + case NodeTypeRole: + return nodeType; + default: + return {}; + } + } + + // File entries + int nodeType = int(index.internalId()); + if (nodeType < 0 || nodeType >= NodeCount) + return {}; + + int row = index.row(); + if (row < 0 || row >= m_entries[nodeType].size()) + return {}; + + const auto &entry = m_entries[nodeType][row]; + + switch (role) { + case Qt::DisplayRole: + return entry.file; + case Qt::DecorationRole: { + // Status char icon + QChar sc = (nodeType == NodeStaged) ? entry.indexStatus : entry.workStatus; + if (sc == QLatin1Char('M')) + return QIcon::fromTheme(QStringLiteral("vcs-locally-modified")); + if (sc == QLatin1Char('A') || sc == QLatin1Char('?')) + return QIcon::fromTheme(QStringLiteral("vcs-added")); + if (sc == QLatin1Char('D')) + return QIcon::fromTheme(QStringLiteral("vcs-removed")); + if (sc == QLatin1Char('R')) + return QIcon::fromTheme(QStringLiteral("vcs-locally-modified")); + return QIcon::fromTheme(QStringLiteral("text-plain")); + } + case Qt::ToolTipRole: { + QChar sc = (nodeType == NodeStaged) ? entry.indexStatus : entry.workStatus; + return QStringLiteral("[%1] %2").arg(sc).arg(entry.file); + } + case FilePathRole: + return entry.file; + case NodeTypeRole: + return nodeType; + default: + return {}; + } +} + +Qt::ItemFlags GitStatusModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) + return Qt::NoItemFlags; + + if (index.internalId() == TopLevelId) + return Qt::ItemIsEnabled; // top-level nodes not selectable + + return Qt::ItemIsEnabled | Qt::ItemIsSelectable; +} + +void GitStatusModel::parseStatus(const QByteArray &raw) +{ + beginResetModel(); + + for (int i = 0; i < NodeCount; ++i) + m_entries[i].clear(); + + const auto lines = raw.split('\n'); + for (const auto &line : lines) { + if (line.size() < 4) + continue; + + // Format: XY filename + // or XY old -> new (for renames) + QChar x = QLatin1Char(line.at(0)); + QChar y = QLatin1Char(line.at(1)); + // line[2] is space + QString file = QString::fromUtf8(line.mid(3)); + + // Handle renames: "R old -> new" + int arrowIdx = file.indexOf(QStringLiteral(" -> ")); + if (arrowIdx >= 0) + file = file.mid(arrowIdx + 4); + + GitStatusEntry entry{file, x, y}; + + // Staged: X is not ' ', '?' or '!' + if (x != QLatin1Char(' ') && x != QLatin1Char('?') && x != QLatin1Char('!')) { + m_entries[NodeStaged].append(entry); + } + + // Changed (working tree): Y is not ' ', '?' or '!' + if (y == QLatin1Char('?')) { + m_entries[NodeUntracked].append(entry); + } else if (y != QLatin1Char(' ') && y != QLatin1Char('!')) { + m_entries[NodeChanged].append(entry); + } + } + + endResetModel(); +} diff --git a/gitstatusmodel.h b/gitstatusmodel.h new file mode 100644 index 0000000..c4e06d5 --- /dev/null +++ b/gitstatusmodel.h @@ -0,0 +1,69 @@ +#ifndef GITSTATUSMODEL_H +#define GITSTATUSMODEL_H + +#include +#include +#include +#include + +/** + * Represents a single file entry in git status output. + */ +struct GitStatusEntry { + QString file; // relative path + QChar indexStatus; // X column from --porcelain=v1 + QChar workStatus; // Y column from --porcelain=v1 +}; + +/** + * Tree model for displaying git status. + * Has three top-level nodes: Staged, Changed (unstaged), Untracked. + * Each node contains file entries. + */ +class GitStatusModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum NodeType { + NodeStaged = 0, + NodeChanged, + NodeUntracked, + NodeCount + }; + + enum Roles { + FilePathRole = Qt::UserRole + 1, + NodeTypeRole, + }; + + explicit GitStatusModel(QObject *parent = nullptr); + ~GitStatusModel() override = default; + + // --- QAbstractItemModel interface --- + QModelIndex index(int row, int column, const QModelIndex &parent = {}) const override; + QModelIndex parent(const QModelIndex &child) const override; + int rowCount(const QModelIndex &parent = {}) const override; + int columnCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + + /** + * Parse raw output from `git status --porcelain=v1` and populate the model. + */ + void parseStatus(const QByteArray &raw); + + /** + * Get files for a given node type. + */ + const QList &entries(NodeType type) const { return m_entries[type]; } + +private: + static constexpr quintptr TopLevelId = 0xFFFFFFFF; + + QList m_entries[NodeCount]; + QString m_nodeNames[NodeCount]; + QIcon m_nodeIcons[NodeCount]; +}; + +#endif // GITSTATUSMODEL_H diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..5137cc7 --- /dev/null +++ b/metadata.json @@ -0,0 +1,14 @@ +{ + "KPlugin": { + "Id": "gitplusplus", + "Name": "Git++", + "Description": "Git integration with status, commit, push, pull, fetch, stage, unstage, and branch operations", + "Icon": "vcs-commit", + "Authors": [ + { + "Name": "lkn" + } + ] + }, + "X-KDE-PluginInfo-EnabledByDefault": true +}