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:
parent
a327a1a12e
commit
f5b532c77d
26
docs/PLAN.md
26
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue