Project: build on Kate's project plugin via KateProjectBridge

Read kateprojectplugin's view through the meta-object system (no link
dependency): projectBaseDir/projectName/projectFiles properties plus
projectMapChanged/projectFileNameChanged/pluginProjectAdded|Removed
signals relayed to one projectChanged(). OllieView attaches on
construction and follows MainWindow::pluginViewCreated/Deleted.

currentProjectRoot() becomes three-tier: explicit sticky folder ->
bridge baseDir (Kate's loaded project) -> VCS discovery from the active
doc. 7 headless tests via a stand-in QObject mirroring the plugin's
property/signal surface.
This commit is contained in:
Levi Neely 2026-10-08 10:03:15 +02:00
parent a327a1a12e
commit f5b532c77d
7 changed files with 311 additions and 10 deletions

View File

@ -211,13 +211,25 @@ Therefore the palette is replaced, not extended, and the replacement is built on
existing explicit pick is honoured **exactly** (no walk-up — VSCode/Sublime
"open folder" semantics); otherwise it derives via `findRoot(startDir)` with
cwd as the final fallback. A stale explicit root reverts to discovery.
- Explicit folder-as-project (our own model; we do **not** use Kate's project
plugin): **Alt+O** "Open Folder as Project…" pops a directory chooser and
makes the pick the **sticky** root. It persists to
`$XDG_CONFIG_HOME/olliepalette/project` and is restored on load (only if it
still exists). "Close Project" (palette `ollie:project:close`) clears it and
reverts to auto-derive. Both are in the M-x palette (`ollie:project:open` /
`:close`), the latter shown only when a project is open.
- Explicit folder-as-project (our own model; we build **on top of** Kate's
project plugin rather than replacing it): **Alt+O** "Open Folder as Project…"
pops a directory chooser and makes the pick the **sticky** root. It persists
to `$XDG_CONFIG_HOME/olliepalette/project` and is restored on load (only if
it still exists). "Close Project" (palette `ollie:project:close`) clears it
and reverts to auto-derive. Both are in the M-x palette
(`ollie:project:open` / `:close`), the latter shown only when open.
- `KateProjectBridge` (`src/project/kateprojectbridge.{h,cpp}`, in
`project_lib`, 7 unit tests) reads Kate's built-in `kateprojectplugin` view
through the **meta-object system** — `projectBaseDir` / `projectName` /
`projectFiles` properties and the `projectMapChanged` /
`projectFileNameChanged` / `pluginProjectAdded|Removed` signals (relayed to
a single `projectChanged()`), with **no link dependency**. `OllieView`
attaches it on construction and follows `MainWindow::pluginViewCreated` /
`pluginViewDeleted`. Tested headlessly against a stand-in QObject that
mirrors the plugin's property/signal surface.
- `currentProjectRoot()` is a **three-tier** resolution: explicit sticky
folder → `KateProjectBridge::baseDir()` (Kate's loaded project) →
`ProjectIndex::resolveRoot("", activeDocDir)` (VCS discovery, cwd fallback).
- `listFiles(root)` prefers `git ls-files --cached --others
--exclude-standard` inside a git work tree (honours .gitignore, includes
untracked-not-ignored), and falls back to a bounded recursive walk

View File

@ -280,6 +280,26 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
// Restore the previously opened project folder (if any, and still present).
loadProjectRoot();
// Build on Kate's project plugin: attach to its view now (if present) and
// follow it as plugins come and go. The bridge is read-only; our explicit
// folder still takes precedence in currentProjectRoot().
{
static const QString kProjectPlugin = QStringLiteral("kateprojectplugin");
m_projectBridge.setProjectView(m_mainWindow->pluginView(kProjectPlugin));
connect(m_mainWindow, &KTextEditor::MainWindow::pluginViewCreated, this,
[this](const QString &name, QObject *view) {
if (name == QStringLiteral("kateprojectplugin")) {
m_projectBridge.setProjectView(view);
}
});
connect(m_mainWindow, &KTextEditor::MainWindow::pluginViewDeleted, this,
[this](const QString &name, QObject *) {
if (name == QStringLiteral("kateprojectplugin")) {
m_projectBridge.setProjectView(nullptr);
}
});
}
setupRadials();
// One application-wide filter serves both the mouse-button radial gesture
@ -517,9 +537,17 @@ QString OllieView::activeDocumentDir() const
QString OllieView::currentProjectRoot() const
{
// Explicit folder wins (sticky, VSCode/Sublime style); otherwise derive
// from the active document, falling back to cwd inside resolveRoot().
return ProjectIndex::resolveRoot(m_projectRoot, activeDocumentDir());
// Precedence: an explicit folder the user opened (sticky) wins; then Kate's
// project plugin (if a project is loaded); then discovery from the active
// document, with cwd as the final fallback inside resolveRoot().
if (!m_projectRoot.isEmpty() && QDir(m_projectRoot).exists()) {
return QDir(m_projectRoot).absolutePath();
}
const QString bridgeDir = m_projectBridge.baseDir();
if (!bridgeDir.isEmpty() && QDir(bridgeDir).exists()) {
return QDir(bridgeDir).absolutePath();
}
return ProjectIndex::resolveRoot(QString(), activeDocumentDir());
}
void OllieView::openProjectFolder()

View File

@ -14,6 +14,7 @@
#include <KTextEditor/Plugin>
#include "frecencystore.h"
#include "kateprojectbridge.h"
#include <QObject>
#include <QPointer>
@ -139,6 +140,7 @@ private:
QHash<QString, QPointer<QAction>> m_actionsById;
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
QString m_projectRoot; // explicitly opened folder (empty = auto-derive)
KateProjectBridge m_projectBridge; // read-only view of Kate's project plugin
};
} // namespace katecustom

