commit ab2d531240a51d8548e74804ab695f6aa47d1725 Author: Levi Neely Date: Wed Jul 15 15:59:37 2026 +0200 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 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 +}