feat(qt): in-app log viewer

The Developer > Share-logs handoff (open::that on the log file) becomes a
filterable in-app view: level filter + substring search run Rust-side over
qbz_log's ring and only the survivors cross the bridge (one document, max
500 rows), with auto-tail, copy-all, a GitHub-ready <details> bundle and
paste upload. The bundle header now reports the real audio backend via
settings_qt::current_backend_label.

cc
This commit is contained in:
vicrodh committed 2026-08-10 17:15:53 -06:00
1 parent 5e1eb46a71
commit 13996a0566
9 files changed
+1010 -5

No files matched your search

+1
View File
@@ -162,6 +162,7 @@ fn main() {
// device flyout both bars mount + the diagnostics modal
// AppShell mounts last.
"qml/shell/QconnectFlyout.qml",
"qml/shell/LogViewerModal.qml",
"qml/shell/QconnectDevModal.qml",
// Immersive mode (2026-08-02 immersive-port contract §2) —
// its own module directory like views/local/ and
@@ -47,10 +47,20 @@ Column {
description: (root.dev.logPath || "") !== ""
? root.dev.logPath
: QbzSession.tr("View the in-app log, copy it (secrets redacted), or upload it to share in a GitHub issue.", QbzSession.trRev)
SettingsButton {
text: QbzSession.tr("Open log file", QbzSession.trRev)
enabled: (root.dev.logPath || "") !== ""
onClicked: QbzBridge.settingsString("open-log-file", "")
Row {
spacing: 8
// The description above has always promised "view the in-app log,
// copy it (secrets redacted), or upload it" — until the viewer
// landed, the only button here did none of those three.
SettingsButton {
text: QbzSession.tr("View logs", QbzSession.trRev)
onClicked: QbzShell.logOpen()
}
SettingsButton {
text: QbzSession.tr("Open log file", QbzSession.trRev)
enabled: (root.dev.logPath || "") !== ""
onClicked: QbzBridge.settingsString("open-log-file", "")
}
}
}
+5 -1
View File
@@ -227,7 +227,11 @@ Item {
name: "cloud-upload"
label: QbzSession.tr("Share logs", QbzSession.trRev)
active: false
onClicked: QbzBridge.settingsString("open-log-file", "")
// Opens the VIEWER (filter / search / copy / bundle /
// upload), 1:1 with the reference. It used to hand the
// raw file to the desktop's file manager, which is
// what "Open log file" in Developer still does.
onClicked: QbzShell.logOpen()
}
}
}
+7
View File
@@ -702,4 +702,11 @@ Rectangle {
// QbzQConnect.diagOpen, so while closed it is an invisible,
// non-interactive Item.
QconnectDevModal { }
// The log viewer (Settings > "Share logs", and Developer). Mounted at the
// AppShell root for the same reason as every other global modal: it is
// opened FROM Settings but must not live inside its Flickable. Self-gates
// on the document's `open`, so while closed it is an invisible,
// non-interactive Item that parses one small JSON string.
LogViewerModal { }
}
+586
View File
@@ -0,0 +1,586 @@
// In-app log viewer — QML port of crates/qbz-ui/ui/shell/LogViewerModal.slint.
//
// THE SHAPE OF THIS MODAL IS THE POINT, and it is not "everything on screen":
//
// default view header (title · shown/total · [+] · X)
// column header (Time · Level · Target · Message)
// the log list
// the uploaded-url row, ONLY after an upload
// footer: Copy all · Upload (public) <- two buttons
//
// [+] reveals the controls row (level · search · Refresh · Auto-tail)
// the advanced actions row (bundle · open file · Clear)
//
// `advancedOpen` starts FALSE. The reference's own comment says why: "the
// default view must serve FAST — the log list plus exactly TWO buttons, Copy
// all and Upload (the whole point of reaching this through Settings > Share
// logs)". The first Qt port drew every control at once and the footer's six
// buttons overflowed the panel; that is what this file exists to not do.
//
// The Rust side owns the ring and pushes an ALREADY filtered + capped slice
// into `QbzShell.logViewerJson` (`total` = ring size, `shown` = survivors), so
// nothing here filters and nothing here holds history.
//
// Mounted in AppShell by declaration order (ADR-009), z >= 3000.
import QtQuick
import com.blitzfc.qbz
import "../controls"
import "../theme"
Item {
id: root
readonly property var doc: {
try {
return JSON.parse(QbzShell.logViewerJson)
} catch (e) {
return ({})
}
}
/// The [+] / [−] disclosure. Private to the view — the reference keeps it
/// as a component-private property too, not in the shared state.
property bool advancedOpen: false
anchors.fill: parent
z: 3100
visible: root.doc.open === true
enabled: root.visible
QbzTheme { id: theme }
readonly property var levelValues: ["all", "error", "warn", "info", "debug", "trace"]
// Column geometry, shared by the header row and every delegate so they
// cannot drift: 80 / 48 / 140 / stretch, 12px side padding, 10px gaps.
readonly property int colTime: 80
readonly property int colLevel: 48
readonly property int colTarget: 140
readonly property int colPad: 12
readonly property int colGap: 10
// Reset the disclosure on every open — the reference's `advanced-open` is
// a fresh component property each time the modal is conditionally
// mounted, so it can never reopen already expanded.
onVisibleChanged: {
if (visible) {
root.advancedOpen = false
keyScope.forceActiveFocus()
}
}
FocusScope {
id: keyScope
anchors.fill: parent
Keys.onEscapePressed: QbzShell.logClose()
}
// Dimmed backdrop; a click closes.
Rectangle {
anchors.fill: parent
color: "#000000bf"
MouseArea {
anchors.fill: parent
onClicked: QbzShell.logClose()
}
}
// Faked 32px drop shadow (#00000080) — the LoginScreen/QbzConfirmModal
// precedent; a real blurred shadow owes its own parity pass.
Rectangle {
anchors.centerIn: panel
width: panel.width + 8
height: panel.height + 8
radius: theme.radiusMd
color: "#00000080"
}
Rectangle {
id: panel
anchors.centerIn: parent
// 760x640 capped, minus an 80px window margin — the reference's exact
// numbers.
width: Math.min(root.width - 80, 760)
height: Math.min(root.height - 80, 640)
radius: theme.radiusMd
color: theme.surfaceCard
border.width: 1
border.color: theme.borderSubtle
// Swallow clicks so they never reach the closing backdrop.
MouseArea { anchors.fill: parent }
// ANCHORED, not a Column. The body is the only stretch row and its
// height depends on which optional rows are mounted (the advanced
// controls, the advanced actions, the uploaded-url row). Hand-counting
// that in a Column meant a formula that had to be re-derived every time
// a row was added — and it was already wrong by one gap in the
// collapsed state. Anchoring the body BETWEEN its neighbours is exact
// and stays exact.
Item {
id: stack
anchors.fill: parent
anchors.margins: 20
// The single gap value the whole modal is spaced by.
readonly property int gap: 14
// ---- Header: title + shown/total + [+] + X --------------------
Item {
id: headerRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 28
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Logs", QbzSession.trRev)
color: theme.textPrimary
font.pixelSize: theme.fontHeading
font.weight: theme.weightSemibold
}
Text {
anchors.verticalCenter: parent.verticalCenter
// "shown / total" — the only way to tell "the filter
// matched nothing" from "the ring is empty".
text: (root.doc.shown || 0) + " / " + (root.doc.total || 0)
color: theme.textMuted
font.pixelSize: theme.fontLegal
}
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
// The discreet disclosure. A bare glyph, not a button.
Item {
width: 24
height: 24
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: root.advancedOpen ? "−" : "+"
color: advArea.containsMouse ? theme.textPrimary : theme.textMuted
font.pixelSize: 18
}
MouseArea {
id: advArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.advancedOpen = !root.advancedOpen
}
}
Item {
width: 28
height: 28
anchors.verticalCenter: parent.verticalCenter
QbzIcon {
anchors.centerIn: parent
name: "x"
width: 17
height: 17
tintName: closeArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: closeArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.logClose()
}
}
}
}
// ---- Controls (ADVANCED): level / search / refresh / auto-tail -
Item {
id: controlsRow
anchors.top: headerRow.bottom
anchors.topMargin: stack.gap
anchors.left: parent.left
anchors.right: parent.right
height: 34
visible: root.advancedOpen
QbzSelect {
id: levelSelect
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
menuWidth: 132
options: [
QbzSession.tr("All levels", QbzSession.trRev),
QbzSession.tr("Error", QbzSession.trRev),
QbzSession.tr("Warn", QbzSession.trRev),
QbzSession.tr("Info", QbzSession.trRev),
QbzSession.tr("Debug", QbzSession.trRev),
QbzSession.tr("Trace", QbzSession.trRev)
]
currentIndex: Math.max(0, root.levelValues.indexOf(root.doc.filterLevel || "all"))
onSelected: function (i) {
QbzShell.logSetLevel(root.levelValues[i] || "all")
}
}
// Search box: a bordered shell with a leading glyph, a focus
// ring, and its own placeholder (the reference builds it the
// same way rather than using the shared line edit).
Rectangle {
id: searchBox
anchors.left: levelSelect.right
anchors.leftMargin: 10
anchors.right: refreshBtn.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
height: 34
radius: theme.radiusSm
border.width: 1
border.color: searchInput.activeFocus ? theme.focusRing : theme.borderSubtle
color: theme.surfaceElevated
QbzIcon {
id: searchGlyph
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
name: "search"
width: 14
height: 14
tintName: "muted"
}
TextInput {
id: searchInput
anchors.left: searchGlyph.right
anchors.leftMargin: 8
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
color: theme.textPrimary
font.pixelSize: theme.fontBody
clip: true
selectByMouse: true
onTextEdited: QbzShell.logSetSearch(text)
}
Text {
visible: searchInput.text === ""
anchors.fill: searchInput
verticalAlignment: Text.AlignVCenter
text: QbzSession.tr("Search logs…", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
}
IconTextButton {
id: refreshBtn
anchors.right: tailRow.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
label: QbzSession.tr("Refresh", QbzSession.trRev)
iconName: "refresh-cw"
onClicked: QbzShell.logRefresh()
}
Row {
id: tailRow
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: QbzSession.tr("Auto-tail", QbzSession.trRev)
color: theme.textSecondary
font.pixelSize: 13
}
QbzToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.doc.autoTail === true
onToggled: function (v) { QbzShell.logSetAutoTail(v) }
}
}
}
// ---- Column header (compact, muted) ---------------------------
Item {
id: colHeaderRow
anchors.top: root.advancedOpen ? controlsRow.bottom : headerRow.bottom
anchors.topMargin: stack.gap
anchors.left: parent.left
anchors.right: parent.right
height: 14
Row {
anchors.left: parent.left
anchors.leftMargin: root.colPad
anchors.right: parent.right
anchors.rightMargin: root.colPad
spacing: root.colGap
Text {
width: root.colTime
text: QbzSession.tr("Time", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
Text {
width: root.colLevel
text: QbzSession.tr("Level", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
Text {
width: root.colTarget
text: QbzSession.tr("Target", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
Text {
text: QbzSession.tr("Message", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontLegal
font.weight: theme.weightSemibold
}
}
}
// ---- Body -----------------------------------------------------
// The stretch row: everything else in this Column is fixed-height,
// so the list takes what is left. Computed rather than anchored
// because a Column child cannot anchor to its siblings' bounds.
Rectangle {
id: bodyBox
anchors.top: colHeaderRow.bottom
anchors.topMargin: stack.gap
anchors.left: parent.left
anchors.right: parent.right
// Stops at whichever optional row is actually mounted below it.
anchors.bottom: uploadedRow.visible ? uploadedRow.top
: (root.advancedOpen ? advActionsRow.top : footerRow.top)
anchors.bottomMargin: stack.gap
radius: theme.radiusSm
// surface-MAIN, a step darker than the card it sits on — the
// list must read as a well, not as more card.
color: theme.surfaceMain
border.width: 1
border.color: theme.borderSubtle
clip: true
ListView {
id: list
anchors.fill: parent
anchors.margins: 1
model: root.doc.rows || []
boundsBehavior: Flickable.StopAtBounds
onCountChanged: if (root.doc.autoTail === true) positionViewAtEnd()
delegate: Item {
width: list.width
// The delegate tracks the WRAPPED message so long lines
// are not clipped (the reference reads the child's
// preferred height — parent-reads-child is the
// non-looping direction).
height: Math.max(msg.implicitHeight, 14) + 6
Row {
anchors.left: parent.left
anchors.leftMargin: root.colPad
anchors.right: parent.right
anchors.rightMargin: root.colPad
anchors.top: parent.top
anchors.topMargin: 3
spacing: root.colGap
Text {
width: root.colTime
text: modelData.ts
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
Text {
width: root.colLevel
text: modelData.level
font.pixelSize: 11
font.weight: theme.weightSemibold
// Fixed literals, 1:1 with the reference —
// NOT theme tokens there either.
color: modelData.level === "ERROR" ? "#f44336"
: modelData.level === "WARN" ? "#e0a030"
: (modelData.level === "DEBUG" || modelData.level === "TRACE")
? theme.textMuted
: theme.textSecondary
}
Text {
width: root.colTarget
text: modelData.target
color: theme.textMuted
font.pixelSize: 11
elide: Text.ElideRight
}
Text {
id: msg
width: parent.width - root.colTime - root.colLevel
- root.colTarget - root.colGap * 3
text: modelData.message
// The MESSAGE is always text-primary. Only the
// LEVEL column carries the level's colour —
// colouring the message too turns the body
// into confetti and is not what the reference
// does.
color: theme.textPrimary
font.pixelSize: 11
wrapMode: Text.WordWrap
}
}
}
}
// Empty state. ONE string — the reference does not distinguish
// "ring empty" from "filter matched nothing"; the shown/total
// counter in the header already says which it is.
Text {
anchors.centerIn: parent
visible: (root.doc.rows || []).length === 0
text: QbzSession.tr("No log entries.", QbzSession.trRev)
color: theme.textMuted
font.pixelSize: theme.fontBody
}
}
// ---- Uploaded URL — after an upload, in BOTH views -------------
// Explicitly NOT gated on `advancedOpen`: Upload lives in the
// always-visible footer, so its result has to be reachable there.
Rectangle {
id: uploadedRow
anchors.bottom: root.advancedOpen ? advActionsRow.top : footerRow.top
anchors.bottomMargin: stack.gap
anchors.left: parent.left
anchors.right: parent.right
height: 32
visible: (root.doc.uploadedUrl || "") !== ""
radius: theme.radiusSm
border.width: 1
border.color: theme.borderSubtle
color: theme.surfaceElevated
QbzIcon {
id: linkGlyph
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
name: "link"
width: 14
height: 14
tintName: "accent"
}
TextInput {
anchors.left: linkGlyph.right
anchors.leftMargin: 8
anchors.right: urlCopy.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
text: root.doc.uploadedUrl || ""
// Read-only but selectable — the point is to grab the url.
readOnly: true
selectByMouse: true
color: theme.textSecondary
font.pixelSize: 12
clip: true
}
Item {
id: urlCopy
anchors.right: parent.right
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
width: 26
height: 26
QbzIcon {
anchors.centerIn: parent
name: "copy"
width: 14
height: 14
tintName: urlCopyArea.containsMouse ? "primary" : "muted"
}
MouseArea {
id: urlCopyArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzShell.logCopyUrl()
}
}
}
// ---- Advanced actions: bundle · (gap) · open file · Clear ------
Item {
id: advActionsRow
anchors.bottom: footerRow.top
anchors.bottomMargin: stack.gap
anchors.left: parent.left
anchors.right: parent.right
height: 32
visible: root.advancedOpen
IconTextButton {
anchors.left: parent.left
label: QbzSession.tr("Copy diagnostics bundle", QbzSession.trRev)
iconName: "copy"
onClicked: QbzShell.logCopyBundle()
}
Row {
anchors.right: parent.right
spacing: 10
IconTextButton {
label: QbzSession.tr("Open log file", QbzSession.trRev)
iconName: "external-link"
onClicked: QbzShell.logOpenFile()
}
IconTextButton {
label: QbzSession.tr("Clear", QbzSession.trRev)
iconName: "trash-2"
danger: true
onClicked: QbzShell.logClear()
}
}
}
// ---- Footer, ALWAYS visible: the two actions this modal is for -
Item {
id: footerRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 32
Row {
anchors.left: parent.left
spacing: 10
IconTextButton {
// Flips to a check + "Copied" for a beat after the
// copy — the reference's `copied` flag, not a toast.
label: root.doc.copied === true
? QbzSession.tr("Copied", QbzSession.trRev)
: QbzSession.tr("Copy all", QbzSession.trRev)
iconName: root.doc.copied === true ? "check" : "copy"
onClicked: QbzShell.logCopyAll()
}
IconTextButton {
label: root.doc.uploading === true
? QbzSession.tr("Uploading…", QbzSession.trRev)
: QbzSession.tr("Upload (public)", QbzSession.trRev)
iconName: "cloud-upload"
btnEnabled: root.doc.uploading !== true
onClicked: QbzShell.logUpload()
}
}
}
}
}
}
+301
View File
@@ -0,0 +1,301 @@
//! In-app log viewer — the read surface over `qbz_log::ring`.
//!
//! Port of `crates/qbz/src/log_viewer.rs`. The ring itself has been live in
//! this build since it landed (`main.rs` installs the tee at boot); what was
//! missing was any way to LOOK at it — Settings > "Share logs" called
//! `open::that(log_path)` and handed the user off to their file manager,
//! where the reference opens a filterable viewer with copy/bundle/upload.
//!
//! Nothing here holds the history: `refresh` snapshots the ring, filters by
//! level + search, keeps the last [`MAX_VIEW_ROWS`], and publishes THAT as a
//! JSON document. `total` is the ring size and `shown` is what survived the
//! filter, so the header can say "showing 200 of 4,000" the way the
//! reference's does.
//!
//! Filtering lives here rather than in QML for the same reason it lives in
//! Rust in the reference: the ring can hold thousands of lines and the view
//! shows at most 500, so filtering QML-side would ship the whole history
//! across the bridge on every keystroke.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};
use cxx_qt_lib::QString;
use serde::Serialize;
use crate::shell_bridge::ui;
/// The view cap. The reference uses the same number.
const MAX_VIEW_ROWS: usize = 500;
/// Lines carried in a shared/uploaded bundle.
const BUNDLE_LINES: usize = 200;
static OPEN: AtomicBool = AtomicBool::new(false);
static AUTO_TAIL: AtomicBool = AtomicBool::new(false);
static UPLOADING: AtomicBool = AtomicBool::new(false);
/// The copy button's transient confirmation. The reference flashes this on
/// the BUTTON (its label and glyph swap to "Copied" + a check) rather than
/// raising a toast — the modal is already the focus, so a toast would fire
/// behind it.
static COPIED: AtomicBool = AtomicBool::new(false);
static FILTER: LazyLock<Mutex<Filter>> = LazyLock::new(|| Mutex::new(Filter::default()));
static UPLOADED_URL: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
#[derive(Default, Clone)]
struct Filter {
level: String,
search: String,
}
#[derive(Serialize)]
struct Row {
ts: String,
level: String,
target: String,
message: String,
}
#[derive(Serialize)]
struct Doc {
open: bool,
rows: Vec<Row>,
/// Ring size, before the filter.
total: i32,
/// After filter + cap.
shown: i32,
#[serde(rename = "filterLevel")]
filter_level: String,
search: String,
#[serde(rename = "autoTail")]
auto_tail: bool,
uploading: bool,
copied: bool,
#[serde(rename = "uploadedUrl")]
uploaded_url: String,
}
/// `all` matches everything; otherwise the line's level must match exactly.
/// The search is a case-insensitive substring over target + message, which is
/// what the reference matches on (`log_viewer.rs:169-185`).
fn line_matches(line: &qbz_log::LogLine, level: &str, search: &str) -> bool {
if level != "all" && !line.level_str().eq_ignore_ascii_case(level) {
return false;
}
if search.is_empty() {
return true;
}
line.message.to_lowercase().contains(search) || line.target.to_lowercase().contains(search)
}
fn snapshot_doc() -> Doc {
let f = FILTER.lock().unwrap_or_else(|e| e.into_inner()).clone();
let level = if f.level.is_empty() {
"all".to_string()
} else {
f.level.to_lowercase()
};
let search = f.search.to_lowercase();
let snap = qbz_log::ring::snapshot();
let total = snap.len();
let filtered: Vec<&qbz_log::LogLine> = snap
.iter()
.filter(|l| line_matches(l, &level, &search))
.collect();
let start = filtered.len().saturating_sub(MAX_VIEW_ROWS);
let rows: Vec<Row> = filtered[start..]
.iter()
.map(|l| Row {
ts: l.format_ts(),
level: l.level_str().to_string(),
target: l.target.clone(),
// Redacted defensively — the ring's write choke point already
// redacts, but this text can leave the machine.
message: qbz_log::redact(&l.message),
})
.collect();
let shown = rows.len() as i32;
Doc {
open: OPEN.load(Ordering::SeqCst),
rows,
total: total as i32,
shown,
filter_level: level,
search: f.search,
auto_tail: AUTO_TAIL.load(Ordering::SeqCst),
uploading: UPLOADING.load(Ordering::SeqCst),
copied: COPIED.load(Ordering::SeqCst),
uploaded_url: UPLOADED_URL.lock().unwrap_or_else(|e| e.into_inner()).clone(),
}
}
/// Flash the copy button's confirmation, then clear it.
fn flash_copied() {
COPIED.store(true, Ordering::SeqCst);
publish();
crate::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
COPIED.store(false, Ordering::SeqCst);
publish();
});
}
pub fn publish() {
let json = serde_json::to_string(&snapshot_doc()).unwrap_or_else(|_| "{}".into());
ui(move |mut b| b.as_mut().set_log_viewer_json(QString::from(json.as_str())));
}
pub fn open() {
OPEN.store(true, Ordering::SeqCst);
publish();
}
pub fn close() {
OPEN.store(false, Ordering::SeqCst);
// Auto-tail must not survive the close, or a closed modal keeps waking
// the UI thread twice a second for a document nobody reads.
set_auto_tail(false);
publish();
}
pub fn set_level(level: String) {
FILTER.lock().unwrap_or_else(|e| e.into_inner()).level = level;
publish();
}
pub fn set_search(search: String) {
FILTER.lock().unwrap_or_else(|e| e.into_inner()).search = search;
publish();
}
pub fn clear() {
qbz_log::ring::clear();
publish();
}
/// Repeating republish while the viewer is open and auto-tail is on.
///
/// One task per enable, guarded by the flag it watches — flipping auto-tail
/// off ends the loop on its next tick, and closing the modal flips it off.
pub fn set_auto_tail(on: bool) {
let was = AUTO_TAIL.swap(on, Ordering::SeqCst);
if on && !was {
crate::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(1000));
ticker.tick().await;
loop {
ticker.tick().await;
if !AUTO_TAIL.load(Ordering::SeqCst) || !OPEN.load(Ordering::SeqCst) {
return;
}
publish();
}
});
}
publish();
}
/// The filtered view as plain redacted lines — what "Copy" puts on the
/// clipboard.
fn filtered_text() -> String {
snapshot_doc()
.rows
.iter()
.map(|r| format!("{} {} {} {}", r.ts, r.level, r.target, r.message))
.collect::<Vec<_>>()
.join("\n")
}
pub fn copy_all() {
crate::share_qt::copy_to_clipboard(filtered_text());
flash_copied();
}
/// The GitHub-ready bundle: the shared formatter's `<details>` header plus the
/// last [`BUNDLE_LINES`] lines.
///
/// DELIBERATE NARROWING vs the reference, recorded rather than hidden: its
/// bundle prepends `diagnostics::build_full_report` (system + the LIVE audio
/// device + graphics + playback + qconnect), which is the DiagnosticsPanel's
/// backend and is not ported. This uses `qbz_log::bundle`, the shared
/// formatter both frontends already have, so the bundle is complete for its
/// own header fields and simply lacks the diagnostics report. It becomes 1:1
/// the day the panel lands.
fn bundle_text() -> String {
let lines = qbz_log::ring::snapshot();
let fields = qbz_log::bundle::DiagFields {
app_version: env!("CARGO_PKG_VERSION"),
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
desktop: &std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(),
session: &std::env::var("XDG_SESSION_TYPE").unwrap_or_default(),
audio_backend: &crate::settings_qt::current_backend_label(),
locale: &qbz_i18n::current_language(),
log_level: &std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
};
qbz_log::bundle::format_diagnostics_bundle(&fields, &lines, BUNDLE_LINES)
}
pub fn copy_bundle() {
crate::share_qt::copy_to_clipboard(bundle_text());
flash_copied();
}
/// Upload the bundle to a public paste and publish the URL.
///
/// Same endpoint as the reference (paste.rs). The text is already redacted
/// twice over — at the ring's write choke point and again in `snapshot_doc` /
/// the bundle formatter — because this is the one path that leaves the
/// machine.
pub async fn upload() {
if UPLOADING.swap(true, Ordering::SeqCst) {
return; // already in flight
}
UPLOADED_URL
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
publish();
let body = bundle_text();
let url = match reqwest::Client::new()
.post("https://paste.rs/")
.body(body)
.send()
.await
{
Ok(resp) => resp.text().await.unwrap_or_default().trim().to_string(),
Err(e) => {
log::warn!("[qbz-qt] log upload failed: {e}");
String::new()
}
};
*UPLOADED_URL.lock().unwrap_or_else(|e| e.into_inner()) = url.clone();
UPLOADING.store(false, Ordering::SeqCst);
if url.is_empty() {
crate::toast_qt::error(qbz_i18n::t("Upload failed."));
}
publish();
}
pub fn copy_url() {
let url = UPLOADED_URL.lock().unwrap_or_else(|e| e.into_inner()).clone();
if url.is_empty() {
return;
}
crate::share_qt::copy_to_clipboard(url);
crate::toast_qt::success(qbz_i18n::t("Copied"));
}
pub fn open_log_file() {
match qbz_log::install::log_file_path() {
Some(path) => {
if let Err(e) = open::that(&path) {
log::warn!("[qbz-qt] open log file failed: {e}");
crate::toast_qt::error(qbz_i18n::t("Could not open the log file."));
}
}
None => crate::toast_qt::error(qbz_i18n::t("Could not open the log file.")),
}
}
+4
View File
@@ -106,6 +106,10 @@ mod genre_filter_qt;
mod home_qt;
mod icon_tint_qt;
mod recently_qt;
// The in-app log viewer over `qbz_log::ring` — the read surface that turns
// "Share logs" from an `open::that(path)` handoff into a filterable view with
// copy / bundle / upload.
mod log_viewer_qt;
mod recommendations_qt;
mod library_db_qt;
mod library_qt;
+6
View File
@@ -1534,6 +1534,12 @@ fn enumerate_devices(backend: AudioBackendType) -> (Vec<DeviceOption>, Vec<Strin
}
}
/// The ACTIVE backend's display label, for anything outside this module that
/// needs to name it in prose (the log viewer's diagnostics bundle header).
pub fn current_backend_label() -> String {
backend_label(audio_settings().backend_type.unwrap_or_default())
}
fn backend_label(t: AudioBackendType) -> String {
match t {
AudioBackendType::PipeWire => "PipeWire".to_string(),
+86
View File
@@ -83,6 +83,13 @@ pub mod qbz_shell {
// Dropdown filter: 0 All / 1 Dark / 2 Light (ui_prefs theme_filter).
#[qproperty(i32, theme_filter)]
// --- Log viewer (log_viewer_qt.rs) --------------------------------
// ONE document: {open, rows[], total, shown, filterLevel, search,
// autoTail, uploading, uploadedUrl}. The ring can hold thousands of
// lines and the view shows at most 500, so the FILTER runs Rust-side
// and only the survivors cross the bridge.
#[qproperty(QString, log_viewer_json)]
// --- Lyrics panel (phase 9) ----------------------------------------
#[qproperty(bool, lyrics_open)]
// One JSON document (lyrics_qt.rs LyricsDoc: status/lines/synced/
@@ -328,6 +335,46 @@ pub mod qbz_shell {
#[qinvokable]
fn large_cycle_spectrum(self: Pin<&mut QbzShell>);
// --- Log viewer (log_viewer_qt.rs) --------------------------------
/// Open / close the viewer. `close` also drops auto-tail, so a closed
/// modal never keeps republishing.
#[qinvokable]
fn log_open(self: Pin<&mut QbzShell>);
#[qinvokable]
fn log_close(self: Pin<&mut QbzShell>);
/// "all" | "error" | "warn" | "info" | "debug" | "trace".
#[qinvokable]
fn log_set_level(self: Pin<&mut QbzShell>, level: QString);
/// Case-insensitive substring over target + message.
#[qinvokable]
fn log_set_search(self: Pin<&mut QbzShell>, search: QString);
/// Re-snapshot the ring now.
#[qinvokable]
fn log_refresh(self: Pin<&mut QbzShell>);
/// Republish once a second while open.
#[qinvokable]
fn log_set_auto_tail(self: Pin<&mut QbzShell>, on: bool);
/// Empty the ring (the in-memory history only — the log FILE is
/// untouched, same as the reference).
#[qinvokable]
fn log_clear(self: Pin<&mut QbzShell>);
/// The filtered view, as plain redacted lines.
#[qinvokable]
fn log_copy_all(self: Pin<&mut QbzShell>);
/// The GitHub-ready `<details>` bundle.
#[qinvokable]
fn log_copy_bundle(self: Pin<&mut QbzShell>);
/// Upload the bundle to a public paste; the url lands in the document.
#[qinvokable]
fn log_upload(self: Pin<&mut QbzShell>);
/// Copy the uploaded paste url.
#[qinvokable]
fn log_copy_url(self: Pin<&mut QbzShell>);
/// Hand the log FILE to the desktop (what "Share logs" used to do on
/// its own).
#[qinvokable]
fn log_open_file(self: Pin<&mut QbzShell>);
/// macOS only (a no-op elsewhere): apply the overlay window
/// attributes and vertically centre the native traffic lights in the
/// header. Called from `Main.qml` on the first rendered frame —
@@ -471,6 +518,7 @@ pub struct QbzShellRust {
theme_slug: QString,
theme_list_json: QString,
theme_filter: i32,
log_viewer_json: QString,
lyrics_open: bool,
lyrics_json: QString,
sidebar_json: QString,
@@ -539,6 +587,7 @@ impl Default for QbzShellRust {
theme_slug: QString::from(crate::theme_qt::current_slug().as_str()),
theme_list_json: QString::from(crate::theme_qt::theme_list_json().as_str()),
theme_filter: crate::theme_qt::theme_filter(),
log_viewer_json: QString::from("{}"),
lyrics_open: false,
lyrics_json: QString::from("{}"),
sidebar_json: QString::from("[]"),
@@ -749,6 +798,43 @@ impl qbz_shell::QbzShell {
crate::large_cycle_spectrum();
}
pub fn log_open(self: Pin<&mut Self>) {
crate::log_viewer_qt::open();
}
pub fn log_close(self: Pin<&mut Self>) {
crate::log_viewer_qt::close();
}
pub fn log_set_level(self: Pin<&mut Self>, level: QString) {
crate::log_viewer_qt::set_level(level.to_string());
}
pub fn log_set_search(self: Pin<&mut Self>, search: QString) {
crate::log_viewer_qt::set_search(search.to_string());
}
pub fn log_refresh(self: Pin<&mut Self>) {
crate::log_viewer_qt::publish();
}
pub fn log_set_auto_tail(self: Pin<&mut Self>, on: bool) {
crate::log_viewer_qt::set_auto_tail(on);
}
pub fn log_clear(self: Pin<&mut Self>) {
crate::log_viewer_qt::clear();
}
pub fn log_copy_all(self: Pin<&mut Self>) {
crate::log_viewer_qt::copy_all();
}
pub fn log_copy_bundle(self: Pin<&mut Self>) {
crate::log_viewer_qt::copy_bundle();
}
pub fn log_upload(self: Pin<&mut Self>) {
crate::spawn(async move { crate::log_viewer_qt::upload().await });
}
pub fn log_copy_url(self: Pin<&mut Self>) {
crate::log_viewer_qt::copy_url();
}
pub fn log_open_file(self: Pin<&mut Self>) {
crate::log_viewer_qt::open_log_file();
}
pub fn apply_mac_chrome(self: Pin<&mut Self>) -> bool {
crate::macos_chrome::apply_and_center()
}