From f5b532c77d6dec7afefc6425c8b4c8cda2f86740 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 8 Oct 2026 10:03:15 +0200 Subject: [PATCH] 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. --- docs/PLAN.md | 26 ++++-- src/plugin/ollieplugin.cpp | 34 +++++++- src/plugin/ollieplugin.h | 2 + src/project/CMakeLists.txt | 6 ++ src/project/kateprojectbridge.cpp | 83 +++++++++++++++++++ src/project/kateprojectbridge.h | 63 +++++++++++++++ src/project/test_kateprojectbridge.cpp | 107 +++++++++++++++++++++++++ 7 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 src/project/kateprojectbridge.cpp create mode 100644 src/project/kateprojectbridge.h create mode 100644 src/project/test_kateprojectbridge.cpp diff --git a/docs/PLAN.md b/docs/PLAN.md index bce30a1..c457f9d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 030b703..701e352 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -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() diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index ae23d9d..5cd7b46 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -14,6 +14,7 @@ #include #include "frecencystore.h" +#include "kateprojectbridge.h" #include #include @@ -139,6 +140,7 @@ private: QHash> 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 diff --git a/src/project/CMakeLists.txt b/src/project/CMakeLists.txt index b2562e0..74aca30 100644 --- a/src/project/CMakeLists.txt +++ b/src/project/CMakeLists.txt @@ -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() diff --git a/src/project/kateprojectbridge.cpp b/src/project/kateprojectbridge.cpp new file mode 100644 index 0000000..8079152 --- /dev/null +++ b/src/project/kateprojectbridge.cpp @@ -0,0 +1,83 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "kateprojectbridge.h" + +#include +#include + +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 diff --git a/src/project/kateprojectbridge.h b/src/project/kateprojectbridge.h new file mode 100644 index 0000000..e228ad7 --- /dev/null +++ b/src/project/kateprojectbridge.h @@ -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 +#include +#include +#include + +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 m_view; +}; + +} // namespace katecustom + +#endif diff --git a/src/project/test_kateprojectbridge.cpp b/src/project/test_kateprojectbridge.cpp new file mode 100644 index 0000000..11c5804 --- /dev/null +++ b/src/project/test_kateprojectbridge.cpp @@ -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 +#include + +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"