diff --git a/.gitignore b/.gitignore index 196eddc..caef56a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build artifacts /target/ **/*.rs.bk +ui/build/ # IDE .idea/ diff --git a/AGENTS.md b/AGENTS.md index 387a809..3c9cc18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `///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 ` dumps the outline via the +model without a display (used to test the 9P client + model in CI). ## Testing Changes diff --git a/README.md b/README.md index 7af5e63..3bcbae4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt new file mode 100644 index 0000000..b7e9ace --- /dev/null +++ b/ui/CMakeLists.txt @@ -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" +) diff --git a/ui/qml/Main.qml b/ui/qml/Main.qml new file mode 100644 index 0000000..46eaa78 --- /dev/null +++ b/ui/qml/Main.qml @@ -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 + } + } + } +} diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml new file mode 100644 index 0000000..0c3397b --- /dev/null +++ b/ui/qml/OutlineView.qml @@ -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 + } + } + } +} diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp new file mode 100644 index 0000000..8add88f --- /dev/null +++ b/ui/src/documentmodel.cpp @@ -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 /. + 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(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 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"}, + }; +} diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h new file mode 100644 index 0000000..c1d306b --- /dev/null +++ b/ui/src/documentmodel.h @@ -0,0 +1,107 @@ +// DocumentModel — a lazy tree model over one document's section hierarchy. +// +// The model maps the ork-server namespace directly: +// / -> the document (setDocument selects it) +// // -> a top-level section (a row under the invisible root) +// /// -> 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 +#include +#include +#include + +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 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 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 m_nodes; +}; diff --git a/ui/src/main.cpp b/ui/src/main.cpp new file mode 100644 index 0000000..0b81476 --- /dev/null +++ b/ui/src/main.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +#include + +// 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(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(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(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(); +} diff --git a/ui/src/orkclient.cpp b/ui/src/orkclient.cpp new file mode 100644 index 0000000..da77124 --- /dev/null +++ b/ui/src/orkclient.cpp @@ -0,0 +1,79 @@ +#include "orkclient.h" + +#include + +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 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 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; +} diff --git a/ui/src/orkclient.h b/ui/src/orkclient.h new file mode 100644 index 0000000..6530c34 --- /dev/null +++ b/ui/src/orkclient.h @@ -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 +#include +#include + +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; +}; diff --git a/ui/src/p9client.cpp b/ui/src/p9client.cpp new file mode 100644 index 0000000..39129d4 --- /dev/null +++ b/ui/src/p9client.cpp @@ -0,0 +1,505 @@ +#include "p9client.h" + +#include +#include +#include +#include + +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(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(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(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(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 P9Client::readDir(const QString &path, bool *ok) { + if (ok) *ok = false; + QVector 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(chunk, data.size() - pos); + QByteArray body; + putU32(body, fid); + putU64(body, offset); + putU32(body, static_cast(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(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; +} diff --git a/ui/src/p9client.h b/ui/src/p9client.h new file mode 100644 index 0000000..d28cfe8 --- /dev/null +++ b/ui/src/p9client.h @@ -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 +#include +#include +#include +#include +#include + +// 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 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, //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; +};