bug reporting

Orual c959a17f 2f2ed57a

+500 -3
+3
Cargo.lock
··· 9964 9964 "time", 9965 9965 "tokio", 9966 9966 "tracing", 9967 + "tracing-subscriber", 9968 + "tracing-wasm", 9969 + "urlencoding", 9967 9970 "wasm-bindgen", 9968 9971 "wasm-bindgen-futures", 9969 9972 "weaver-api",
+3
crates/weaver-app/Cargo.toml
··· 52 52 loro = "1.9.1" 53 53 markdown-weaver-escape = { workspace = true } 54 54 web-time = "1.1" 55 + urlencoding = "2.1" 56 + tracing-subscriber = { version = "0.3", default-features = false, features = ["std", "registry"] } 55 57 56 58 [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] 57 59 webbrowser = "1.0.6" ··· 59 61 60 62 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] 61 63 reqwest = { version = "0.12", default-features = false, features = ["json"] } 64 + tracing-wasm = "0.2" 62 65 #sqlite-wasm-rs = { version = "0.4", default-features = false, features = ["precompiled", "relaxed-idb"] } 63 66 time = { version = "0.3", features = ["wasm-bindgen"] } 64 67 console_error_panic_hook = "0.1"
+144
crates/weaver-app/assets/styling/editor.css
··· 116 116 line-height: 1em; 117 117 vertical-align: baseline; 118 118 } 119 + 120 + /* Editor page header with report button */ 121 + .editor-header { 122 + display: flex; 123 + justify-content: space-between; 124 + align-items: center; 125 + padding: 1rem 6rem; 126 + background: var(--color-base); 127 + } 128 + 129 + .editor-header h1 { 130 + margin: 0; 131 + color: var(--color-text); 132 + } 133 + 134 + /* Bug report button and dialog */ 135 + .report-bug-button { 136 + padding: 0.5rem 1rem; 137 + background: var(--color-surface); 138 + border: 1px solid var(--color-border); 139 + border-radius: 4px; 140 + color: var(--color-text); 141 + cursor: pointer; 142 + margin-right: 3.5rem; 143 + font-size: 0.9rem; 144 + font-family: var(--font-body); 145 + } 146 + 147 + .report-bug-button:hover { 148 + background: var(--color-overlay); 149 + } 150 + 151 + .report-dialog-overlay { 152 + position: fixed; 153 + top: 0; 154 + left: 0; 155 + right: 0; 156 + bottom: 0; 157 + background: rgba(0, 0, 0, 0.6); 158 + display: flex; 159 + align-items: center; 160 + justify-content: center; 161 + z-index: 1000; 162 + } 163 + 164 + .report-dialog { 165 + background: var(--color-surface); 166 + border: 1px solid var(--color-border); 167 + border-radius: 8px; 168 + padding: 1.5rem; 169 + max-width: 1000px; 170 + width: 90%; 171 + max-height: 80vh; 172 + overflow-y: auto; 173 + color: var(--color-text); 174 + } 175 + 176 + .report-dialog h2 { 177 + margin: 0 0 1rem 0; 178 + color: var(--color-emphasis); 179 + } 180 + 181 + .report-section { 182 + margin-bottom: 1rem; 183 + } 184 + 185 + .report-section label { 186 + display: block; 187 + margin-bottom: 0.5rem; 188 + font-weight: 500; 189 + color: var(--color-text); 190 + } 191 + 192 + .report-section h4 { 193 + margin: 0.5rem 0; 194 + color: var(--color-subtle); 195 + } 196 + 197 + .report-section pre { 198 + background: var(--color-base); 199 + padding: 1rem; 200 + border-radius: 4px; 201 + overflow-x: auto; 202 + font-size: 0.8rem; 203 + font-family: var(--font-mono); 204 + overflow-y: auto; 205 + color: var(--color-text); 206 + } 207 + 208 + .report-comment { 209 + width: 100%; 210 + padding: 1rem; 211 + border: 1px solid var(--color-border); 212 + border-radius: 4px; 213 + background: var(--color-base); 214 + color: var(--color-text); 215 + font-family: var(--font-body); 216 + resize: vertical; 217 + } 218 + 219 + .report-details { 220 + margin: 1rem 0; 221 + } 222 + 223 + .report-details summary { 224 + cursor: pointer; 225 + color: var(--color-muted); 226 + } 227 + 228 + .report-actions { 229 + display: flex; 230 + gap: 1rem; 231 + justify-content: flex-end; 232 + margin-top: 1rem; 233 + } 234 + 235 + .report-cancel { 236 + padding: 0.5rem 1rem; 237 + background: transparent; 238 + border: 1px solid var(--color-border); 239 + border-radius: 4px; 240 + color: var(--color-text); 241 + cursor: pointer; 242 + font-family: var(--font-body); 243 + } 244 + 245 + .report-cancel:hover { 246 + background: var(--color-overlay); 247 + } 248 + 249 + .report-submit { 250 + padding: 0.5rem 1rem; 251 + background: var(--color-primary); 252 + border: none; 253 + border-radius: 4px; 254 + color: var(--color-base); 255 + cursor: pointer; 256 + font-weight: 500; 257 + font-family: var(--font-body); 258 + } 259 + 260 + .report-submit:hover { 261 + opacity: 0.9; 262 + }
+110
crates/weaver-app/src/components/editor/log_buffer.rs
··· 1 + //! Ring buffer log capture for bug reports. 2 + //! 3 + //! Provides a tracing Layer that captures recent log messages to a fixed-size 4 + //! ring buffer. Logs can be retrieved on-demand for bug reports. 5 + 6 + use std::cell::RefCell; 7 + use std::collections::VecDeque; 8 + use std::fmt::Write as FmtWrite; 9 + 10 + use tracing::field::{Field, Visit}; 11 + use tracing::{Event, Level, Subscriber}; 12 + use tracing_subscriber::layer::Context; 13 + use tracing_subscriber::Layer; 14 + 15 + /// Maximum number of log entries to keep. 16 + const MAX_ENTRIES: usize = 100; 17 + 18 + /// Module prefixes to capture in the ring buffer. 19 + const CAPTURED_PREFIXES: &[&str] = &["weaver_", "markdown_weaver"]; 20 + 21 + thread_local! { 22 + static LOG_BUFFER: RefCell<VecDeque<String>> = RefCell::new(VecDeque::with_capacity(MAX_ENTRIES)); 23 + } 24 + 25 + /// Minimum level to buffer from our modules. 26 + const BUFFER_MIN_LEVEL: Level = Level::DEBUG; 27 + 28 + /// A tracing Layer that captures log messages to a ring buffer. 29 + /// Console output is handled by WASMLayer in the subscriber stack. 30 + pub struct LogCaptureLayer; 31 + 32 + impl<S: Subscriber> Layer<S> for LogCaptureLayer { 33 + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { 34 + let metadata = event.metadata(); 35 + let level = metadata.level(); 36 + let target = metadata.target(); 37 + 38 + // Only buffer debug+ logs from our modules 39 + let is_our_module = CAPTURED_PREFIXES.iter().any(|prefix| target.starts_with(prefix)); 40 + if !is_our_module || *level > BUFFER_MIN_LEVEL { 41 + return; 42 + } 43 + 44 + // Format the log entry 45 + let mut message = String::new(); 46 + let mut visitor = MessageVisitor(&mut message); 47 + event.record(&mut visitor); 48 + 49 + let formatted = format!("[{}] {}: {}", level_str(level), target, message); 50 + 51 + LOG_BUFFER.with(|buf| { 52 + let mut buf = buf.borrow_mut(); 53 + if buf.len() >= MAX_ENTRIES { 54 + buf.pop_front(); 55 + } 56 + buf.push_back(formatted); 57 + }); 58 + } 59 + } 60 + 61 + /// Visitor that extracts the message field from a tracing event. 62 + struct MessageVisitor<'a>(&'a mut String); 63 + 64 + impl Visit for MessageVisitor<'_> { 65 + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { 66 + if field.name() == "message" { 67 + let _ = write!(self.0, "{:?}", value); 68 + } else { 69 + if !self.0.is_empty() { 70 + self.0.push_str(", "); 71 + } 72 + let _ = write!(self.0, "{}={:?}", field.name(), value); 73 + } 74 + } 75 + 76 + fn record_str(&mut self, field: &Field, value: &str) { 77 + if field.name() == "message" { 78 + self.0.push_str(value); 79 + } else { 80 + if !self.0.is_empty() { 81 + self.0.push_str(", "); 82 + } 83 + let _ = write!(self.0, "{}={}", field.name(), value); 84 + } 85 + } 86 + } 87 + 88 + fn level_str(level: &Level) -> &'static str { 89 + match *level { 90 + Level::ERROR => "ERROR", 91 + Level::WARN => "WARN", 92 + Level::INFO => "INFO", 93 + Level::DEBUG => "DEBUG", 94 + Level::TRACE => "TRACE", 95 + } 96 + } 97 + 98 + /// Get all captured log entries as a single string. 99 + pub fn get_logs() -> String { 100 + LOG_BUFFER.with(|buf| { 101 + let buf = buf.borrow(); 102 + buf.iter().cloned().collect::<Vec<_>>().join("\n") 103 + }) 104 + } 105 + 106 + /// Clear the log buffer. 107 + #[allow(dead_code)] 108 + pub fn clear_logs() { 109 + LOG_BUFFER.with(|buf| buf.borrow_mut().clear()); 110 + }
+4
crates/weaver-app/src/components/editor/mod.rs
··· 7 7 mod cursor; 8 8 mod document; 9 9 mod formatting; 10 + mod log_buffer; 10 11 mod offset_map; 11 12 mod paragraph; 12 13 mod platform; 13 14 mod render; 15 + mod report; 14 16 mod storage; 15 17 mod toolbar; 16 18 mod visibility; ··· 28 30 pub use toolbar::EditorToolbar; 29 31 pub use visibility::VisibilityState; 30 32 pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult}; 33 + pub use report::ReportButton; 34 + pub use log_buffer::LogCaptureLayer; 31 35 32 36 use dioxus::prelude::*; 33 37
+199
crates/weaver-app/src/components/editor/report.rs
··· 1 + //! Bug report dialog for the markdown editor. 2 + //! 3 + //! Captures editor state, DOM, and platform info for bug reports. 4 + //! All capture happens on-demand when the report button is clicked. 5 + 6 + use dioxus::prelude::*; 7 + 8 + use super::log_buffer; 9 + use super::storage::load_from_storage; 10 + 11 + /// Captured report data. 12 + #[derive(Clone, Default)] 13 + struct ReportData { 14 + editor_text: String, 15 + dom_html: String, 16 + platform_info: String, 17 + recent_logs: String, 18 + } 19 + 20 + impl ReportData { 21 + /// Capture current state from DOM and LocalStorage. 22 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 23 + fn capture(editor_id: &str) -> Self { 24 + let dom_html = web_sys::window() 25 + .and_then(|w| w.document()) 26 + .and_then(|d| d.get_element_by_id(editor_id)) 27 + .map(|e| e.outer_html()) 28 + .unwrap_or_default(); 29 + 30 + let editor_text = load_from_storage() 31 + .map(|snapshot| snapshot.to_string()) 32 + .unwrap_or_default(); 33 + 34 + let platform_info = { 35 + let plat = super::platform::platform(); 36 + format!( 37 + "iOS: {}, Android: {}, Safari: {}, Chrome: {}, Firefox: {}, Mobile: {}\n\ 38 + User Agent: {}", 39 + plat.ios, 40 + plat.android, 41 + plat.safari, 42 + plat.chrome, 43 + plat.gecko, 44 + plat.mobile, 45 + web_sys::window() 46 + .and_then(|w| w.navigator().user_agent().ok()) 47 + .unwrap_or_default() 48 + ) 49 + }; 50 + 51 + let recent_logs = log_buffer::get_logs(); 52 + 53 + Self { 54 + editor_text, 55 + dom_html, 56 + platform_info, 57 + recent_logs, 58 + } 59 + } 60 + 61 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 62 + fn capture(_editor_id: &str) -> Self { 63 + Self::default() 64 + } 65 + 66 + /// Generate mailto URL with report data. 67 + fn to_mailto(&self, email: &str, comment: &str) -> String { 68 + let subject = "Weaver Editor Bug Report"; 69 + 70 + let body = format!( 71 + "## Bug Report\n\n\ 72 + ### Comment\n{}\n\n\ 73 + ### Platform Info\n```\n{}\n```\n\n\ 74 + ### Recent Logs\n```\n{}\n```\n\n\ 75 + ### Editor Text\n```markdown\n{}\n```\n\n\ 76 + ### DOM State\n```html\n{}\n```", 77 + comment, self.platform_info, self.recent_logs, self.editor_text, self.dom_html 78 + ); 79 + 80 + let encoded_subject = urlencoding::encode(subject); 81 + let encoded_body = urlencoding::encode(&body); 82 + 83 + format!( 84 + "mailto:{}?subject={}&body={}", 85 + email, encoded_subject, encoded_body 86 + ) 87 + } 88 + } 89 + 90 + /// Props for the bug report button. 91 + #[derive(Props, Clone, PartialEq)] 92 + pub struct ReportButtonProps { 93 + /// Email address to send reports to. 94 + pub email: String, 95 + /// Editor element ID for DOM capture. 96 + pub editor_id: String, 97 + } 98 + 99 + /// Bug report button and dialog. 100 + #[component] 101 + pub fn ReportButton(props: ReportButtonProps) -> Element { 102 + let mut show_dialog = use_signal(|| false); 103 + let mut comment = use_signal(String::new); 104 + let mut report_data = use_signal(ReportData::default); 105 + 106 + let editor_id = props.editor_id.clone(); 107 + let capture_state = move |_| { 108 + report_data.set(ReportData::capture(&editor_id)); 109 + show_dialog.set(true); 110 + }; 111 + 112 + let email = props.email.clone(); 113 + let submit_report = move |_| { 114 + let data = report_data(); 115 + let mailto_url = data.to_mailto(&email, &comment()); 116 + 117 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 118 + if let Some(window) = web_sys::window() { 119 + let _ = window.open_with_url(&mailto_url); 120 + } 121 + 122 + show_dialog.set(false); 123 + comment.set(String::new()); 124 + }; 125 + 126 + let close_dialog = move |_| { 127 + show_dialog.set(false); 128 + }; 129 + 130 + rsx! { 131 + button { 132 + class: "report-bug-button", 133 + onclick: capture_state, 134 + "Report Bug" 135 + } 136 + 137 + if show_dialog() { 138 + div { 139 + class: "report-dialog-overlay", 140 + onclick: close_dialog, 141 + 142 + div { 143 + class: "report-dialog", 144 + onclick: move |e| e.stop_propagation(), 145 + 146 + h2 { "Report a Bug" } 147 + 148 + div { class: "report-section", 149 + label { "Describe the issue:" } 150 + textarea { 151 + class: "report-comment", 152 + placeholder: "What happened? What did you expect?", 153 + value: "{comment}", 154 + oninput: move |e| comment.set(e.value()), 155 + rows: "4", 156 + } 157 + } 158 + 159 + details { class: "report-details", 160 + summary { "Captured Data (click to expand)" } 161 + 162 + div { class: "report-section", 163 + h4 { "Platform" } 164 + pre { "{report_data().platform_info}" } 165 + } 166 + 167 + div { class: "report-section", 168 + h4 { "Recent Logs" } 169 + pre { "{report_data().recent_logs}" } 170 + } 171 + 172 + div { class: "report-section", 173 + h4 { "Editor Text" } 174 + pre { "{report_data().editor_text}" } 175 + } 176 + 177 + div { class: "report-section", 178 + h4 { "DOM HTML" } 179 + pre { "{report_data().dom_html}" } 180 + } 181 + } 182 + 183 + div { class: "report-actions", 184 + button { 185 + class: "report-cancel", 186 + onclick: close_dialog, 187 + "Cancel" 188 + } 189 + button { 190 + class: "report-submit", 191 + onclick: submit_report, 192 + "Open Email" 193 + } 194 + } 195 + } 196 + } 197 + } 198 + } 199 + }
+28
crates/weaver-app/src/main.rs
··· 100 100 #[cfg(target_arch = "wasm32")] 101 101 console_error_panic_hook::set_once(); 102 102 103 + // Set up tracing subscriber with both console output and log capture (wasm only) 104 + // Must happen before dioxus::launch so dioxus skips its own init 105 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 106 + { 107 + use tracing::Level; 108 + use tracing::subscriber::set_global_default; 109 + use tracing_subscriber::layer::SubscriberExt; 110 + use tracing_subscriber::Registry; 111 + 112 + let console_level = if cfg!(debug_assertions) { 113 + Level::DEBUG 114 + } else { 115 + Level::INFO 116 + }; 117 + 118 + let wasm_layer = tracing_wasm::WASMLayer::new( 119 + tracing_wasm::WASMLayerConfigBuilder::new() 120 + .set_max_level(console_level) 121 + .build(), 122 + ); 123 + 124 + let reg = Registry::default() 125 + .with(wasm_layer) 126 + .with(components::editor::LogCaptureLayer); 127 + 128 + let _ = set_global_default(reg); 129 + } 130 + 103 131 #[cfg(feature = "server")] 104 132 std::panic::set_hook(Box::new(|panic_info| { 105 133 tracing::error!("PANIC: {:?}", panic_info);
+9 -3
crates/weaver-app/src/views/editor.rs
··· 1 1 //! Editor view - wraps the MarkdownEditor component for the /editor route. 2 2 3 - use crate::components::{editor::MarkdownEditor, record_view::CodeView}; 3 + use crate::components::editor::{MarkdownEditor, ReportButton}; 4 4 use dioxus::prelude::*; 5 5 6 6 /// Editor page view. ··· 12 12 rsx! { 13 13 EditorCss {} 14 14 div { class: "editor-page", 15 - h1 { style: "margin-left: 6rem;", "Markdown Editor Test" } 15 + div { class: "editor-header", 16 + h1 { "Markdown Editor Test" } 17 + ReportButton { 18 + email: "editor-bugs@weaver.sh".to_string(), 19 + editor_id: "markdown-editor".to_string(), 20 + } 21 + } 16 22 MarkdownEditor { initial_content: None } 17 - 18 23 } 19 24 } 20 25 } ··· 41 46 _ => rsx! {}, 42 47 } 43 48 } 49 +