Files
php-qml/framework/skeleton/qml/Main.qml
magdev 030502ca38
Some checks failed
CI / Quality (push) Failing after 1m45s
Phase 2 sub-commit 3: full Update Semantics + ReactiveListModel + AppShell
BackendConnection's ConnectionState enum is now Connecting / Online /
Reconnecting / Offline (PLAN.md §5). The probe loop tracks the first
failure since the last Online and transitions to Reconnecting on any
failed probe, then to Offline once the configurable threshold (30 s
default) is exceeded. The Error state is gone; Reconnecting + the
exposed `error` string subsume its UI role.

ReactiveListModel is the headline QML type:

- QAbstractListModel that GETs `baseUrl + source` for an initial JSON
  array and then keeps in sync via an internal MercureClient subscribed
  to `topic`.
- Role names are derived dynamically from the first row's keys plus an
  internal `pending` boolean role used by optimistic mutations.
- Diff application: upsert (insert-or-update), delete, replace; gap
  detection via the envelope `version` field with auto re-fetch.
- `invoke(method, path, body, optimistic)` is the optimistic command
  primitive. Generates an Idempotency-Key, applies the local diff,
  POST/PATCH/DELETEs with that key, and resolves on the matching
  Mercure echo (correlation-key matched in ModelPublisher's envelope).
  Rolls back and emits commandFailed on 4xx/5xx, commandTimedOut after
  10 s without an echo. Phase 4 packaging will surface configuration
  for the timeout.

AppShell.qml is the optional convenience root:

- Reads BackendConnection.connectionState.
- Reconnecting → top banner.
- Offline → modal overlay with the error string and a Retry button
  (calls BackendConnection.restart()).
- Wraps user content via `default property alias content`.

Apps that want full chrome control can skip AppShell entirely; the
skeleton's Main.qml keeps its own status display for demonstration
and is unaffected.

ReactiveObject (single-entity twin of ReactiveListModel) is intentionally
deferred — same envelope handling, smaller surface; will land in Phase 2
follow-up or Phase 3 alongside the todo example. Cursor pagination is
similarly deferred (the Phase 2 done criterion uses small lists).

Smoke tested: /healthz + /api/ping round-trip cleanly, zero Mercure 401s,
clean shutdown. composer quality stays green (16 tests, 45 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:40:12 +02:00

139 lines
4.4 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window
import PhpQml.Bridge
ApplicationWindow {
id: window
width: 760
height: 540
visible: true
title: "php-qml — skeleton"
RestClient {
id: rest
baseUrl: BackendConnection.url
token: BackendConnection.token
}
MercureClient {
id: mercure
hubUrl: BackendConnection.url + "/.well-known/mercure"
topic: "app://ping"
// No token in Phase 1 dev mode — Caddyfile enables `anonymous`
// for Mercure subscribers. Phase 4 swaps in a Mercure-issued JWT
// when the bundled mode tightens auth.
onUpdate: function(data, id) {
log.append("← mercure " + (id ? id.split(":").pop() : "") + " " + data)
}
onError: function(detail) {
log.append("× mercure error: " + detail)
}
}
Connections {
target: BackendConnection
function onConnectionStateChanged() {
if (BackendConnection.connectionState === BackendConnection.Online) {
if (!mercure.active) mercure.start()
} else {
if (mercure.active) mercure.stop()
}
}
}
Connections {
target: SingleInstance
function onLaunchArgsReceived(args) {
window.requestActivate()
log.append("· launch args from peer: " + args.join(" "))
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
RowLayout {
spacing: 10
Layout.fillWidth: true
Rectangle {
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
color: {
switch (BackendConnection.connectionState) {
case BackendConnection.Online: return "#3ab36c"
case BackendConnection.Reconnecting: return "#d89614"
case BackendConnection.Offline: return "#d8503c"
default: return "#888"
}
}
}
Label { text: "Backend: " + window._stateName(BackendConnection.connectionState) }
Item { width: 12 }
Rectangle {
Layout.preferredWidth: 12; Layout.preferredHeight: 12; radius: 6
color: mercure.active ? "#3ab36c" : "#666"
}
Label { text: "Mercure: " + (mercure.active ? "subscribed" : "off") }
Item { Layout.fillWidth: true }
Button {
text: "Ping"
enabled: BackendConnection.connectionState === BackendConnection.Online
onClicked: {
const t0 = Date.now()
rest.get("/api/ping").then(function(r) {
const dt = Date.now() - t0
log.append("→ ping " + dt + "ms " + JSON.stringify(r.body))
}).catch(function(e) {
log.append("× ping " + e.status + " " + JSON.stringify(e.problem || e.raw))
})
}
}
}
Label {
text: "URL: " + BackendConnection.url
+ (BackendConnection.error ? " error: " + BackendConnection.error : "")
color: BackendConnection.error ? "#d8503c" : "#888"
font.pixelSize: 12
elide: Text.ElideRight
Layout.fillWidth: true
}
Frame {
Layout.fillWidth: true
Layout.fillHeight: true
padding: 0
ScrollView {
anchors.fill: parent
TextArea {
id: log
readOnly: true
wrapMode: TextArea.Wrap
font.family: "monospace"
font.pixelSize: 12
placeholderText: "events will appear here…"
}
}
}
}
function _stateName(s) {
switch (s) {
case BackendConnection.Connecting: return "connecting"
case BackendConnection.Online: return "online"
case BackendConnection.Reconnecting: return "reconnecting"
case BackendConnection.Offline: return "offline"
}
return "?"
}
}