Phase 5 sub-commit 1: DevConsole + child-output capture + Ctrl+` toggle
BackendConnection now captures the bundled FrankenPHP child's merged stdout+stderr into a 500-line ring buffer, mirrors each line through qCInfo(lcBundled) so terminal users still see logs, and exposes childLogTail() / childLogLine for QML. DevConsole.qml is an opt-in monospaced viewer with auto-scroll + clear that the skeleton and the todo example bind to Ctrl+`. Dev mode (when BRIDGE_URL is set, no bundled child) renders an explanatory hint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -200,9 +200,22 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DevConsole {
|
||||||
|
id: devConsole
|
||||||
|
visible: false
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 220
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+` toggles the FrankenPHP child output console (Phase 5 §13).
|
||||||
|
Shortcut {
|
||||||
|
sequences: ["Ctrl+`", "Ctrl+~"]
|
||||||
|
onActivated: devConsole.visible = !devConsole.visible
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: SingleInstance
|
target: SingleInstance
|
||||||
function onLaunchArgsReceived(args) {
|
function onLaunchArgsReceived(args) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ qt_add_qml_module(php_qml_bridge
|
|||||||
QML_FILES
|
QML_FILES
|
||||||
qml/RestClient.qml
|
qml/RestClient.qml
|
||||||
qml/AppShell.qml
|
qml/AppShell.qml
|
||||||
|
qml/DevConsole.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(php_qml_bridge PUBLIC src/)
|
target_include_directories(php_qml_bridge PUBLIC src/)
|
||||||
|
|||||||
138
framework/qml/qml/DevConsole.qml
Normal file
138
framework/qml/qml/DevConsole.qml
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import PhpQml.Bridge
|
||||||
|
|
||||||
|
// Optional in-window log viewer for the bundled FrankenPHP child's
|
||||||
|
// stdout+stderr. Captured passively in BackendConnection (see PLAN.md
|
||||||
|
// §13 Phase 5 sub-commit 1) so opening the console is free — there is
|
||||||
|
// no IPC cost, just rendering a ring buffer the host already keeps.
|
||||||
|
//
|
||||||
|
// Drop one into a Window/ApplicationWindow and toggle `visible`. The
|
||||||
|
// skeleton's Main.qml binds Ctrl+` to flip visibility.
|
||||||
|
//
|
||||||
|
// Dev mode (BRIDGE_URL set): childLogTail() returns empty; the console
|
||||||
|
// renders a hint that there's no bundled child to observe.
|
||||||
|
Rectangle {
|
||||||
|
id: console_
|
||||||
|
|
||||||
|
color: "#0e1116"
|
||||||
|
border.color: "#2a3138"
|
||||||
|
border.width: 1
|
||||||
|
radius: 4
|
||||||
|
|
||||||
|
// The view is implicitly tied to a fixed-pitch font for log alignment.
|
||||||
|
readonly property int maxLines: 500
|
||||||
|
|
||||||
|
ListModel { id: lineModel }
|
||||||
|
|
||||||
|
function append(line) {
|
||||||
|
lineModel.append({ "text": line });
|
||||||
|
while (lineModel.count > maxLines) {
|
||||||
|
lineModel.remove(0);
|
||||||
|
}
|
||||||
|
if (autoScroll.checked) {
|
||||||
|
view.positionViewAtEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reseed() {
|
||||||
|
lineModel.clear();
|
||||||
|
const tail = BackendConnection.childLogTail();
|
||||||
|
for (let i = 0; i < tail.length; ++i) {
|
||||||
|
lineModel.append({ "text": tail[i] });
|
||||||
|
}
|
||||||
|
if (autoScroll.checked) {
|
||||||
|
view.positionViewAtEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: reseed()
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
// Re-seed whenever the user re-opens the console so they see the
|
||||||
|
// latest tail rather than a stale snapshot from the first open.
|
||||||
|
if (visible) reseed();
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: BackendConnection
|
||||||
|
function onChildLogLine(line) { console_.append(line); }
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: BackendConnection.mode === BackendConnection.Bundled
|
||||||
|
? "FrankenPHP — child output"
|
||||||
|
: "Dev mode — no bundled child to capture"
|
||||||
|
color: "#cbd5e1"
|
||||||
|
font.pixelSize: 12
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Label.ElideRight
|
||||||
|
}
|
||||||
|
CheckBox {
|
||||||
|
id: autoScroll
|
||||||
|
text: "Auto-scroll"
|
||||||
|
checked: true
|
||||||
|
contentItem: Label {
|
||||||
|
text: autoScroll.text
|
||||||
|
color: "#cbd5e1"
|
||||||
|
leftPadding: autoScroll.indicator.width + 4
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "Clear"
|
||||||
|
onClicked: lineModel.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: "#0b0d10"
|
||||||
|
border.color: "#1f242b"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: view
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 4
|
||||||
|
model: lineModel
|
||||||
|
clip: true
|
||||||
|
cacheBuffer: 200
|
||||||
|
|
||||||
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
|
||||||
|
delegate: Label {
|
||||||
|
width: view.width
|
||||||
|
text: model.text
|
||||||
|
color: "#d6dee7"
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: 11
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
elide: Label.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: lineModel.count === 0
|
||||||
|
text: BackendConnection.mode === BackendConnection.Bundled
|
||||||
|
? "(no output yet)"
|
||||||
|
: "(dev mode — observe your terminal instead)"
|
||||||
|
color: "#5a6674"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,7 +261,11 @@ bool BackendConnection::spawnChild(QString* errorOut)
|
|||||||
env.insert(QStringLiteral("MERCURE_PUBLISHER_JWT_KEY"), m_jwtSecret);
|
env.insert(QStringLiteral("MERCURE_PUBLISHER_JWT_KEY"), m_jwtSecret);
|
||||||
env.insert(QStringLiteral("MERCURE_SUBSCRIBER_JWT_KEY"), m_jwtSecret);
|
env.insert(QStringLiteral("MERCURE_SUBSCRIBER_JWT_KEY"), m_jwtSecret);
|
||||||
m_child->setProcessEnvironment(env);
|
m_child->setProcessEnvironment(env);
|
||||||
m_child->setProcessChannelMode(QProcess::ForwardedChannels);
|
// Capture both streams into a single chronological buffer so the
|
||||||
|
// DevConsole sees stderr (FrankenPHP / Symfony logs) interleaved with
|
||||||
|
// stdout the same way a TTY would. The console keeps still emitting
|
||||||
|
// each line via qCInfo(lcBundled) so terminal users aren't blinded.
|
||||||
|
m_child->setProcessChannelMode(QProcess::MergedChannels);
|
||||||
|
|
||||||
// Linux: kernel kills the child if the parent dies for any reason.
|
// Linux: kernel kills the child if the parent dies for any reason.
|
||||||
m_child->setChildProcessModifier([] {
|
m_child->setChildProcessModifier([] {
|
||||||
@@ -270,6 +274,8 @@ bool BackendConnection::spawnChild(QString* errorOut)
|
|||||||
|
|
||||||
connect(m_child, &QProcess::finished,
|
connect(m_child, &QProcess::finished,
|
||||||
this, &BackendConnection::onChildFinished);
|
this, &BackendConnection::onChildFinished);
|
||||||
|
connect(m_child, &QProcess::readyReadStandardOutput,
|
||||||
|
this, &BackendConnection::onChildOutputReady);
|
||||||
|
|
||||||
qCInfo(lcBundled) << "spawning frankenphp on port" << m_port;
|
qCInfo(lcBundled) << "spawning frankenphp on port" << m_port;
|
||||||
m_child->start();
|
m_child->start();
|
||||||
@@ -296,6 +302,38 @@ void BackendConnection::teardownChild()
|
|||||||
disconnect(m_child, nullptr, this, nullptr);
|
disconnect(m_child, nullptr, this, nullptr);
|
||||||
m_child->deleteLater();
|
m_child->deleteLater();
|
||||||
m_child = nullptr;
|
m_child = nullptr;
|
||||||
|
m_childLogBuffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackendConnection::onChildOutputReady()
|
||||||
|
{
|
||||||
|
if (!m_child) return;
|
||||||
|
m_childLogBuffer += QString::fromUtf8(m_child->readAllStandardOutput());
|
||||||
|
|
||||||
|
int nl;
|
||||||
|
while ((nl = m_childLogBuffer.indexOf(QLatin1Char('\n'))) >= 0) {
|
||||||
|
QString line = m_childLogBuffer.left(nl);
|
||||||
|
m_childLogBuffer.remove(0, nl + 1);
|
||||||
|
if (line.endsWith(QLatin1Char('\r'))) {
|
||||||
|
line.chop(1);
|
||||||
|
}
|
||||||
|
qCInfo(lcBundled).noquote() << line;
|
||||||
|
m_childLog.enqueue(line);
|
||||||
|
while (m_childLog.size() > kChildLogMax) {
|
||||||
|
m_childLog.dequeue();
|
||||||
|
}
|
||||||
|
emit childLogLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList BackendConnection::childLogTail() const
|
||||||
|
{
|
||||||
|
QStringList out;
|
||||||
|
out.reserve(m_childLog.size());
|
||||||
|
for (const QString& l : m_childLog) {
|
||||||
|
out.append(l);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status)
|
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
|
#include <QQueue>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
#include <QtQmlIntegration>
|
#include <QtQmlIntegration>
|
||||||
|
|
||||||
class QNetworkAccessManager;
|
class QNetworkAccessManager;
|
||||||
@@ -78,6 +80,11 @@ public:
|
|||||||
/// after this completes; emits `updateApplied` / `updateApplyFailed`.
|
/// after this completes; emits `updateApplied` / `updateApplyFailed`.
|
||||||
Q_INVOKABLE void applyUpdate();
|
Q_INVOKABLE void applyUpdate();
|
||||||
|
|
||||||
|
/// Returns a snapshot of the captured child stdout/stderr ring buffer
|
||||||
|
/// (most recent kChildLogMax lines). Bundled mode only — empty in dev
|
||||||
|
/// mode. Used by `DevConsole.qml` to seed its view on first show.
|
||||||
|
Q_INVOKABLE QStringList childLogTail() const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void urlChanged();
|
void urlChanged();
|
||||||
void tokenChanged();
|
void tokenChanged();
|
||||||
@@ -94,10 +101,16 @@ signals:
|
|||||||
void updateApplied();
|
void updateApplied();
|
||||||
void updateApplyFailed(const QString& reason);
|
void updateApplyFailed(const QString& reason);
|
||||||
|
|
||||||
|
/// Emitted for each newline-terminated chunk read from the bundled
|
||||||
|
/// FrankenPHP child's merged stdout+stderr stream. DevConsole.qml
|
||||||
|
/// listens for these to populate its log view live.
|
||||||
|
void childLogLine(const QString& line);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void probe();
|
void probe();
|
||||||
void onProbeFinished();
|
void onProbeFinished();
|
||||||
void onChildFinished(int exitCode, QProcess::ExitStatus status);
|
void onChildFinished(int exitCode, QProcess::ExitStatus status);
|
||||||
|
void onChildOutputReady();
|
||||||
void onUpdateCheckFinished(int exitCode, QProcess::ExitStatus status);
|
void onUpdateCheckFinished(int exitCode, QProcess::ExitStatus status);
|
||||||
void onUpdateApplyFinished(int exitCode, QProcess::ExitStatus status);
|
void onUpdateApplyFinished(int exitCode, QProcess::ExitStatus status);
|
||||||
|
|
||||||
@@ -138,6 +151,10 @@ private:
|
|||||||
int m_supervisorRetries = 0;
|
int m_supervisorRetries = 0;
|
||||||
static constexpr int kMaxSupervisorRetries = 5;
|
static constexpr int kMaxSupervisorRetries = 5;
|
||||||
|
|
||||||
|
QQueue<QString> m_childLog;
|
||||||
|
QString m_childLogBuffer;
|
||||||
|
static constexpr int kChildLogMax = 500;
|
||||||
|
|
||||||
QProcess* m_updateCheck = nullptr;
|
QProcess* m_updateCheck = nullptr;
|
||||||
QProcess* m_updateApply = nullptr;
|
QProcess* m_updateApply = nullptr;
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,19 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DevConsole {
|
||||||
|
id: devConsole
|
||||||
|
visible: false
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 220
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+` toggles the FrankenPHP child output console (Phase 5 §13).
|
||||||
|
Shortcut {
|
||||||
|
sequences: ["Ctrl+`", "Ctrl+~"]
|
||||||
|
onActivated: devConsole.visible = !devConsole.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
function _stateName(s) {
|
function _stateName(s) {
|
||||||
|
|||||||
Reference in New Issue
Block a user