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:
2026-05-02 20:58:53 +02:00
parent 31bdac80e6
commit 4c15ac281c
6 changed files with 221 additions and 1 deletions

View File

@@ -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) {

View File

@@ -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/)

View 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
}
}
}
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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) {