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:
parent
33173bcc29
commit
71a89943ae
|
|
@ -1,6 +1,7 @@
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/target/
|
/target/
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
ui/build/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
24
AGENTS.md
24
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.
|
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
|
## Codebase Structure
|
||||||
|
|
||||||
|
|
@ -16,6 +16,9 @@ crates/
|
||||||
├── org-parser/ # Tree-sitter parser, AST conversion
|
├── org-parser/ # Tree-sitter parser, AST conversion
|
||||||
├── ork-server/ # 9P server exposing org files (bin: ork-server)
|
├── ork-server/ # 9P server exposing org files (bin: ork-server)
|
||||||
└── ork-cli/ # CLI tool, a 9P client (bin name: ork)
|
└── 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
|
## 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
|
3. **Timestamp text** — displays "set" placeholder, not actual date
|
||||||
4. **No incremental parsing** — full reparse on every call
|
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:
|
Tasks:
|
||||||
1. Set up Qt6/QML project in `ui/`
|
1. Section body view: read `/<doc>/<id>/body`, render structured
|
||||||
2. Connect to `ork-server` over 9P (no FFI)
|
2. Per-section drop-to-raw-text edit; reparse-on-commit (one 9P write per edit)
|
||||||
3. Implement document model (QAbstractItemModel) from the 9P namespace
|
3. Direct-manipulation structural edits mapped to `/ctl` verbs
|
||||||
4. Basic headline rendering with folding
|
(move/promote/demote/refile) — drag/click, no menu trees
|
||||||
5. Structural styling (keywords, priorities, tags, timestamps)
|
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
|
## Testing Changes
|
||||||
|
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -36,7 +36,16 @@ orkmode/
|
||||||
│ ├── namespace.rs # 9P namespace (docs, sections, agenda, query)
|
│ ├── namespace.rs # 9P namespace (docs, sections, agenda, query)
|
||||||
│ ├── p9.rs # 9P protocol handling
|
│ ├── p9.rs # 9P protocol handling
|
||||||
│ └── virtfs.rs # Virtual filesystem tree
|
│ └── 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
|
## Features
|
||||||
|
|
@ -77,6 +86,7 @@ Parses common #+KEYWORD directives:
|
||||||
|
|
||||||
- Rust 1.70+
|
- Rust 1.70+
|
||||||
- C compiler (for tree-sitter grammar)
|
- C compiler (for tree-sitter grammar)
|
||||||
|
- Qt 6.3+ and CMake 3.21+ (for the GUI)
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
|
|
@ -91,6 +101,19 @@ cargo test
|
||||||
cargo build --release
|
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
|
## Usage
|
||||||
|
|
||||||
### CLI (ork)
|
### CLI (ork)
|
||||||
|
|
@ -248,10 +271,11 @@ All nodes include source span information for editor integration.
|
||||||
- [ ] Complete inline object parsing
|
- [ ] Complete inline object parsing
|
||||||
|
|
||||||
### Phase 2: Qt UI
|
### Phase 2: Qt UI
|
||||||
- [ ] Document rendering (read-only)
|
- [x] Document rendering (read-only)
|
||||||
- [ ] Syntax highlighting
|
- [x] Structural styling (keywords, priorities, tags, timestamps)
|
||||||
- [ ] Folding/cycling
|
- [x] Folding/cycling
|
||||||
- [ ] Navigation
|
- [x] Navigation
|
||||||
|
- [ ] Syntax highlighting for section bodies
|
||||||
|
|
||||||
### Phase 3: Editing
|
### Phase 3: Editing
|
||||||
- [ ] Inline editing
|
- [ ] Inline editing
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue