diff --git a/Cargo.lock b/Cargo.lock index 1f03c63..e3150db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9964,6 +9964,9 @@ dependencies = [ "time", "tokio", "tracing", + "tracing-subscriber", + "tracing-wasm", + "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "weaver-api", diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 2da1be7..3ca98ac 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -52,6 +52,8 @@ serde_ipld_dagcbor = { version = "0.6" } loro = "1.9.1" markdown-weaver-escape = { workspace = true } web-time = "1.1" +urlencoding = "2.1" +tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] } [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] webbrowser = "1.0.6" @@ -59,6 +61,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json"] } [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] reqwest = { version = "0.12", default-features = false, features = ["json"] } +tracing-wasm = "0.2" #sqlite-wasm-rs = { version = "0.4", default-features = false, features = ["precompiled", "relaxed-idb"] } time = { version = "0.3", features = ["wasm-bindgen"] } console_error_panic_hook = "0.1" diff --git a/crates/weaver-app/assets/styling/editor.css b/crates/weaver-app/assets/styling/editor.css index 6eea5a0..d1fd20a 100644 --- a/crates/weaver-app/assets/styling/editor.css +++ b/crates/weaver-app/assets/styling/editor.css @@ -116,3 +116,147 @@ li:has(.md-syntax-block:not(.hidden)) { line-height: 1em; vertical-align: baseline; } + +/* Editor page header with report button */ +.editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 6rem; + background: var(--color-base); +} + +.editor-header h1 { + margin: 0; + color: var(--color-text); +} + +/* Bug report button and dialog */ +.report-bug-button { + padding: 0.5rem 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text); + cursor: pointer; + margin-right: 3.5rem; + font-size: 0.9rem; + font-family: var(--font-body); +} + +.report-bug-button:hover { + background: var(--color-overlay); +} + +.report-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.report-dialog { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 1.5rem; + max-width: 1000px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + color: var(--color-text); +} + +.report-dialog h2 { + margin: 0 0 1rem 0; + color: var(--color-emphasis); +} + +.report-section { + margin-bottom: 1rem; +} + +.report-section label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--color-text); +} + +.report-section h4 { + margin: 0.5rem 0; + color: var(--color-subtle); +} + +.report-section pre { + background: var(--color-base); + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + font-size: 0.8rem; + font-family: var(--font-mono); + overflow-y: auto; + color: var(--color-text); +} + +.report-comment { + width: 100%; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-base); + color: var(--color-text); + font-family: var(--font-body); + resize: vertical; +} + +.report-details { + margin: 1rem 0; +} + +.report-details summary { + cursor: pointer; + color: var(--color-muted); +} + +.report-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1rem; +} + +.report-cancel { + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid var(--color-border); + border-radius: 4px; + color: var(--color-text); + cursor: pointer; + font-family: var(--font-body); +} + +.report-cancel:hover { + background: var(--color-overlay); +} + +.report-submit { + padding: 0.5rem 1rem; + background: var(--color-primary); + border: none; + border-radius: 4px; + color: var(--color-base); + cursor: pointer; + font-weight: 500; + font-family: var(--font-body); +} + +.report-submit:hover { + opacity: 0.9; +} diff --git a/crates/weaver-app/src/components/editor/log_buffer.rs b/crates/weaver-app/src/components/editor/log_buffer.rs new file mode 100644 index 0000000..ef102f2 --- /dev/null +++ b/crates/weaver-app/src/components/editor/log_buffer.rs @@ -0,0 +1,110 @@ +//! Ring buffer log capture for bug reports. +//! +//! Provides a tracing Layer that captures recent log messages to a fixed-size +//! ring buffer. Logs can be retrieved on-demand for bug reports. + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::fmt::Write as FmtWrite; + +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +/// Maximum number of log entries to keep. +const MAX_ENTRIES: usize = 100; + +/// Module prefixes to capture in the ring buffer. +const CAPTURED_PREFIXES: &[&str] = &["weaver_", "markdown_weaver"]; + +thread_local! { + static LOG_BUFFER: RefCell> = RefCell::new(VecDeque::with_capacity(MAX_ENTRIES)); +} + +/// Minimum level to buffer from our modules. +const BUFFER_MIN_LEVEL: Level = Level::DEBUG; + +/// A tracing Layer that captures log messages to a ring buffer. +/// Console output is handled by WASMLayer in the subscriber stack. +pub struct LogCaptureLayer; + +impl Layer for LogCaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let metadata = event.metadata(); + let level = metadata.level(); + let target = metadata.target(); + + // Only buffer debug+ logs from our modules + let is_our_module = CAPTURED_PREFIXES.iter().any(|prefix| target.starts_with(prefix)); + if !is_our_module || *level > BUFFER_MIN_LEVEL { + return; + } + + // Format the log entry + let mut message = String::new(); + let mut visitor = MessageVisitor(&mut message); + event.record(&mut visitor); + + let formatted = format!("[{}] {}: {}", level_str(level), target, message); + + LOG_BUFFER.with(|buf| { + let mut buf = buf.borrow_mut(); + if buf.len() >= MAX_ENTRIES { + buf.pop_front(); + } + buf.push_back(formatted); + }); + } +} + +/// Visitor that extracts the message field from a tracing event. +struct MessageVisitor<'a>(&'a mut String); + +impl Visit for MessageVisitor<'_> { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.0, "{:?}", value); + } else { + if !self.0.is_empty() { + self.0.push_str(", "); + } + let _ = write!(self.0, "{}={:?}", field.name(), value); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.0.push_str(value); + } else { + if !self.0.is_empty() { + self.0.push_str(", "); + } + let _ = write!(self.0, "{}={}", field.name(), value); + } + } +} + +fn level_str(level: &Level) -> &'static str { + match *level { + Level::ERROR => "ERROR", + Level::WARN => "WARN", + Level::INFO => "INFO", + Level::DEBUG => "DEBUG", + Level::TRACE => "TRACE", + } +} + +/// Get all captured log entries as a single string. +pub fn get_logs() -> String { + LOG_BUFFER.with(|buf| { + let buf = buf.borrow(); + buf.iter().cloned().collect::>().join("\n") + }) +} + +/// Clear the log buffer. +#[allow(dead_code)] +pub fn clear_logs() { + LOG_BUFFER.with(|buf| buf.borrow_mut().clear()); +} diff --git a/crates/weaver-app/src/components/editor/mod.rs b/crates/weaver-app/src/components/editor/mod.rs index 6c069c0..2482715 100644 --- a/crates/weaver-app/src/components/editor/mod.rs +++ b/crates/weaver-app/src/components/editor/mod.rs @@ -7,10 +7,12 @@ mod cursor; mod document; mod formatting; +mod log_buffer; mod offset_map; mod paragraph; mod platform; mod render; +mod report; mod storage; mod toolbar; mod visibility; @@ -28,6 +30,8 @@ pub use storage::{EditorSnapshot, clear_storage, load_from_storage, save_to_stor pub use toolbar::EditorToolbar; pub use visibility::VisibilityState; pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; +pub use report::ReportButton; +pub use log_buffer::LogCaptureLayer; use dioxus::prelude::*; diff --git a/crates/weaver-app/src/components/editor/report.rs b/crates/weaver-app/src/components/editor/report.rs new file mode 100644 index 0000000..f538120 --- /dev/null +++ b/crates/weaver-app/src/components/editor/report.rs @@ -0,0 +1,199 @@ +//! Bug report dialog for the markdown editor. +//! +//! Captures editor state, DOM, and platform info for bug reports. +//! All capture happens on-demand when the report button is clicked. + +use dioxus::prelude::*; + +use super::log_buffer; +use super::storage::load_from_storage; + +/// Captured report data. +#[derive(Clone, Default)] +struct ReportData { + editor_text: String, + dom_html: String, + platform_info: String, + recent_logs: String, +} + +impl ReportData { + /// Capture current state from DOM and LocalStorage. + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + fn capture(editor_id: &str) -> Self { + let dom_html = web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.get_element_by_id(editor_id)) + .map(|e| e.outer_html()) + .unwrap_or_default(); + + let editor_text = load_from_storage() + .map(|snapshot| snapshot.to_string()) + .unwrap_or_default(); + + let platform_info = { + let plat = super::platform::platform(); + format!( + "iOS: {}, Android: {}, Safari: {}, Chrome: {}, Firefox: {}, Mobile: {}\n\ + User Agent: {}", + plat.ios, + plat.android, + plat.safari, + plat.chrome, + plat.gecko, + plat.mobile, + web_sys::window() + .and_then(|w| w.navigator().user_agent().ok()) + .unwrap_or_default() + ) + }; + + let recent_logs = log_buffer::get_logs(); + + Self { + editor_text, + dom_html, + platform_info, + recent_logs, + } + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + fn capture(_editor_id: &str) -> Self { + Self::default() + } + + /// Generate mailto URL with report data. + fn to_mailto(&self, email: &str, comment: &str) -> String { + let subject = "Weaver Editor Bug Report"; + + let body = format!( + "## Bug Report\n\n\ + ### Comment\n{}\n\n\ + ### Platform Info\n```\n{}\n```\n\n\ + ### Recent Logs\n```\n{}\n```\n\n\ + ### Editor Text\n```markdown\n{}\n```\n\n\ + ### DOM State\n```html\n{}\n```", + comment, self.platform_info, self.recent_logs, self.editor_text, self.dom_html + ); + + let encoded_subject = urlencoding::encode(subject); + let encoded_body = urlencoding::encode(&body); + + format!( + "mailto:{}?subject={}&body={}", + email, encoded_subject, encoded_body + ) + } +} + +/// Props for the bug report button. +#[derive(Props, Clone, PartialEq)] +pub struct ReportButtonProps { + /// Email address to send reports to. + pub email: String, + /// Editor element ID for DOM capture. + pub editor_id: String, +} + +/// Bug report button and dialog. +#[component] +pub fn ReportButton(props: ReportButtonProps) -> Element { + let mut show_dialog = use_signal(|| false); + let mut comment = use_signal(String::new); + let mut report_data = use_signal(ReportData::default); + + let editor_id = props.editor_id.clone(); + let capture_state = move |_| { + report_data.set(ReportData::capture(&editor_id)); + show_dialog.set(true); + }; + + let email = props.email.clone(); + let submit_report = move |_| { + let data = report_data(); + let mailto_url = data.to_mailto(&email, &comment()); + + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + if let Some(window) = web_sys::window() { + let _ = window.open_with_url(&mailto_url); + } + + show_dialog.set(false); + comment.set(String::new()); + }; + + let close_dialog = move |_| { + show_dialog.set(false); + }; + + rsx! { + button { + class: "report-bug-button", + onclick: capture_state, + "Report Bug" + } + + if show_dialog() { + div { + class: "report-dialog-overlay", + onclick: close_dialog, + + div { + class: "report-dialog", + onclick: move |e| e.stop_propagation(), + + h2 { "Report a Bug" } + + div { class: "report-section", + label { "Describe the issue:" } + textarea { + class: "report-comment", + placeholder: "What happened? What did you expect?", + value: "{comment}", + oninput: move |e| comment.set(e.value()), + rows: "4", + } + } + + details { class: "report-details", + summary { "Captured Data (click to expand)" } + + div { class: "report-section", + h4 { "Platform" } + pre { "{report_data().platform_info}" } + } + + div { class: "report-section", + h4 { "Recent Logs" } + pre { "{report_data().recent_logs}" } + } + + div { class: "report-section", + h4 { "Editor Text" } + pre { "{report_data().editor_text}" } + } + + div { class: "report-section", + h4 { "DOM HTML" } + pre { "{report_data().dom_html}" } + } + } + + div { class: "report-actions", + button { + class: "report-cancel", + onclick: close_dialog, + "Cancel" + } + button { + class: "report-submit", + onclick: submit_report, + "Open Email" + } + } + } + } + } + } +} diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index fd76d4e..f60e75c 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -100,6 +100,34 @@ fn main() { #[cfg(target_arch = "wasm32")] console_error_panic_hook::set_once(); + // Set up tracing subscriber with both console output and log capture (wasm only) + // Must happen before dioxus::launch so dioxus skips its own init + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + use tracing::Level; + use tracing::subscriber::set_global_default; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::Registry; + + let console_level = if cfg!(debug_assertions) { + Level::DEBUG + } else { + Level::INFO + }; + + let wasm_layer = tracing_wasm::WASMLayer::new( + tracing_wasm::WASMLayerConfigBuilder::new() + .set_max_level(console_level) + .build(), + ); + + let reg = Registry::default() + .with(wasm_layer) + .with(components::editor::LogCaptureLayer); + + let _ = set_global_default(reg); + } + #[cfg(feature = "server")] std::panic::set_hook(Box::new(|panic_info| { tracing::error!("PANIC: {:?}", panic_info); diff --git a/crates/weaver-app/src/views/editor.rs b/crates/weaver-app/src/views/editor.rs index 78a13e1..1fd9874 100644 --- a/crates/weaver-app/src/views/editor.rs +++ b/crates/weaver-app/src/views/editor.rs @@ -1,6 +1,6 @@ //! Editor view - wraps the MarkdownEditor component for the /editor route. -use crate::components::{editor::MarkdownEditor, record_view::CodeView}; +use crate::components::editor::{MarkdownEditor, ReportButton}; use dioxus::prelude::*; /// Editor page view. @@ -12,9 +12,14 @@ pub fn Editor() -> Element { rsx! { EditorCss {} div { class: "editor-page", - h1 { style: "margin-left: 6rem;", "Markdown Editor Test" } + div { class: "editor-header", + h1 { "Markdown Editor Test" } + ReportButton { + email: "editor-bugs@weaver.sh".to_string(), + editor_id: "markdown-editor".to_string(), + } + } MarkdownEditor { initial_content: None } - } } } @@ -41,3 +46,4 @@ pub fn EditorCss() -> Element { _ => rsx! {}, } } +