Add v0.3 read-only Qt6/QML GUI (9P client)

A standalone Qt6/QML app in ui/ that speaks 9P to ork-server; no FFI,
no privileged path. All org logic stays in the server.

- p9client: minimal synchronous 9P2000 client over QTcp/QLocalSocket
- orkclient: QML-facing facade (read/list/listDirs/write/rdwr/docs)
- documentmodel: lazy QAbstractItemModel of the section tree, with a
  BodyRole that reads /<doc>/<id>/body (planning line + :PROPERTIES:
  drawer stripped, since those are shown as structural objects)
- Main.qml: document sidebar + outline; OutlineView.qml: folding
  TreeView rendering keyword/priority/title/tags/timestamps as distinct
  objects, with section body shown inline beneath each headline
- main.cpp: --addr/$ORK_ADDR, --selftest (headless outline dump),
  --shot (headless window grab for debugging)
- CMake build; binary emitted to bin/ to avoid the QML-module-URI vs
  target-name directory clash

Known issue: inline body layout still needs work (row sizing/overlap
when bodies wrap); to be iterated.
This commit is contained in:
Levi Neely 2026-10-01 02:02:53 +02:00
parent 33173bcc29
commit 71a89943ae
13 changed files with 1688 additions and 13 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
# Build artifacts
/target/
**/*.rs.bk
ui/build/
# IDE
.idea/

View File

@ -6,7 +6,7 @@ Instructions for AI agents working on orkmode.
Orkmode is a native org-mode implementation: Rust parser/core + Qt/QML UI.
Current state: **v0.1** — parser and CLI complete, no UI yet.
Current state: **v0.3** — parser, CLI, 9P server, and a read-only Qt6/QML GUI.
## Codebase Structure
@ -16,6 +16,9 @@ crates/
├── org-parser/ # Tree-sitter parser, AST conversion
├── ork-server/ # 9P server exposing org files (bin: ork-server)
└── ork-cli/ # CLI tool, a 9P client (bin name: ork)
ui/ # Qt6/QML GUI, a 9P client (bin: orkmode)
├── src/ # p9client, orkclient, documentmodel, main
└── qml/ # Main.qml, OutlineView.qml
```
## Working with the Code
@ -100,16 +103,21 @@ Use `-j/--json` for machine-readable output. Use `-n/--dry-run` for mutations.
3. **Timestamp text** — displays "set" placeholder, not actual date
4. **No incremental parsing** — full reparse on every call
## Next Steps (v0.3)
## Next Steps (v0.4)
Priority: Qt/QML read-only document viewer, over 9P.
The read-only GUI is in place (Qt6/QML, 9P client in `ui/`). Next: in-place
editing per the VISION "document is the control surface" model.
Tasks:
1. Set up Qt6/QML project in `ui/`
2. Connect to `ork-server` over 9P (no FFI)
3. Implement document model (QAbstractItemModel) from the 9P namespace
4. Basic headline rendering with folding
5. Structural styling (keywords, priorities, tags, timestamps)
1. Section body view: read `/<doc>/<id>/body`, render structured
2. Per-section drop-to-raw-text edit; reparse-on-commit (one 9P write per edit)
3. Direct-manipulation structural edits mapped to `/ctl` verbs
(move/promote/demote/refile) — drag/click, no menu trees
4. TODO cycling, tag, and priority edits via the existing field files
5. Agenda view backed by `/agenda/{today,week,todos}`
GUI verification: `orkmode --selftest -a <addr>` dumps the outline via the
model without a display (used to test the 9P client + model in CI).
## Testing Changes

View File

@ -36,7 +36,16 @@ orkmode/
│ ├── namespace.rs # 9P namespace (docs, sections, agenda, query)
│ ├── p9.rs # 9P protocol handling
│ └── virtfs.rs # Virtual filesystem tree
└── ui/ # (future) Qt/QML UI
└── ui/ # Qt6/QML GUI (9P client, no FFI)
├── CMakeLists.txt # Qt6 build (Core/Gui/Qml/Quick/Network)
├── src/
│ ├── main.cpp # Entry point; --addr, --selftest
│ ├── p9client.{h,cpp} # Minimal synchronous 9P2000 client
│ ├── orkclient.{h,cpp} # QML-facing façade over the 9P client
│ └── documentmodel.{h,cpp} # Lazy QAbstractItemModel section tree
└── qml/
├── Main.qml # Window, document sidebar, outline
└── OutlineView.qml # Folding TreeView with structural styling
```
## Features
@ -77,6 +86,7 @@ Parses common #+KEYWORD directives:
- Rust 1.70+
- C compiler (for tree-sitter grammar)
- Qt 6.3+ and CMake 3.21+ (for the GUI)
### Build
@ -91,6 +101,19 @@ cargo test
cargo build --release
```
#### GUI
The GUI is a separate Qt6/CMake project that talks to `ork-server` over 9P.
```bash
cmake -S ui -B ui/build -DCMAKE_BUILD_TYPE=Release
cmake --build ui/build -j
# Run against a running ork-server (see below)
./ui/build/bin/orkmode -a unix:///tmp/ork.sock
```
## Usage
### CLI (ork)
@ -248,10 +271,11 @@ All nodes include source span information for editor integration.
- [ ] Complete inline object parsing
### Phase 2: Qt UI
- [ ] Document rendering (read-only)
- [ ] Syntax highlighting
- [ ] Folding/cycling
- [ ] Navigation
- [x] Document rendering (read-only)
- [x] Structural styling (keywords, priorities, tags, timestamps)
- [x] Folding/cycling
- [x] Navigation
- [ ] Syntax highlighting for section bodies
### Phase 3: Editing
- [ ] Inline editing

45
ui/CMakeLists.txt Normal file
View File

@ -0,0 +1,45 @@
cmake_minimum_required(VERSION 3.21)
project(orkmode-ui VERSION 0.3 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 6.3 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Network)
qt_standard_project_setup(REQUIRES 6.5)
qt_add_executable(orkmode
src/main.cpp
src/p9client.cpp
src/p9client.h
src/orkclient.cpp
src/orkclient.h
src/documentmodel.cpp
src/documentmodel.h
)
qt_add_qml_module(orkmode
URI orkmode
VERSION 1.0
QML_FILES
qml/Main.qml
qml/OutlineView.qml
)
target_link_libraries(orkmode PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Quick
Qt6::QuickControls2
Qt6::Network
)
# The QML module (URI "orkmode") copies its sources into a build-dir folder
# named "orkmode", which would collide with an executable also named
# "orkmode". Emit the binary into bin/ to keep the two apart.
set_target_properties(orkmode PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)

154
ui/qml/Main.qml Normal file
View File

@ -0,0 +1,154 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: root
width: 1000
height: 700
visible: true
title: qsTr("orkmode")
// orkmode palette — muted, org-like.
readonly property color bgColor: "#1e2127"
readonly property color panelColor: "#252932"
readonly property color textColor: "#d8dee9"
readonly property color dimColor: "#7a8290"
readonly property color accentColor: "#5e81ac"
color: bgColor
// Currently selected document name (namespace directory).
property string currentDoc: ""
function refreshDocs() {
docList.model = orkClient.docs()
if (docList.model.length > 0 && root.currentDoc === "")
root.selectDoc(docList.model[0].name)
}
function selectDoc(name) {
root.currentDoc = name
docModel.document = name
}
Component.onCompleted: refreshDocs()
Connections {
target: orkClient
function onConnectedChanged() { root.refreshDocs() }
}
header: ToolBar {
background: Rectangle { color: root.panelColor }
RowLayout {
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
Label {
text: "orkmode"
color: root.textColor
font.pixelSize: 16
font.bold: true
}
Item { Layout.fillWidth: true }
Label {
text: orkClient.connected
? "● " + orkClient.address
: "○ disconnected"
color: orkClient.connected ? "#a3be8c" : "#bf616a"
font.pixelSize: 12
}
ToolButton {
text: "⟳"
onClicked: { docModel.reload(); root.refreshDocs() }
ToolTip.text: qsTr("Reload")
ToolTip.visible: hovered
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
// Document sidebar.
Rectangle {
Layout.preferredWidth: 220
Layout.fillHeight: true
color: root.panelColor
ColumnLayout {
anchors.fill: parent
spacing: 0
Label {
text: qsTr("Documents")
color: root.dimColor
font.pixelSize: 11
font.bold: true
Layout.margins: 10
}
ListView {
id: docList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: []
delegate: ItemDelegate {
required property var modelData
width: docList.width
highlighted: root.currentDoc === modelData.name
onClicked: root.selectDoc(modelData.name)
background: Rectangle {
color: highlighted ? root.accentColor : "transparent"
opacity: highlighted ? 0.3 : 1.0
}
contentItem: ColumnLayout {
spacing: 1
Label {
text: modelData.title
color: root.textColor
font.pixelSize: 13
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: modelData.name
color: root.dimColor
font.pixelSize: 10
elide: Text.ElideRight
Layout.fillWidth: true
}
}
}
}
}
}
Rectangle { Layout.preferredWidth: 1; Layout.fillHeight: true; color: "#11141a" }
// Outline view.
Item {
Layout.fillWidth: true
Layout.fillHeight: true
OutlineView {
anchors.fill: parent
anchors.margins: 8
model: docModel
}
Label {
anchors.centerIn: parent
visible: root.currentDoc === ""
text: orkClient.connected
? qsTr("Select a document")
: qsTr("Not connected: %1").arg(orkClient.error)
color: root.dimColor
font.pixelSize: 14
}
}
}
}

190
ui/qml/OutlineView.qml Normal file
View File

@ -0,0 +1,190 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// A folding outline of a document's sections. Each row renders the section
// headline as distinct structural objects (keyword, priority, title, tags,
// timestamps) followed by the section body, always visible inline — the
// document is the control surface.
TreeView {
id: tree
clip: true
readonly property color textColor: "#d8dee9"
readonly property color dimColor: "#7a8290"
readonly property int rowPad: 6
// Palette for TODO keywords by state.
function keywordColor(kw) {
switch (kw) {
case "TODO": return "#bf616a"
case "NEXT": return "#d08770"
case "WAITING": return "#ebcb8b"
case "DONE": return "#a3be8c"
case "CANCELLED": return "#7a8290"
default: return "#88c0d0"
}
}
// Headline text scales down slightly with depth.
function levelFontSize(level) {
return Math.max(13, 19 - level)
}
selectionModel: ItemSelectionModel {}
delegate: TreeViewDelegate {
id: del
indentation: 18
readonly property string sectionBody: (model.body ?? "").trim()
readonly property int bodyIndent: 12
// The horizontal space available to the body text. The base delegate
// reserves (depth+1)*indentation on the left for the tree structure;
// the content region occupies the rest, minus our body indent and a
// right margin. Computing this explicitly (rather than via anchors)
// gives the body Text a concrete width, so contentHeight resolves
// deterministically and the row height below is correct.
readonly property real bodyWidth:
Math.max(1, tree.width - (depth + 1) * indentation
- bodyIndent - 12)
implicitWidth: tree.width
// Row height = headline + body (if any) + padding. bodyMeasure is a
// non-visible Text with the same font/width as the body, used purely
// to measure wrapped height without depending on the laid-out item.
implicitHeight: headlineRow.implicitHeight
+ (sectionBody !== "" ? bodyMeasure.implicitHeight + 4 : 0)
+ tree.rowPad * 2
background: Rectangle {
color: del.current ? "#2f3540" : "transparent"
radius: 4
}
// Off-screen measurement of the wrapped body height.
Text {
id: bodyMeasure
visible: false
width: del.bodyWidth
text: del.sectionBody
font.pixelSize: 12
lineHeight: 1.15
wrapMode: Text.WordWrap
textFormat: Text.PlainText
}
contentItem: Item {
RowLayout {
id: headlineRow
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 8
// TODO keyword pill.
Rectangle {
visible: (model.keyword ?? "") !== ""
radius: 3
color: "transparent"
border.color: tree.keywordColor(model.keyword ?? "")
border.width: 1
implicitWidth: kwLabel.implicitWidth + 10
implicitHeight: kwLabel.implicitHeight + 4
Label {
id: kwLabel
anchors.centerIn: parent
text: model.keyword ?? ""
color: tree.keywordColor(model.keyword ?? "")
font.pixelSize: 11
font.bold: true
}
}
// Priority cookie.
Label {
visible: (model.priority ?? "") !== ""
text: "[#" + (model.priority ?? "") + "]"
color: "#b48ead"
font.pixelSize: 12
font.bold: true
}
// Title.
Label {
text: (model.title ?? "")
color: (model.isDone ?? false) ? tree.dimColor : tree.textColor
font.pixelSize: tree.levelFontSize(model.level ?? 1)
font.strikeout: (model.isDone ?? false)
elide: Text.ElideRight
Layout.fillWidth: true
}
// Scheduled / deadline stamps.
Label {
visible: (model.scheduled ?? "") !== ""
text: "⏱ " + (model.scheduled ?? "")
color: "#81a1c1"
font.pixelSize: 11
}
Label {
visible: (model.deadline ?? "") !== ""
text: "⚑ " + (model.deadline ?? "")
color: "#bf616a"
font.pixelSize: 11
}
// Tags.
Repeater {
model: del.model.tags ?? []
delegate: Rectangle {
required property string modelData
radius: 3
color: "#3b4252"
implicitWidth: tagLabel.implicitWidth + 8
implicitHeight: tagLabel.implicitHeight + 3
Label {
id: tagLabel
anchors.centerIn: parent
text: parent.modelData
color: "#8fbcbb"
font.pixelSize: 10
}
}
}
}
// Left rule marking the body region.
Rectangle {
visible: bodyLabel.visible
width: 2
color: "#3b4252"
anchors.top: bodyLabel.top
anchors.bottom: bodyLabel.bottom
anchors.left: parent.left
anchors.leftMargin: 2
}
// Section body, always visible inline beneath the headline.
// Given an explicit width (matching bodyMeasure) so wrapping and
// height are consistent with the reserved row height.
Text {
id: bodyLabel
anchors.top: headlineRow.bottom
anchors.topMargin: 4
anchors.left: parent.left
anchors.leftMargin: del.bodyIndent
width: del.bodyWidth
visible: del.sectionBody !== ""
text: del.sectionBody
color: tree.dimColor
font.pixelSize: 12
lineHeight: 1.15
wrapMode: Text.WordWrap
textFormat: Text.PlainText
}
}
}
}

257
ui/src/documentmodel.cpp Normal file
View File

@ -0,0 +1,257 @@
#include "documentmodel.h"
#include "orkclient.h"
namespace {
// A trailing newline is emitted by every scalar field file; strip it.
QString trimField(const QString &s) {
QString t = s;
while (t.endsWith('\n') || t.endsWith('\r')) t.chop(1);
return t;
}
} // namespace
DocumentModel::DocumentModel(QObject *parent) : QAbstractItemModel(parent) {
// Node 0 is the invisible root.
Node root;
m_nodes.append(root);
}
void DocumentModel::setClient(OrkClient *client) {
m_client = client;
reload();
}
void DocumentModel::setDocument(const QString &doc) {
if (doc == m_document) return;
m_document = doc;
emit documentChanged();
reload();
}
void DocumentModel::reload() {
beginResetModel();
m_nodes.clear();
Node root;
m_nodes.append(root);
if (m_client && !m_document.isEmpty()) {
// Top-level section directories under /<doc>.
const QStringList ids = m_client->listDirs("/" + m_document);
for (const QString &id : ids) {
const QString path = "/" + m_document + "/" + id;
const int idx = makeNode(id, path, 0);
m_nodes[0].children.append(idx);
}
m_nodes[0].childrenLoaded = true;
}
endResetModel();
}
int DocumentModel::makeNode(const QString &id, const QString &path, int parent) {
Node n;
n.id = id;
n.path = path;
n.parent = parent;
m_nodes.append(n);
return m_nodes.size() - 1;
}
void DocumentModel::loadFields(int idx) const {
Node &n = m_nodes[idx];
if (n.fieldsLoaded || !m_client || n.path.isEmpty()) return;
n.title = trimField(m_client->read(n.path + "/title"));
n.keyword = trimField(m_client->read(n.path + "/keyword"));
n.priority = trimField(m_client->read(n.path + "/priority"));
const QString tags = trimField(m_client->read(n.path + "/tags"));
n.tags = tags.isEmpty() ? QStringList()
: tags.split(':', Qt::SkipEmptyParts);
n.level = trimField(m_client->read(n.path + "/level")).toInt();
n.scheduled = trimField(m_client->read(n.path + "/scheduled"));
n.deadline = trimField(m_client->read(n.path + "/deadline"));
n.fieldsLoaded = true;
}
void DocumentModel::loadBody(int idx) const {
Node &n = m_nodes[idx];
if (n.bodyLoaded || !m_client || n.path.isEmpty()) return;
// The server's /body is the section content beneath the headline,
// excluding child subtrees. It still contains the planning line
// (SCHEDULED/DEADLINE/CLOSED) and the :PROPERTIES: drawer, which the GUI
// already surfaces as structural objects. Strip those leading lines so the
// body view shows just the prose/content.
const QString raw = m_client->read(n.path + "/body");
QStringList lines = raw.split('\n');
int start = 0;
// Skip a leading planning line (SCHEDULED:/DEADLINE:/CLOSED: keywords).
while (start < lines.size()) {
const QString t = lines[start].trimmed();
if (t.startsWith("SCHEDULED:") || t.startsWith("DEADLINE:") ||
t.startsWith("CLOSED:")) {
++start;
} else {
break;
}
}
// Skip a :PROPERTIES: ... :END: drawer if present next.
if (start < lines.size() &&
lines[start].trimmed().compare(":PROPERTIES:", Qt::CaseInsensitive) == 0) {
int end = start + 1;
while (end < lines.size() &&
lines[end].trimmed().compare(":END:", Qt::CaseInsensitive) != 0) {
++end;
}
if (end < lines.size()) start = end + 1; // consume through :END:
}
QString body = lines.mid(start).join('\n');
// Trim leading and trailing blank lines for display.
while (body.startsWith('\n') || body.startsWith('\r')) body.remove(0, 1);
while (body.endsWith('\n') || body.endsWith('\r')) body.chop(1);
n.body = body;
n.bodyLoaded = true;
}
int DocumentModel::indexOf(const QModelIndex &index) const {
if (!index.isValid()) return 0; // root
return static_cast<int>(index.internalId());
}
const DocumentModel::Node *DocumentModel::nodeFor(const QModelIndex &index) const {
const int i = indexOf(index);
if (i < 0 || i >= m_nodes.size()) return nullptr;
return &m_nodes[i];
}
QModelIndex DocumentModel::index(int row, int column, const QModelIndex &parent) const {
if (column != 0) return {};
const int pidx = indexOf(parent);
if (pidx < 0 || pidx >= m_nodes.size()) return {};
const Node &p = m_nodes[pidx];
if (row < 0 || row >= p.children.size()) return {};
return createIndex(row, column, quintptr(p.children[row]));
}
QModelIndex DocumentModel::parent(const QModelIndex &child) const {
const int cidx = indexOf(child);
if (cidx <= 0 || cidx >= m_nodes.size()) return {}; // root or invalid
const Node &c = m_nodes[cidx];
const int pidx = c.parent;
if (pidx <= 0) return {}; // parent is the invisible root
const Node &p = m_nodes[pidx];
const int gp = p.parent;
if (gp < 0 || gp >= m_nodes.size()) return {};
const int rowInGp = m_nodes[gp].children.indexOf(pidx);
if (rowInGp < 0) return {};
return createIndex(rowInGp, 0, quintptr(pidx));
}
int DocumentModel::rowCount(const QModelIndex &parent) const {
const int pidx = indexOf(parent);
if (pidx < 0 || pidx >= m_nodes.size()) return 0;
return m_nodes[pidx].children.size();
}
int DocumentModel::columnCount(const QModelIndex &) const { return 1; }
bool DocumentModel::hasChildren(const QModelIndex &parent) const {
const int pidx = indexOf(parent);
if (pidx < 0 || pidx >= m_nodes.size()) return false;
const Node &n = m_nodes[pidx];
if (n.childrenLoaded) return !n.children.isEmpty();
// Unknown until fetched — assume it may have children so the view shows a
// disclosure control and triggers canFetchMore/fetchMore.
return true;
}
bool DocumentModel::canFetchMore(const QModelIndex &parent) const {
const int pidx = indexOf(parent);
if (pidx < 0 || pidx >= m_nodes.size()) return false;
return !m_nodes[pidx].childrenLoaded;
}
void DocumentModel::fetchMore(const QModelIndex &parent) {
const int pidx = indexOf(parent);
if (pidx < 0 || pidx >= m_nodes.size()) return;
if (m_nodes[pidx].childrenLoaded) return;
// Determine how many children we're about to add.
const QStringList ids =
(m_client && !m_nodes[pidx].path.isEmpty())
? m_client->listDirs(m_nodes[pidx].path)
: QStringList();
if (ids.isEmpty()) {
m_nodes[pidx].childrenLoaded = true;
return;
}
beginInsertRows(parent, 0, ids.size() - 1);
for (const QString &cid : ids) {
const QString cpath = m_nodes[pidx].path + "/" + cid;
const int cidx = makeNode(cid, cpath, pidx);
m_nodes[pidx].children.append(cidx);
}
m_nodes[pidx].childrenLoaded = true;
endInsertRows();
}
QVariant DocumentModel::data(const QModelIndex &index, int role) const {
const Node *n = nodeFor(index);
if (!n || n->path.isEmpty()) return {};
const int idx = indexOf(index);
loadFields(idx);
const Node &c = m_nodes[idx];
switch (role) {
case TitleRole:
case Qt::DisplayRole:
return c.title;
case KeywordRole:
return c.keyword;
case PriorityRole:
return c.priority;
case TagsRole:
return c.tags;
case LevelRole:
return c.level;
case ScheduledRole:
return c.scheduled;
case DeadlineRole:
return c.deadline;
case SectionIdRole:
return c.id;
case PathRole:
return c.path;
case IsTodoRole:
return !c.keyword.isEmpty();
case IsDoneRole:
return c.keyword == QLatin1String("DONE") ||
c.keyword == QLatin1String("CANCELLED");
case BodyRole:
loadBody(idx);
return m_nodes[idx].body;
default:
return {};
}
}
QHash<int, QByteArray> DocumentModel::roleNames() const {
return {
{TitleRole, "title"},
{KeywordRole, "keyword"},
{PriorityRole, "priority"},
{TagsRole, "tags"},
{LevelRole, "level"},
{ScheduledRole, "scheduled"},
{DeadlineRole, "deadline"},
{SectionIdRole, "sectionId"},
{PathRole, "path"},
{IsTodoRole, "isTodo"},
{IsDoneRole, "isDone"},
{BodyRole, "body"},
};
}

107
ui/src/documentmodel.h Normal file
View File

@ -0,0 +1,107 @@
// DocumentModel — a lazy tree model over one document's section hierarchy.
//
// The model maps the ork-server namespace directly:
// /<doc> -> the document (setDocument selects it)
// /<doc>/<id> -> a top-level section (a row under the invisible root)
// /<doc>/<id>/<cid> -> a child section (a nested row)
//
// Each section's display fields (title, keyword, priority, tags, level,
// scheduled, deadline) are read once from the corresponding leaf files when
// the node is first materialised. Children are fetched lazily on expansion so
// large outlines do not force a full walk up front.
//
// This is a read-only view for v0.3; field roles are exposed so a later
// editing milestone can add setData without reshaping the model.
#pragma once
#include <QAbstractItemModel>
#include <QHash>
#include <QString>
#include <QVector>
class OrkClient;
class DocumentModel : public QAbstractItemModel {
Q_OBJECT
Q_PROPERTY(QString document READ document WRITE setDocument NOTIFY documentChanged)
public:
enum Roles {
TitleRole = Qt::UserRole + 1,
KeywordRole,
PriorityRole,
TagsRole, // QStringList
LevelRole,
ScheduledRole,
DeadlineRole,
SectionIdRole,
PathRole, // full namespace path of the section directory
IsTodoRole, // keyword is non-empty
IsDoneRole, // keyword is a "done" state (DONE/CANCELLED)
BodyRole, // section content beneath the headline (lazy)
};
Q_ENUM(Roles)
explicit DocumentModel(QObject *parent = nullptr);
void setClient(OrkClient *client);
QString document() const { return m_document; }
void setDocument(const QString &doc);
// QAbstractItemModel.
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) const override;
QHash<int, QByteArray> roleNames() const override;
bool hasChildren(const QModelIndex &parent) const override;
bool canFetchMore(const QModelIndex &parent) const override;
void fetchMore(const QModelIndex &parent) override;
// Reload from the server (re-reads doc sections).
Q_INVOKABLE void reload();
signals:
void documentChanged();
private:
struct Node {
QString id; // section UUID/custom-id (empty for root)
QString path; // "/doc/id" (empty for root)
int parent = -1; // index into m_nodes, -1 for root
QVector<int> children; // indices into m_nodes
bool childrenLoaded = false;
// Cached display fields.
QString title;
QString keyword;
QString priority;
QStringList tags;
int level = 0;
QString scheduled;
QString deadline;
bool fieldsLoaded = false;
// Section body is read separately, only when first requested, since it
// can be large and is not needed to render the headline row.
QString body;
bool bodyLoaded = false;
};
// Create a section node, register it, and return its index. Does not load
// fields or children.
int makeNode(const QString &id, const QString &path, int parent);
void loadFields(int idx) const; // populates cached headline fields
void loadBody(int idx) const; // populates cached body text
const Node *nodeFor(const QModelIndex &index) const;
int indexOf(const QModelIndex &index) const; // node index, -1 == root
OrkClient *m_client = nullptr;
QString m_document;
// Node 0 is the invisible root. Sections start at index 1.
// mutable because field loading is lazy inside const data().
mutable QVector<Node> m_nodes;
};

153
ui/src/main.cpp Normal file
View File

@ -0,0 +1,153 @@
// orkmode GUI entry point.
//
// A read-only org-mode document viewer that speaks 9P to ork-server. The
// server address is taken from --addr, then $ORK_ADDR, defaulting to
// tcp://localhost:5640 (ork-server's own default). No org logic lives here;
// the GUI walks the server's namespace and renders it.
#include "documentmodel.h"
#include "orkclient.h"
#include <QGuiApplication>
#include <QImage>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickWindow>
#include <QTimer>
#include <QtQml>
#include <cstdio>
// Walk the model recursively and print each section, for --selftest.
static void dumpModel(const DocumentModel &m, const QModelIndex &parent, int depth) {
const int rows = m.rowCount(parent);
for (int r = 0; r < rows; ++r) {
const QModelIndex idx = m.index(r, 0, parent);
const QString kw = m.data(idx, DocumentModel::KeywordRole).toString();
const QString pri = m.data(idx, DocumentModel::PriorityRole).toString();
const QString title = m.data(idx, DocumentModel::TitleRole).toString();
const QStringList tags = m.data(idx, DocumentModel::TagsRole).toStringList();
const QString sched = m.data(idx, DocumentModel::ScheduledRole).toString();
const QString dead = m.data(idx, DocumentModel::DeadlineRole).toString();
QString line = QString(depth * 2, ' ') + "* ";
if (!kw.isEmpty()) line += kw + " ";
if (!pri.isEmpty()) line += "[#" + pri + "] ";
line += title;
if (!tags.isEmpty()) line += " :" + tags.join(':') + ":";
if (!sched.isEmpty()) line += " SCHED=" + sched;
if (!dead.isEmpty()) line += " DEAD=" + dead;
std::fprintf(stdout, "%s\n", qUtf8Printable(line));
const QString body = m.data(idx, DocumentModel::BodyRole).toString();
if (!body.trimmed().isEmpty()) {
const QString indent = QString(depth * 2 + 4, ' ');
for (const QString &bl : body.split('\n')) {
std::fprintf(stdout, "%s| %s\n", qUtf8Printable(indent),
qUtf8Printable(bl));
}
}
// Force lazy child load then recurse.
if (m.canFetchMore(idx)) const_cast<DocumentModel &>(m).fetchMore(idx);
dumpModel(m, idx, depth + 1);
}
}
int main(int argc, char *argv[]) {
QGuiApplication app(argc, argv);
app.setApplicationName("orkmode");
app.setOrganizationName("orkmode");
// Resolve the server address: --addr, else $ORK_ADDR, else default.
QString addr = QStringLiteral("tcp://localhost:5640");
if (const QByteArray env = qgetenv("ORK_ADDR"); !env.isEmpty()) {
addr = QString::fromUtf8(env);
}
const QStringList args = app.arguments();
bool selftest = false;
QString shotPath;
for (int i = 1; i < args.size(); ++i) {
if ((args[i] == "-a" || args[i] == "--addr") && i + 1 < args.size()) {
addr = args[i + 1];
++i;
} else if (args[i] == "--selftest") {
selftest = true;
} else if (args[i] == "--shot" && i + 1 < args.size()) {
shotPath = args[i + 1];
++i;
}
}
// Expose the client and the document model to QML.
OrkClient client;
DocumentModel docModel;
docModel.setClient(&client);
const bool connected = client.connectTo(addr);
if (!connected) {
qWarning("orkmode: could not connect to %s: %s",
qUtf8Printable(addr), qUtf8Printable(client.error()));
// Continue anyway: the QML shows the connection error and offers retry.
}
// Headless verification: dump the doc list and each document's outline via
// the model, then exit. Used to test the 9P client + model without a GUI.
if (selftest) {
if (!connected) {
std::fprintf(stderr, "selftest: not connected: %s\n",
qUtf8Printable(client.error()));
return 2;
}
const QVariantList docs = client.docs();
std::fprintf(stdout, "documents: %lld\n",
static_cast<long long>(docs.size()));
for (const QVariant &d : docs) {
const QVariantMap m = d.toMap();
const QString name = m.value("name").toString();
std::fprintf(stdout, "== %s (%s) ==\n",
qUtf8Printable(m.value("title").toString()),
qUtf8Printable(name));
docModel.setDocument(name);
dumpModel(docModel, QModelIndex(), 0);
}
return docs.isEmpty() ? 1 : 0;
}
QObject::connect(&client, &OrkClient::connectedChanged, &docModel,
[&docModel]() { docModel.reload(); });
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("orkClient", &client);
engine.rootContext()->setContextProperty("docModel", &docModel);
QObject::connect(
&engine, &QQmlApplicationEngine::objectCreationFailed, &app,
[]() { QCoreApplication::exit(-1); }, Qt::QueuedConnection);
engine.loadFromModule("orkmode", "Main");
if (engine.rootObjects().isEmpty()) return -1;
// Screenshot mode: after the scene has rendered, grab the window to a PNG
// and quit. Used to verify layout without a display.
if (!shotPath.isEmpty()) {
auto *win = qobject_cast<QQuickWindow *>(engine.rootObjects().first());
if (!win) {
std::fprintf(stderr, "shot: root object is not a window\n");
return 3;
}
// Grab after a short delay so async model reads and layout settle.
QTimer::singleShot(800, &app, [&, shotPath]() {
QImage img = win->grabWindow();
if (!img.isNull() && img.save(shotPath)) {
std::fprintf(stdout, "shot saved: %s (%dx%d)\n",
qUtf8Printable(shotPath), img.width(), img.height());
} else {
std::fprintf(stderr, "shot: failed to save %s\n",
qUtf8Printable(shotPath));
}
QCoreApplication::quit();
});
}
return app.exec();
}

79
ui/src/orkclient.cpp Normal file
View File

@ -0,0 +1,79 @@
#include "orkclient.h"
#include <QVariantMap>
OrkClient::OrkClient(QObject *parent) : QObject(parent) {}
void OrkClient::syncError() {
const QString e = m_client.lastError();
if (e != m_error) {
m_error = e;
emit errorChanged();
}
}
bool OrkClient::connectTo(const QString &address) {
m_address = address;
const bool ok = m_client.connectTo(address);
syncError();
emit connectedChanged();
return ok;
}
QString OrkClient::read(const QString &path) {
bool ok = false;
const QByteArray data = m_client.readFile(path, &ok);
syncError();
return QString::fromUtf8(data);
}
QStringList OrkClient::list(const QString &path) {
bool ok = false;
const QVector<P9Stat> entries = m_client.readDir(path, &ok);
syncError();
QStringList names;
names.reserve(entries.size());
for (const P9Stat &e : entries) names.append(e.name);
return names;
}
QStringList OrkClient::listDirs(const QString &path) {
bool ok = false;
const QVector<P9Stat> entries = m_client.readDir(path, &ok);
syncError();
QStringList names;
for (const P9Stat &e : entries) {
if (e.isDir) names.append(e.name);
}
return names;
}
bool OrkClient::write(const QString &path, const QString &text) {
const bool ok = m_client.writeFile(path, text.toUtf8());
syncError();
return ok;
}
QString OrkClient::rdwr(const QString &path, const QString &text) {
bool ok = false;
const QByteArray data = m_client.rdwr(path, text.toUtf8(), &ok);
syncError();
return QString::fromUtf8(data);
}
QVariantList OrkClient::docs() {
QVariantList out;
const QString idx = read("/idx");
for (const QString &line : idx.split('\n', Qt::SkipEmptyParts)) {
// Each line: name\tpath\ttitle
const QStringList cols = line.split('\t');
if (cols.isEmpty()) continue;
QVariantMap m;
m["name"] = cols.value(0);
m["path"] = cols.value(1);
const QString title = cols.value(2);
m["title"] = (title.isEmpty() || title == "-") ? cols.value(0) : title;
out.append(m);
}
return out;
}

64
ui/src/orkclient.h Normal file
View File

@ -0,0 +1,64 @@
// OrkClient — a QML-facing façade over the 9P client.
//
// It exposes just enough of the ork-server namespace for the read-only
// document viewer: connect, read a file, list a directory, and read the
// well-known index/keyword/agenda files. Writes are included so later
// milestones (in-place editing, structural ops via /ctl) can build on the
// same object without a second client.
#pragma once
#include "p9client.h"
#include <QObject>
#include <QString>
#include <QStringList>
class OrkClient : public QObject {
Q_OBJECT
Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
Q_PROPERTY(QString address READ address NOTIFY connectedChanged)
Q_PROPERTY(QString error READ error NOTIFY errorChanged)
public:
explicit OrkClient(QObject *parent = nullptr);
bool connected() const { return m_client.isConnected(); }
QString address() const { return m_address; }
QString error() const { return m_error; }
// Connect (or reconnect) to an ork-server address.
Q_INVOKABLE bool connectTo(const QString &address);
// Read a namespace file as UTF-8 text (e.g. "/idx", "/note/id/title").
Q_INVOKABLE QString read(const QString &path);
// List the entry names of a directory.
Q_INVOKABLE QStringList list(const QString &path);
// List only the sub-directory entries (used to enumerate docs/sections,
// filtering out the leaf field files).
Q_INVOKABLE QStringList listDirs(const QString &path);
// Truncating write of text to a namespace file. Returns success.
Q_INVOKABLE bool write(const QString &path, const QString &text);
// Write-then-read against a control/query file (/ctl, /query, .../new).
Q_INVOKABLE QString rdwr(const QString &path, const QString &text);
// Parse /idx into a list of {name, title, path} maps for the doc sidebar.
Q_INVOKABLE QVariantList docs();
// Non-QML access for the C++ model.
P9Client &raw() { return m_client; }
signals:
void connectedChanged();
void errorChanged();
private:
void syncError();
P9Client m_client;
QString m_address;
QString m_error;
};

505
ui/src/p9client.cpp Normal file
View File

@ -0,0 +1,505 @@
#include "p9client.h"
#include <QDataStream>
#include <QLocalSocket>
#include <QTcpSocket>
#include <QUrl>
namespace {
// 9P message types (we only use the subset ork-server implements).
constexpr quint8 Tversion = 100, Rversion = 101;
constexpr quint8 Tattach = 104, Rattach = 105;
constexpr quint8 Twalk = 110, Rwalk = 111;
constexpr quint8 Topen = 112, Ropen = 113;
constexpr quint8 Tread = 116, Rread = 117;
constexpr quint8 Twrite = 118, Rwrite = 119;
constexpr quint8 Tclunk = 120, Rclunk = 121;
constexpr quint8 Rerror = 107;
constexpr quint16 NOTAG = 0xFFFF;
constexpr int IO_TIMEOUT_MS = 5000;
// Append a 9P string (2-byte LE length prefix + bytes).
void putString(QByteArray &b, const QString &s) {
const QByteArray u = s.toUtf8();
quint16 n = static_cast<quint16>(u.size());
b.append(char(n & 0xFF));
b.append(char((n >> 8) & 0xFF));
b.append(u);
}
void putU16(QByteArray &b, quint16 v) {
b.append(char(v & 0xFF));
b.append(char((v >> 8) & 0xFF));
}
void putU32(QByteArray &b, quint32 v) {
for (int i = 0; i < 4; ++i) b.append(char((v >> (8 * i)) & 0xFF));
}
void putU64(QByteArray &b, quint64 v) {
for (int i = 0; i < 8; ++i) b.append(char((v >> (8 * i)) & 0xFF));
}
quint16 getU16(const QByteArray &b, int &off) {
quint16 v = quint8(b[off]) | (quint8(b[off + 1]) << 8);
off += 2;
return v;
}
quint32 getU32(const QByteArray &b, int &off) {
quint32 v = 0;
for (int i = 0; i < 4; ++i) v |= quint32(quint8(b[off + i])) << (8 * i);
off += 4;
return v;
}
quint64 getU64(const QByteArray &b, int &off) {
quint64 v = 0;
for (int i = 0; i < 8; ++i) v |= quint64(quint8(b[off + i])) << (8 * i);
off += 8;
return v;
}
QString getString(const QByteArray &b, int &off) {
quint16 n = getU16(b, off);
QString s = QString::fromUtf8(b.constData() + off, n);
off += n;
return s;
}
// Split an absolute path into its non-empty elements.
QStringList pathElements(const QString &path) {
QStringList out;
for (const QString &part : path.split('/')) {
if (!part.isEmpty()) out.append(part);
}
return out;
}
} // namespace
P9Client::~P9Client() { close(); }
bool P9Client::isConnected() const { return m_connected; }
void P9Client::close() {
if (m_dev) {
m_dev->close();
m_dev->deleteLater();
m_dev = nullptr;
}
m_connected = false;
m_fidCounter = ROOTFID;
}
bool P9Client::connectTo(const QString &addr) {
close();
if (addr.startsWith("unix://")) {
const QString sockPath = addr.mid(7);
auto *s = new QLocalSocket();
s->connectToServer(sockPath);
if (!s->waitForConnected(IO_TIMEOUT_MS)) {
m_error = QStringLiteral("connect unix://%1: %2").arg(sockPath, s->errorString());
s->deleteLater();
return false;
}
m_dev = s;
} else {
QString hostPort = addr;
if (hostPort.startsWith("tcp://")) hostPort = hostPort.mid(6);
const int colon = hostPort.lastIndexOf(':');
if (colon < 0) {
m_error = QStringLiteral("bad tcp address: %1").arg(addr);
return false;
}
const QString host = hostPort.left(colon);
const quint16 port = hostPort.mid(colon + 1).toUShort();
auto *s = new QTcpSocket();
s->connectToHost(host, port);
if (!s->waitForConnected(IO_TIMEOUT_MS)) {
m_error = QStringLiteral("connect tcp://%1:%2: %3").arg(host).arg(port).arg(s->errorString());
s->deleteLater();
return false;
}
m_dev = s;
}
if (!doVersion() || !doAttach()) {
close();
return false;
}
m_connected = true;
return true;
}
QByteArray P9Client::transact(quint8 ttype, const QByteArray &body, quint8 *rtype) {
if (!m_dev) {
m_error = QStringLiteral("not connected");
return {};
}
// Frame: size[4] type[1] tag[2] body
QByteArray msg;
const quint32 size = 4 + 1 + 2 + static_cast<quint32>(body.size());
putU32(msg, size);
msg.append(char(ttype));
putU16(msg, NOTAG);
msg.append(body);
if (m_dev->write(msg) != msg.size() || !m_dev->waitForBytesWritten(IO_TIMEOUT_MS)) {
m_error = QStringLiteral("write failed: %1").arg(m_dev->errorString());
return {};
}
// Read reply size.
QByteArray hdr;
while (hdr.size() < 4) {
if (m_dev->bytesAvailable() == 0 && !m_dev->waitForReadyRead(IO_TIMEOUT_MS)) {
m_error = QStringLiteral("read timeout (size)");
return {};
}
hdr.append(m_dev->read(4 - hdr.size()));
}
int off = 0;
const quint32 rsize = getU32(hdr, off);
if (rsize < 7) {
m_error = QStringLiteral("short reply");
return {};
}
// Read remaining bytes.
QByteArray rest;
const int need = static_cast<int>(rsize) - 4;
while (rest.size() < need) {
if (m_dev->bytesAvailable() == 0 && !m_dev->waitForReadyRead(IO_TIMEOUT_MS)) {
m_error = QStringLiteral("read timeout (body)");
return {};
}
rest.append(m_dev->read(need - rest.size()));
}
off = 0;
const quint8 mtype = quint8(rest[off]);
off += 1;
getU16(rest, off); // tag (ignored, single outstanding request)
const QByteArray payload = rest.mid(off);
if (rtype) *rtype = mtype;
if (mtype == Rerror) {
int eoff = 0;
m_error = getString(payload, eoff);
return {};
}
return payload;
}
bool P9Client::doVersion() {
QByteArray body;
putU32(body, m_msize);
putString(body, QStringLiteral("9P2000"));
quint8 rtype = 0;
const QByteArray r = transact(Tversion, body, &rtype);
if (rtype != Rversion) {
if (m_error.isEmpty()) m_error = QStringLiteral("version failed");
return false;
}
int off = 0;
const quint32 srvMsize = getU32(r, off);
m_msize = qMin(m_msize, srvMsize);
return true;
}
bool P9Client::doAttach() {
QByteArray body;
putU32(body, ROOTFID); // fid
putU32(body, 0xFFFFFFFF); // afid (NOFID)
putString(body, QStringLiteral("ork")); // uname
putString(body, QString()); // aname
quint8 rtype = 0;
transact(Tattach, body, &rtype);
if (rtype != Rattach) {
if (m_error.isEmpty()) m_error = QStringLiteral("attach failed");
return false;
}
return true;
}
bool P9Client::walk(quint32 newfid, const QString &path, bool *lastIsDir) {
const QStringList elems = pathElements(path);
QByteArray body;
putU32(body, ROOTFID);
putU32(body, newfid);
putU16(body, static_cast<quint16>(elems.size()));
for (const QString &e : elems) putString(body, e);
quint8 rtype = 0;
const QByteArray r = transact(Twalk, body, &rtype);
if (rtype != Rwalk) {
if (m_error.isEmpty()) m_error = QStringLiteral("walk failed: %1").arg(path);
return false;
}
int off = 0;
const quint16 nwqid = getU16(r, off);
if (nwqid != elems.size()) {
m_error = QStringLiteral("path not found: %1").arg(path);
return false;
}
// Each qid: type[1] version[4] path[8]. Inspect the last for dir bit.
bool isDir = true; // root (zero elements) is a directory
for (quint16 i = 0; i < nwqid; ++i) {
const quint8 qtype = quint8(r[off]);
off += 13;
if (i == nwqid - 1) isDir = (qtype & QTDIR) != 0;
}
if (lastIsDir) *lastIsDir = isDir;
return true;
}
bool P9Client::clunk(quint32 fid) {
QByteArray body;
putU32(body, fid);
quint8 rtype = 0;
transact(Tclunk, body, &rtype);
return rtype == Rclunk;
}
QByteArray P9Client::readFile(const QString &path, bool *ok) {
if (ok) *ok = false;
const quint32 fid = nextFid();
if (!walk(fid, path, nullptr)) return {};
// Topen for read.
{
QByteArray body;
putU32(body, fid);
body.append(char(OREAD));
quint8 rtype = 0;
transact(Topen, body, &rtype);
if (rtype != Ropen) {
clunk(fid);
if (m_error.isEmpty()) m_error = QStringLiteral("open failed: %1").arg(path);
return {};
}
}
// Read until short reply.
QByteArray out;
const quint32 chunk = m_msize - 24;
quint64 offset = 0;
while (true) {
QByteArray body;
putU32(body, fid);
putU64(body, offset);
putU32(body, chunk);
quint8 rtype = 0;
const QByteArray r = transact(Tread, body, &rtype);
if (rtype != Rread) {
clunk(fid);
return {};
}
int roff = 0;
const quint32 count = getU32(r, roff);
if (count == 0) break;
out.append(r.mid(roff, count));
offset += count;
if (count < chunk) break;
}
clunk(fid);
if (ok) *ok = true;
return out;
}
QVector<P9Stat> P9Client::readDir(const QString &path, bool *ok) {
if (ok) *ok = false;
QVector<P9Stat> entries;
const quint32 fid = nextFid();
if (!walk(fid, path, nullptr)) return entries;
{
QByteArray body;
putU32(body, fid);
body.append(char(OREAD));
quint8 rtype = 0;
transact(Topen, body, &rtype);
if (rtype != Ropen) {
clunk(fid);
if (m_error.isEmpty()) m_error = QStringLiteral("open dir failed: %1").arg(path);
return entries;
}
}
// Directory reads return concatenated stat records.
QByteArray raw;
const quint32 chunk = m_msize - 24;
quint64 offset = 0;
while (true) {
QByteArray body;
putU32(body, fid);
putU64(body, offset);
putU32(body, chunk);
quint8 rtype = 0;
const QByteArray r = transact(Tread, body, &rtype);
if (rtype != Rread) {
clunk(fid);
return entries;
}
int roff = 0;
const quint32 count = getU32(r, roff);
if (count == 0) break;
raw.append(r.mid(roff, count));
offset += count;
if (count < chunk) break;
}
clunk(fid);
// Parse stat records: size[2] type[2] dev[4] qid[13] mode[4] atime[4]
// mtime[4] length[8] name[s] uid[s] gid[s] muid[s].
int off = 0;
while (off + 2 <= raw.size()) {
const int start = off;
const quint16 statSize = getU16(raw, off);
if (start + 2 + statSize > raw.size()) break;
int inner = off;
inner += 2; // type
inner += 4; // dev
const quint8 qtype = quint8(raw[inner]);
inner += 13; // qid
const quint32 mode = getU32(raw, inner);
inner += 4; // atime
inner += 4; // mtime
const quint64 length = getU64(raw, inner);
const QString name = getString(raw, inner);
P9Stat st;
st.name = name;
// DMDIR is 0x80000000 in mode; qid.type dir bit is 0x80.
st.isDir = (mode & 0x80000000u) != 0 || (qtype & QTDIR) != 0;
st.length = length;
entries.append(st);
off = start + 2 + statSize;
}
if (ok) *ok = true;
return entries;
}
bool P9Client::writeFile(const QString &path, const QByteArray &data) {
const quint32 fid = nextFid();
if (!walk(fid, path, nullptr)) return false;
{
QByteArray body;
putU32(body, fid);
body.append(char(OWRITE | OTRUNC));
quint8 rtype = 0;
transact(Topen, body, &rtype);
if (rtype != Ropen) {
clunk(fid);
if (m_error.isEmpty()) m_error = QStringLiteral("open for write failed: %1").arg(path);
return false;
}
}
quint64 offset = 0;
const quint32 chunk = m_msize - 24;
int pos = 0;
while (pos < data.size()) {
const int n = qMin<int>(chunk, data.size() - pos);
QByteArray body;
putU32(body, fid);
putU64(body, offset);
putU32(body, static_cast<quint32>(n));
body.append(data.constData() + pos, n);
quint8 rtype = 0;
const QByteArray r = transact(Twrite, body, &rtype);
if (rtype != Rwrite) {
clunk(fid);
return false;
}
int roff = 0;
const quint32 wrote = getU32(r, roff);
if (wrote == 0) break;
pos += wrote;
offset += wrote;
}
// Empty writes (e.g. clearing a field) still need one Twrite.
if (data.isEmpty()) {
QByteArray body;
putU32(body, fid);
putU64(body, 0);
putU32(body, 0);
quint8 rtype = 0;
transact(Twrite, body, &rtype);
}
return clunk(fid);
}
QByteArray P9Client::rdwr(const QString &path, const QByteArray &data, bool *ok) {
if (ok) *ok = false;
const quint32 fid = nextFid();
if (!walk(fid, path, nullptr)) return {};
{
QByteArray body;
putU32(body, fid);
body.append(char(ORDWR));
quint8 rtype = 0;
transact(Topen, body, &rtype);
if (rtype != Ropen) {
clunk(fid);
if (m_error.isEmpty()) m_error = QStringLiteral("open rdwr failed: %1").arg(path);
return {};
}
}
// Single write triggers the server-side rdwr handler, which caches the
// response for subsequent reads.
{
QByteArray body;
putU32(body, fid);
putU64(body, 0);
putU32(body, static_cast<quint32>(data.size()));
body.append(data);
quint8 rtype = 0;
const QByteArray r = transact(Twrite, body, &rtype);
if (rtype != Rwrite) {
clunk(fid);
return {};
}
}
// Read the cached response.
QByteArray out;
const quint32 chunk = m_msize - 24;
quint64 offset = 0;
while (true) {
QByteArray body;
putU32(body, fid);
putU64(body, offset);
putU32(body, chunk);
quint8 rtype = 0;
const QByteArray r = transact(Tread, body, &rtype);
if (rtype != Rread) {
clunk(fid);
return {};
}
int roff = 0;
const quint32 count = getU32(r, roff);
if (count == 0) break;
out.append(r.mid(roff, count));
offset += count;
if (count < chunk) break;
}
clunk(fid);
if (ok) *ok = true;
return out;
}

88
ui/src/p9client.h Normal file
View File

@ -0,0 +1,88 @@
// Minimal synchronous 9P2000 client.
//
// Speaks the same dialect ork-server serves: plain 9P2000, little-endian,
// msize negotiated down to 8192. One request/response at a time (tag 0),
// which is sufficient for a single-threaded GUI model that walks the
// namespace on demand.
//
// The client owns a QIODevice (QTcpSocket or QLocalSocket). All operations
// block on the socket with a timeout; this is deliberate — the namespace is
// small and reads are cheap, and a synchronous API keeps the model code
// straightforward.
#pragma once
#include <QByteArray>
#include <QIODevice>
#include <QString>
#include <QStringList>
#include <QVector>
#include <cstdint>
// A single directory entry parsed from a 9P stat record.
struct P9Stat {
QString name;
bool isDir = false;
quint64 length = 0;
};
// 9P2000 client. Not thread-safe: use from one thread only.
class P9Client {
public:
P9Client() = default;
~P9Client();
// Connect to addr of the form "tcp://host:port" or "unix:///path/to.sock",
// then perform version + attach. Returns false and sets lastError() on
// failure.
bool connectTo(const QString &addr);
void close();
bool isConnected() const;
// Read the entire contents of a file at an absolute namespace path
// (e.g. "/idx" or "/notes/abc123/title"). Returns the bytes; on error
// returns an empty QByteArray and sets ok=false when provided.
QByteArray readFile(const QString &path, bool *ok = nullptr);
// List the names of entries in a directory at an absolute path.
QVector<P9Stat> readDir(const QString &path, bool *ok = nullptr);
// Truncating write of data to a file. Opens for write (OTRUNC), writes,
// clunks. Returns false and sets lastError() on failure.
bool writeFile(const QString &path, const QByteArray &data);
// Write data then read the response back (for rdwr control files such as
// /ctl, /query, /<doc>/new). Returns the response bytes.
QByteArray rdwr(const QString &path, const QByteArray &data, bool *ok = nullptr);
QString lastError() const { return m_error; }
private:
// 9P message tags/fids.
static constexpr quint8 OREAD = 0;
static constexpr quint8 OWRITE = 1;
static constexpr quint8 ORDWR = 2;
static constexpr quint8 OTRUNC = 0x10;
static constexpr quint32 QTDIR = 0x80; // qid.type high bit for directories
static constexpr quint32 ROOTFID = 0;
// Send a T-message body (type + tag + body) and read the matching
// R-message. Returns the R-message payload (after type+tag), or empty on
// error. rtype receives the reply message type.
QByteArray transact(quint8 ttype, const QByteArray &body, quint8 *rtype);
// Walk from the root fid to path, cloning into newfid. Returns false if the
// walk did not resolve every element.
bool walk(quint32 newfid, const QString &path, bool *lastIsDir);
bool doVersion();
bool doAttach();
bool clunk(quint32 fid);
quint32 nextFid() { return ++m_fidCounter; }
QIODevice *m_dev = nullptr;
quint32 m_msize = 8192;
quint32 m_fidCounter = ROOTFID; // fids 1.. are transient
QString m_error;
bool m_connected = false;
};