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
This commit is contained in:
Levi Neely 2026-07-15 15:59:37 +02:00 committed by Levi Neely
commit ab2d531240
10 changed files with 2263 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build/

34
CMakeLists.txt Normal file
View File

@ -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})

15
Makefile Normal file
View File

@ -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)

137
README.md Normal file
View File

@ -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.

1551
gitplusplus.cpp Normal file

File diff suppressed because it is too large Load Diff

180
gitplusplus.h Normal file
View File

@ -0,0 +1,180 @@
#ifndef GITPLUSPLUS_H
#define GITPLUSPLUS_H
#include <KTextEditor/Plugin>
#include <KTextEditor/MainWindow>
#include <KTextEditor/View>
#include <ktexteditor_version.h>
#if KTEXTEDITOR_VERSION >= QT_VERSION_CHECK(6, 0, 0)
#include <KPluginMetaData>
#endif
#include <QPointer>
#include <QProcess>
#include <QWidget>
#include <QTimer>
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<QProcess> m_remoteProc;
int m_spinnerFrame = 0;
QTimer m_fsDebounce;
QFileSystemWatcher *m_watcher = nullptr;
};
#endif // GITPLUSPLUS_H

68
gitprocess.h Normal file
View File

@ -0,0 +1,68 @@
#ifndef GITPROCESS_H
#define GITPROCESS_H
#include <QProcess>
#include <QString>
#include <QStringList>
#include <QStandardPaths>
/**
* 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

194
gitstatusmodel.cpp Normal file
View File

@ -0,0 +1,194 @@
#include "gitstatusmodel.h"
#include <QFont>
#include <QIcon>
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 <space> filename
// or XY <space> 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();
}

69
gitstatusmodel.h Normal file
View File

@ -0,0 +1,69 @@
#ifndef GITSTATUSMODEL_H
#define GITSTATUSMODEL_H
#include <QAbstractItemModel>
#include <QList>
#include <QString>
#include <QIcon>
/**
* 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<GitStatusEntry> &entries(NodeType type) const { return m_entries[type]; }
private:
static constexpr quintptr TopLevelId = 0xFFFFFFFF;
QList<GitStatusEntry> m_entries[NodeCount];
QString m_nodeNames[NodeCount];
QIcon m_nodeIcons[NodeCount];
};
#endif // GITSTATUSMODEL_H

14
metadata.json Normal file
View File

@ -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
}