gui: integrate bookmarks with plan9 plumber

- Add ollie:// URL scheme for plumb messages
  Format: ollie://session/agent#block
- Plumber listens on 'ollie' port for navigation messages
- Bookmarks show 📋 button to copy ollie:// link
- Add data/plumbing with rules to route ollie:// URLs
- On plumb message: switch session/agent, scroll to block, raise window
This commit is contained in:
Levi Neely 2026-10-09 13:27:40 +02:00
parent 96b6406871
commit ecbea0fe90
6 changed files with 132 additions and 25 deletions

10
data/plumbing Normal file
View File

@ -0,0 +1,10 @@
# Ollie plumbing rules
# Include this file in $HOME/lib/plumbing:
# include /path/to/ollie/data/plumbing
# Then reload: cat $HOME/lib/plumbing | 9p write plumb/rules
# ollie:// URLs go to the ollie port
# Format: ollie://session/agent#block
type is text
data matches 'ollie://[a-zA-Z0-9_\-./]+(/[a-zA-Z0-9_\-./]+)?(#[0-9]+)?'
plumb to ollie

View File

@ -421,6 +421,8 @@ Item {
width: bookmarksList.width width: bookmarksList.width
height: 36 height: 36
property string ollieUrl: "ollie://" + encodeURIComponent(sessionName) + "/" + encodeURIComponent(agentName) + "#" + blockIndex
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: 8 anchors.leftMargin: 8
@ -446,6 +448,16 @@ Item {
} }
} }
ToolButton {
text: "📋"
implicitWidth: 20
implicitHeight: 20
onClicked: clipboard.setText(ollieUrl)
ToolTip.visible: hovered
ToolTip.delay: 500
ToolTip.text: "Copy ollie:// link"
}
ToolButton { ToolButton {
text: "✕" text: "✕"
implicitWidth: 20 implicitWidth: 20

View File

@ -79,6 +79,9 @@ const QUrl url(QStringLiteral("qrc:/main.qml"));
engine.rootContext()->setContextProperty("trayIcon", trayIcon); engine.rootContext()->setContextProperty("trayIcon", trayIcon);
// Start plumber reader for ollie port
plumber->startReader();
QObject::connect(showAction, &QAction::triggered, [window, trayIcon]() { QObject::connect(showAction, &QAction::triggered, [window, trayIcon]() {
window->show(); window->show();
window->raise(); window->raise();

View File

@ -61,6 +61,61 @@ ApplicationWindow {
return false return false
} }
// Handle plumb messages from the ollie port
// URL format: ollie://session/agent#block
function handleOllieMessage(url) {
// Parse ollie://session/agent#block
var match = url.match(/^ollie:\/\/([^\/]+)(?:\/([^#]+))?(?:#(\d+))?$/)
if (!match) {
console.log("Invalid ollie URL:", url)
return
}
var sessionName = decodeURIComponent(match[1])
var agentName = match[2] ? decodeURIComponent(match[2]) : ""
var blockIndex = match[3] ? parseInt(match[3]) : -1
// Find session by name and switch to it
var sessions = ollie.sessions
for (var i = 0; i < sessions.length; i++) {
var s = sessions[i]
if (s.name === sessionName) {
ollie.setActiveSessionId(s.id)
// If agent specified, find and switch to it
if (agentName && s.agents) {
for (var j = 0; j < s.agents.length; j++) {
var a = s.agents[j]
if (a.name === agentName) {
ollie.setActiveAgentId(a.id)
break
}
}
}
// If block specified, scroll to it after a short delay
if (blockIndex >= 0) {
Qt.callLater(function() {
chatPane.scrollToBlock(blockIndex)
})
}
// Raise window
root.show()
root.raise()
root.requestActivate()
return
}
}
console.log("Session not found:", sessionName)
}
Connections {
target: plumber
function onOllieMessage(url) {
handleOllieMessage(url)
}
}
// Apply theme palette to all Qt Quick Controls // Apply theme palette to all Qt Quick Controls
palette.active.window: theme.windowColor palette.active.window: theme.windowColor
palette.active.windowText: theme.windowTextColor palette.active.windowText: theme.windowTextColor

View File

@ -60,7 +60,7 @@ static QString currentUser()
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PlumbReader: owns a NineP session on the edit port, loops on blocking reads. // PlumbReader: owns a NineP session on a named port, loops on blocking reads.
// Lives on its own QThread. Emits parsed messages via queued signals so the // Lives on its own QThread. Emits parsed messages via queued signals so the
// Plumber (GUI thread) can act on them. // Plumber (GUI thread) can act on them.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -68,9 +68,10 @@ class PlumbReader : public QThread
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit PlumbReader(const QString &socketPath, QObject *parent = nullptr) explicit PlumbReader(const QString &socketPath, const QString &portName, QObject *parent = nullptr)
: QThread(parent) : QThread(parent)
, m_socketPath(socketPath) , m_socketPath(socketPath)
, m_portName(portName)
{ {
} }
@ -80,7 +81,7 @@ public:
} }
Q_SIGNALS: Q_SIGNALS:
void message(const QString &file, const QString &addr, const QString &wdir); void message(const QString &data, const QString &addr, const QString &wdir);
void failed(const QString &message); void failed(const QString &message);
protected: protected:
@ -91,9 +92,9 @@ protected:
Q_EMIT failed(QStringLiteral("plumb connect: %1").arg(nine.errorString())); Q_EMIT failed(QStringLiteral("plumb connect: %1").arg(nine.errorString()));
return; return;
} }
static constexpr uint32_t EditFid = 1; static constexpr uint32_t PortFid = 1;
if (!nine.walk(EditFid, QStringLiteral("edit")) || !nine.open(EditFid, NineP::OREAD)) { if (!nine.walk(PortFid, m_portName) || !nine.open(PortFid, NineP::OREAD)) {
Q_EMIT failed(QStringLiteral("plumb open edit: %1").arg(nine.errorString())); Q_EMIT failed(QStringLiteral("plumb open %1: %2").arg(m_portName, nine.errorString()));
return; return;
} }
@ -101,7 +102,7 @@ protected:
bool pending = false; bool pending = false;
while (!m_stop.loadRelaxed()) { while (!m_stop.loadRelaxed()) {
if (!pending) { if (!pending) {
if (!nine.beginRead(EditFid, 0, nine.msize())) { if (!nine.beginRead(PortFid, 0, nine.msize())) {
Q_EMIT failed(QStringLiteral("plumb read: %1").arg(nine.errorString())); Q_EMIT failed(QStringLiteral("plumb read: %1").arg(nine.errorString()));
break; break;
} }
@ -130,6 +131,7 @@ protected:
private: private:
QString m_socketPath; QString m_socketPath;
QString m_portName;
QAtomicInt m_stop{0}; QAtomicInt m_stop{0};
}; };
@ -143,15 +145,19 @@ Plumber::Plumber(QObject *parent)
Plumber::~Plumber() Plumber::~Plumber()
{ {
if (!m_reader) auto stopReader = [](PlumbReader *&reader) {
return; if (!reader)
m_reader->stop(); return;
if (m_reader->wait(3000)) { reader->stop();
delete m_reader; if (reader->wait(3000)) {
} else { delete reader;
connect(m_reader, &QThread::finished, m_reader, &QObject::deleteLater); } else {
} QObject::connect(reader, &QThread::finished, reader, &QObject::deleteLater);
m_reader = nullptr; }
reader = nullptr;
};
stopReader(m_reader);
stopReader(m_ollieReader);
} }
bool Plumber::available() const bool Plumber::available() const
@ -214,17 +220,24 @@ bool Plumber::sendTo(const QString &data, const QString &dst, const QString &wdi
void Plumber::startReader() void Plumber::startReader()
{ {
if (m_reader)
return;
const QString path = socketPath(); const QString path = socketPath();
if (path.isEmpty()) { if (path.isEmpty()) {
Q_EMIT readerError(QStringLiteral("no plumb namespace ($NAMESPACE/$DISPLAY unset)")); Q_EMIT readerError(QStringLiteral("no plumb namespace ($NAMESPACE/$DISPLAY unset)"));
return; return;
} }
m_reader = new PlumbReader(path);
connect(m_reader, &PlumbReader::message, this, &Plumber::edit, Qt::QueuedConnection); // Start ollie port reader (for ollie:// URLs)
connect(m_reader, &PlumbReader::failed, this, &Plumber::readerError, Qt::QueuedConnection); if (!m_ollieReader) {
m_reader->start(); m_ollieReader = new PlumbReader(path, QStringLiteral("ollie"));
connect(m_ollieReader, &PlumbReader::message, this, [this](const QString &data, const QString &, const QString &) {
Q_EMIT ollieMessage(data);
}, Qt::QueuedConnection);
connect(m_ollieReader, &PlumbReader::failed, this, [](const QString &msg) {
// Silently ignore ollie port errors — port may not exist in plumb rules
qDebug("plumber: ollie port: %s", qPrintable(msg));
}, Qt::QueuedConnection);
m_ollieReader->start();
}
} }
#include "plumber.moc" #include "plumber.moc"

View File

@ -8,12 +8,17 @@
* plumb message; the plumber's rules resolve it (file:line -> edit port, * plumb message; the plumber's rules resolve it (file:line -> edit port,
* url -> web port, etc.). The source is "ollie" and the destination is * url -> web port, etc.). The source is "ollie" and the destination is
* left empty so the rules decide. * left empty so the rules decide.
* - RECEIVE: a dedicated reader thread opens the "edit" port and blocks on * - RECEIVE: dedicated reader threads open ports and block on reads.
* reads. Each plumb message that arrives (file to open, with an optional * The "ollie" port receives ollie-specific messages (ollie:// URLs).
* addr) is emitted via edit() on the GUI thread via a queued signal. * Messages are emitted via signals on the GUI thread.
* *
* The namespace socket is $NAMESPACE/plumb, or /tmp/ns.$USER.$DISPLAY/plumb * The namespace socket is $NAMESPACE/plumb, or /tmp/ns.$USER.$DISPLAY/plumb
* when $NAMESPACE is unset — the same resolution plan9's getns() performs. * when $NAMESPACE is unset — the same resolution plan9's getns() performs.
*
* URL scheme for ollie plumb messages:
* ollie://session/agent#block - navigate to block in chat
* ollie://session/agent - switch to agent
* ollie://session - switch to session
*/ */
#ifndef PLUMBER_H #ifndef PLUMBER_H
#define PLUMBER_H #define PLUMBER_H
@ -72,6 +77,14 @@ Q_SIGNALS:
*/ */
void edit(const QString &file, const QString &addr, const QString &wdir); void edit(const QString &file, const QString &addr, const QString &wdir);
/*!
* A plumb message arrived on the ollie port. The \a url is an ollie:// URL:
* ollie://session/agent#block - navigate to chat block
* ollie://session/agent - switch to agent
* ollie://session - switch to session
*/
void ollieMessage(const QString &url);
/*! The reader stopped with an error (e.g. plumber not running). */ /*! The reader stopped with an error (e.g. plumber not running). */
void readerError(const QString &message); void readerError(const QString &message);
@ -80,6 +93,7 @@ Q_SIGNALS:
private: private:
PlumbReader *m_reader = nullptr; PlumbReader *m_reader = nullptr;
PlumbReader *m_ollieReader = nullptr;
QString m_error; QString m_error;
}; };