View File

@ -3,6 +3,8 @@ add_library(project_lib STATIC
projectindex.h
symbolindex.cpp
symbolindex.h
kateprojectbridge.cpp
kateprojectbridge.h
)
target_link_libraries(project_lib PUBLIC
Qt6::Core
@ -17,4 +19,8 @@ if(Qt6Test_FOUND)
add_executable(test_symbolindex test_symbolindex.cpp)
target_link_libraries(test_symbolindex PRIVATE project_lib Qt6::Core Qt6::Test)
add_test(NAME symbolindex COMMAND test_symbolindex)
add_executable(test_kateprojectbridge test_kateprojectbridge.cpp)
target_link_libraries(test_kateprojectbridge PRIVATE project_lib Qt6::Core Qt6::Test)
add_test(NAME kateprojectbridge COMMAND test_kateprojectbridge)
endif()

View File

@ -0,0 +1,83 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "kateprojectbridge.h"
#include <QMetaMethod>
#include <QMetaObject>
namespace katecustom
{
KateProjectBridge::KateProjectBridge(QObject *parent)
: QObject(parent)
{
}
void KateProjectBridge::setProjectView(QObject *view)
{
if (m_view == view) {
return;
}
if (m_view) {
disconnect(m_view, nullptr, this, nullptr);
}
m_view = view;
if (m_view) {
// Relay the project plugin's change notifications to projectChanged().
// Connect by name only when the signal exists, so a stand-in object (or
// a future API revision) does not trigger connect warnings.
const QMetaObject *mo = m_view->metaObject();
const char *signals_[] = {
"projectFileNameChanged()",
"projectMapChanged()",
"pluginProjectAdded(QString,QString)",
"pluginProjectRemoved(QString,QString)",
};
for (const char *sig : signals_) {
const QByteArray norm = QMetaObject::normalizedSignature(sig);
const int idx = mo->indexOfSignal(norm.constData());
if (idx < 0) {
continue;
}
const QMetaMethod signal = mo->method(idx);
const QMetaMethod relay =
metaObject()->method(metaObject()->indexOfSignal("projectChanged()"));
connect(m_view, signal, this, relay);
}
}
Q_EMIT projectChanged();
}
bool KateProjectBridge::isAvailable() const
{
return !m_view.isNull();
}
QString KateProjectBridge::baseDir() const
{
if (m_view.isNull()) {
return QString();
}
return m_view->property("projectBaseDir").toString();
}
QString KateProjectBridge::name() const
{
if (m_view.isNull()) {
return QString();
}
return m_view->property("projectName").toString();
}
QStringList KateProjectBridge::files() const
{
if (m_view.isNull()) {
return QStringList();
}
return m_view->property("projectFiles").toStringList();
}
} // namespace katecustom

View File

@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* KateProjectBridge — read-only adaptor over Kate's built-in project plugin.
*
* Kate ships `kateprojectplugin`, whose per-window view object exposes a
* runtime API via Qt properties and signals (projectBaseDir, projectName,
* projectFiles; projectFileNameChanged / projectMapChanged). We consume that
* API through the meta-object system — no link dependency, no private headers —
* so our switchers and file tree can reuse the plugin's git-aware index and
* follow project switches, while our own UI/keybindings stay in charge.
*
* The bridge is deliberately tolerant: when no project view is attached (plugin
* disabled, not yet created, or a different Kate build) every accessor returns
* an empty value and callers fall back to our own ProjectIndex.
*/
#ifndef KATECUSTOM_KATEPROJECTBRIDGE_H
#define KATECUSTOM_KATEPROJECTBRIDGE_H
#include <QObject>
#include <QPointer>
#include <QString>
#include <QStringList>
namespace katecustom
{
class KateProjectBridge : public QObject
{
Q_OBJECT
public:
explicit KateProjectBridge(QObject *parent = nullptr);
/*!
* Attach (or, with \a view == nullptr, detach) the project plugin's view
* object. Reconnects change notifications and emits projectChanged().
*/
void setProjectView(QObject *view);
/*! True when a project view is currently attached. */
bool isAvailable() const;
/*! The active project's base directory, or empty when unavailable. */
QString baseDir() const;
/*! The active project's display name, or empty when unavailable. */
QString name() const;
/*! The active project's files (relative to baseDir()), or empty. */
QStringList files() const;
Q_SIGNALS:
/*! Emitted when the attached project changes (switch, reload, detach). */
void projectChanged();
private:
QPointer<QObject> m_view;
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,107 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* Unit tests for KateProjectBridge. Uses a stand-in QObject that mimics the
* kateprojectplugin view's property/signal surface, so the meta-object-based
* reads and the change-signal relay can be exercised headlessly.
*/
#include "kateprojectbridge.h"
#include <QSignalSpy>
#include <QTest>
using namespace katecustom;
// Stand-in with the same Q_PROPERTY names and one change signal as the real
// KateProjectPluginView.
class FakeProjectView : public QObject
{
Q_OBJECT
Q_PROPERTY(QString projectBaseDir READ projectBaseDir)
Q_PROPERTY(QString projectName READ projectName)
Q_PROPERTY(QStringList projectFiles READ projectFiles)
public:
QString m_base;
QString m_name;
QStringList m_files;
QString projectBaseDir() const { return m_base; }
QString projectName() const { return m_name; }
QStringList projectFiles() const { return m_files; }
void bumpProject() { Q_EMIT projectMapChanged(); }
Q_SIGNALS:
void projectMapChanged();
void projectFileNameChanged();
};
class TestKateProjectBridge : public QObject
{
Q_OBJECT
private Q_SLOTS:
void unavailableWhenDetached()
{
KateProjectBridge b;
QVERIFY(!b.isAvailable());
QVERIFY(b.baseDir().isEmpty());
QVERIFY(b.name().isEmpty());
QVERIFY(b.files().isEmpty());
}
void readsPropertiesWhenAttached()
{
FakeProjectView view;
view.m_base = QStringLiteral("/home/me/proj");
view.m_name = QStringLiteral("proj");
view.m_files = {QStringLiteral("a.cpp"), QStringLiteral("src/b.h")};
KateProjectBridge b;
b.setProjectView(&view);
QVERIFY(b.isAvailable());
QCOMPARE(b.baseDir(), QStringLiteral("/home/me/proj"));
QCOMPARE(b.name(), QStringLiteral("proj"));
QCOMPARE(b.files(), (QStringList{QStringLiteral("a.cpp"), QStringLiteral("src/b.h")}));
}
void emitsOnAttachAndDetach()
{
FakeProjectView view;
KateProjectBridge b;
QSignalSpy spy(&b, &KateProjectBridge::projectChanged);
b.setProjectView(&view);
QCOMPARE(spy.count(), 1); // attach
b.setProjectView(nullptr);
QCOMPARE(spy.count(), 2); // detach
QVERIFY(!b.isAvailable());
}
void relaysViewChangeSignal()
{
FakeProjectView view;
KateProjectBridge b;
b.setProjectView(&view);
QSignalSpy spy(&b, &KateProjectBridge::projectChanged);
view.bumpProject();
QCOMPARE(spy.count(), 1); // relayed projectMapChanged -> projectChanged
}
void survivesViewDestruction()
{
KateProjectBridge b;
{
FakeProjectView view;
view.m_base = QStringLiteral("/tmp/x");
b.setProjectView(&view);
QVERIFY(b.isAvailable());
}
// QPointer goes null after the view is destroyed; accessors are safe.
QVERIFY(!b.isAvailable());
QVERIFY(b.baseDir().isEmpty());
}
};
QTEST_MAIN(TestKateProjectBridge)
#include "test_kateprojectbridge.moc"