diff --git a/.gitignore b/.gitignore index 2f22286a56..f193f85169 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ tmp/ bundle/ # in debugging we frequently dump wasm to wat with `wasm-tools print` -*.wat +*.wat \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index f5f8d42586..5846699c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ members = [ "packages/hooks", "packages/html-internal-macro", "packages/html", + "packages/inspector", + "packages/inspector-macros", "packages/interpreter", "packages/lazy-js-bundle", "packages/liveview", @@ -70,6 +72,8 @@ members = [ "packages/asset-resolver", "packages/depinfo", "packages/component-manifest", + "packages/inspector", + "packages/inspector-macros", # CLI harnesses, all included "packages/cli-harnesses/*", @@ -158,6 +162,8 @@ dioxus-history = { path = "packages/history", version = "0.7.1", default-feature dioxus-html = { path = "packages/html", version = "0.7.1", default-features = false } dioxus-html-internal-macro = { path = "packages/html-internal-macro", version = "0.7.1" } dioxus-hooks = { path = "packages/hooks", version = "0.7.1" } +dioxus-inspector = { path = "packages/inspector", version = "0.1.0", default-features = false } +dioxus-inspector-macros = { path = "packages/inspector-macros", version = "0.1.0" } dioxus-web = { path = "packages/web", version = "0.7.1", default-features = false } dioxus-ssr = { path = "packages/ssr", version = "0.7.1", default-features = false } dioxus-desktop = { path = "packages/desktop", version = "0.7.1", default-features = false } diff --git a/packages/dioxus/Cargo.toml b/packages/dioxus/Cargo.toml index 8644885374..bcc1b4e3d5 100644 --- a/packages/dioxus/Cargo.toml +++ b/packages/dioxus/Cargo.toml @@ -67,6 +67,7 @@ macro = ["dep:dioxus-core-macro"] html = ["dep:dioxus-html"] hooks = ["dep:dioxus-hooks"] devtools = ["dep:dioxus-devtools", "dioxus-web?/devtools"] +inspector = ["dioxus-web?/inspector"] mounted = ["dioxus-web?/mounted"] asset = ["dep:manganis", "dep:dioxus-asset-resolver"] document = ["dioxus-web?/document", "dep:dioxus-document", "dep:dioxus-history"] diff --git a/packages/inspector-macros/Cargo.toml b/packages/inspector-macros/Cargo.toml new file mode 100644 index 0000000000..f0e71f51e5 --- /dev/null +++ b/packages/inspector-macros/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "dioxus-inspector-macros" +version = "0.1.0" +edition = "2024" +description = "Attribute macros for annotating Dioxus components with inspector metadata" +license = "MIT OR Apache-2.0" + +[lib] +proc-macro = true + +[dependencies] diff --git a/packages/inspector-macros/src/lib.rs b/packages/inspector-macros/src/lib.rs new file mode 100644 index 0000000000..7d93aa75e8 --- /dev/null +++ b/packages/inspector-macros/src/lib.rs @@ -0,0 +1,9 @@ +//! Attribute macro stubs for the inspector runtime. + +use proc_macro::TokenStream; + +/// Placeholder attribute so existing `#[inspector]` usages continue to compile. +#[proc_macro_attribute] +pub fn inspector(_args: TokenStream, input: TokenStream) -> TokenStream { + input +} diff --git a/packages/inspector/.gitignore b/packages/inspector/.gitignore new file mode 100644 index 0000000000..96ef6c0b94 --- /dev/null +++ b/packages/inspector/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/packages/inspector/Cargo.toml b/packages/inspector/Cargo.toml new file mode 100644 index 0000000000..7455496165 --- /dev/null +++ b/packages/inspector/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "dioxus-inspector" +version = "0.1.0" +edition = "2024" +description = "Inspector tooling for Dioxus applications" +license = "MIT OR Apache-2.0" + +[features] +default = [] +client = ["dep:wasm-bindgen", "dep:wasm-bindgen-futures", "dep:web-sys"] +server = ["dep:reqwest"] + +[dependencies] +dioxus-inspector-macros = { path = "../inspector-macros" } +reqwest = { version = "0.12", optional = true, features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +wasm-bindgen = { version = "0.2", optional = true, features = ["serde-serialize"] } +wasm-bindgen-futures = { version = "0.4", optional = true } +web-sys = { version = "0.3", optional = true, features = [ + "Document", + "Element", + "HtmlDivElement", + "HtmlElement", + "CssStyleDeclaration", + "Window", + "MouseEvent", + "KeyboardEvent", + "Event", + "EventTarget", + "DomRect", + "Request", + "RequestInit", + "RequestMode", + "Response", + "Headers", + "console", +] } diff --git a/packages/inspector/README.ko.md b/packages/inspector/README.ko.md new file mode 100644 index 0000000000..b8ed089446 --- /dev/null +++ b/packages/inspector/README.ko.md @@ -0,0 +1,221 @@ +# Dioxus Inspector / 디옥서스 인스펙터 + +> Click a rendered element (Cmd/Ctrl + Shift + Click) and jump straight to the original Rust source line in your IDE. +> 렌더된 요소를 **Cmd/Ctrl + Shift + Click** 하면 IDE에서 원본 소스 라인으로 바로 이동합니다. + +## 🚀 빠른 시작 + +### 1. 의존성 추가 (이미 완료됨) + +```toml +# Cargo.toml +[dependencies] +dioxus-inspector = { path = "../../crates/dioxus/packages/inspector", features = ["client"] } + +[features] +inspector = ["dioxus-inspector"] +``` + +### 2. 컴포넌트 설정 + +```rust +use dioxus::prelude::*; +use dioxus_inspector::InspectorClient; + +#[component] +pub fn App() -> Element { + #[cfg(feature = "inspector")] + { + use_effect(|| { + if let Err(err) = InspectorClient::new("http://127.0.0.1:41235").install() { + tracing::warn!(?err, "Inspector client failed to initialize"); + } + || {} + }); + } + + rsx! { + div { + class: "app", + "Hello World" + } + } +} +``` + +### 3. Server 실행 + +```bash +# Terminal 1: Inspector Server +npm run dev:inspector + +# Terminal 2: Dioxus App (Web) +cd apps/metacity-server +dx serve --features inspector + +# (선택) Desktop/Tauri +cargo tauri dev --features inspector +``` + +### 4. 사용하기 + +1. 브라우저에서 앱 열기 +2. **Cmd+Shift+Click** (또는 Ctrl+Shift+Click) +3. 컴포넌트 클릭 +4. IDE가 자동으로 열림! (VSCode, Cursor, Windsurf, JetBrains 등 대부분 CLI 지원 IDE) + +## 📝 상세 사용법 + +### DOM 메타데이터는 자동 삽입 + +`dx serve --features inspector` 처럼 **Debug 빌드**를 실행하면 Dioxus 매크로가 모든 DOM 요소에 `data-inspector` 속성을 자동으로 추가합니다. (파일 경로, 줄, 열 정보 포함) +따라서 더 이상 `data_inspector` 속성을 직접 작성할 필요가 없습니다. + +### 조건부 컴파일 + +- **Debug 빌드**: Inspector 활성화 +- **Release 빌드**: 자동으로 제거 (성능 영향 0) + +```bash +# Debug (inspector 포함) +dx serve --features inspector + +# Release (inspector 제외) +dx build --release +``` + +## 🎯 IDE 지원 / Supported IDEs + +- VSCode / Code Insiders +- Cursor +- Windsurf +- WebStorm / IntelliJ / Fleet (JetBrains) +- 기타 `--goto file:line[:column]` 형태의 CLI를 제공하는 IDE (커스텀 명령 추가 가능) + +Inspector server는 환경변수(`EDITOR`, `TERM_PROGRAM`), 실행 중인 프로세스, CLI 존재 여부(`which`, `where`)를 활용해 IDE를 감지합니다. 필요하면 `scripts/inspector-server.js`에서 감지 순서를 커스터마이징하세요. + +## 🔧 설정 / 커스터마이징 + +### 다른 포트 사용 + +```rust +const INSPECTOR_ENDPOINT: &str = "http://127.0.0.1:8888"; + +InspectorClient::new(INSPECTOR_ENDPOINT).install() +``` + +Server도 동일한 포트로: +```javascript +// scripts/inspector-server.js +const PORT = 8888; +``` + +### 커스텀 단축키 + +```rust +use dioxus_inspector::client::ClickModifier; + +let client = InspectorClient::new(endpoint) + .with_modifier(ClickModifier { + meta: false, // Cmd/Ctrl 불필요 + shift: true, // Shift만 필요 + }); +``` + +## 🐛 문제 해결 + +### 클릭해도 반응 없음 +```bash +# 1. Inspector server 실행 중인지 확인 +npm run dev:inspector + +# 2. 브라우저 콘솔 확인 +# "Inspector client installed" 메시지 있어야 함 +``` + +### IDE가 안 열림 +```bash +# 1. Server 로그 확인 +[Inspector] Opening: code --goto /path/to/file.rs:42:1 + +# 2. IDE CLI 설치 확인 +which windsurf # 또는 code, cursor + +# 3. 수동으로 테스트 +windsurf --goto /path/to/file.rs:42:1 +``` + +### CORS 에러 +→ `inspector-server.js`에 이미 CORS 설정됨. 포트 확인. + +## 📚 아키텍처 / Architecture + +``` +Browser (WASM) Dev Server (Node.js) IDE + │ │ │ + │ Cmd/Ctrl+Shift+Click │ │ + │──────────────────────────────>│ │ + │ │ │ + │ POST /api/inspector/open │ spawn('code'/'cursor') │ + │ { file, line, column } │─────────────────────────>│ + │ │ │ + │ ← 200 OK │ │ + │ │ File opens! +``` + +## 🎨 예제 / Example + +`apps/metacity-server/src/components/app.rs`에서 실전 예시를 볼 수 있습니다. 아래와 같이 InspectorClient만 초기화하면 DOM 노드에는 자동으로 메타데이터가 주입됩니다. + +```rust +#[cfg(feature = "inspector")] +use dioxus_inspector::InspectorClient; + +#[component] +pub fn App() -> Element { + #[cfg(feature = "inspector")] + use_effect(|| { + InspectorClient::new("http://127.0.0.1:41235/api/inspector/open") + .install() + .ok(); + || {} + }); + + rsx! { div { class: "app", "Hello" } } +} +``` + +Debug 모드에서 RSX 매크로가 모든 노드에 `data-inspector` 속성을 자동으로 부여합니다. + +## ✅ CI / 검증 방법 + +Inspector가 Dioxus fork 내부에 포함되어 있으므로, CI에서 다음 명령들을 통해 회귀를 막을 수 있습니다. + +1. **FMT & Clippy** + ```bash + cargo fmt --workspace -- packages/rsx/src/element.rs packages/inspector packages/inspector-macros + cargo clippy -p dioxus-inspector -p dioxus-inspector-macros --all-features -- -D warnings + ``` + +2. **WASM 빌드 검사** (브라우저 클라이언트 확인) + ```bash + cargo check -p dioxus-inspector --features client --target wasm32-unknown-unknown + ``` + +3. **Downstream Smoke Test** (예: POS-agent) + ```bash + cd apps/metacity-server + cargo check --features inspector + # or run dx serve in CI with xvfb if 통합 테스트가 필요 + ``` + +4. **Inspector Server Lint (선택)** + ```bash + npm run lint -- scripts/inspector-server.js + ``` + +이 검증 절차를 CI 파이프라인에 넣으면 Inspector 관련 변경이 들어와도 안정적으로 동작하는지 빠르게 확인할 수 있습니다. + +## 📄 라이선스 + +MIT OR Apache-2.0 diff --git a/packages/inspector/README.md b/packages/inspector/README.md new file mode 100644 index 0000000000..ee879a9d3f --- /dev/null +++ b/packages/inspector/README.md @@ -0,0 +1,218 @@ +# Dioxus Inspector + +English • [한국어](./README.ko.md) • [简体中文](./README.zh.md) + +Click any rendered element (Cmd/Ctrl + Shift + Click) and jump straight to the original Rust source line in your IDE. + +## 🚀 빠른 시작 + +### 1. Add the dependency + +```toml +# Cargo.toml +[dependencies] +dioxus-inspector = { path = "../../crates/dioxus/packages/inspector", features = ["client"] } + +[features] +inspector = ["dioxus-inspector"] +``` + +### 2. Initialize the client in your component + +```rust +use dioxus::prelude::*; +use dioxus_inspector::InspectorClient; + +#[component] +pub fn App() -> Element { + #[cfg(feature = "inspector")] + { + use_effect(|| { + if let Err(err) = InspectorClient::new("http://127.0.0.1:41235").install() { + tracing::warn!(?err, "Inspector client failed to initialize"); + } + || {} + }); + } + + rsx! { + div { + class: "app", + "Hello World" + } + } +} +``` + +### 3. Run the inspector server + your app + +```bash +# Terminal 1: Inspector Server +npm run dev:inspector + +# Terminal 2: Dioxus App (Web) +cd apps/metacity-server +dx serve --features inspector + +# (선택) Desktop/Tauri +cargo tauri dev --features inspector +``` + +### 4. Use it + +1. Open your app in a browser (web or desktop) +2. Hold **Cmd/Ctrl + Shift** and click the element you want +3. The Inspector server spawns your IDE (`code`, `cursor`, `windsurf`, JetBrains, …) + +## 📝 상세 사용법 + +### DOM 메타데이터는 자동 삽입 + +When you run a **debug build** with the `inspector` feature, the patched `rsx!` macro automatically injects a `data-inspector` attribute into every DOM node (file, line, column, tag). You no longer need to annotate elements manually. + +### 조건부 컴파일 + +- **Debug 빌드**: Inspector 활성화 +- **Release 빌드**: 자동으로 제거 (성능 영향 0) + +```bash +# Debug (inspector 포함) +dx serve --features inspector + +# Release (inspector 제외) +dx build --release +``` + +## 🎯 Supported IDEs + +- VSCode / Code Insiders +- Cursor +- Windsurf +- WebStorm / IntelliJ / Fleet (JetBrains family) +- Any IDE that exposes a `--goto file:line[:column]` CLI (you can customize the command) + +The Node inspector server auto-detects IDEs using `EDITOR`, `TERM_PROGRAM`, running processes, or CLI availability (`which`/`where`). Adjust `scripts/inspector-server.js` if you need a custom detection order. + +## 🔧 설정 / 커스터마이징 + +### 다른 포트 사용 + +```rust +const INSPECTOR_ENDPOINT: &str = "http://127.0.0.1:8888"; + +InspectorClient::new(INSPECTOR_ENDPOINT).install() +``` + +Server도 동일한 포트로: +```javascript +// scripts/inspector-server.js +const PORT = 8888; +``` + +### 커스텀 단축키 + +```rust +use dioxus_inspector::client::ClickModifier; + +let client = InspectorClient::new(endpoint) + .with_modifier(ClickModifier { + meta: false, // Cmd/Ctrl 불필요 + shift: true, // Shift만 필요 + }); +``` + +## 🐛 Troubleshooting + +### 클릭해도 반응 없음 +```bash +# 1. Inspector server 실행 중인지 확인 +npm run dev:inspector + +# 2. 브라우저 콘솔 확인 +# "Inspector client installed" 메시지 있어야 함 +``` + +### IDE doesn't open +```bash +# 1. Server 로그 확인 +[Inspector] Opening: code --goto /path/to/file.rs:42:1 + +# 2. IDE CLI 설치 확인 +which windsurf # 또는 code, cursor + +# 3. 수동으로 테스트 +windsurf --goto /path/to/file.rs:42:1 +``` + +### CORS 에러 +→ `inspector-server.js`에 이미 CORS 설정됨. 포트 확인. + +## 📚 Architecture + +``` +Browser (WASM) Dev Server (Node.js) IDE + │ │ │ + │ Cmd/Ctrl+Shift+Click │ │ + │──────────────────────────────>│ │ + │ │ │ + │ POST /api/inspector/open │ spawn('code'/'cursor') │ + │ { file, line, column } │─────────────────────────>│ + │ │ │ + │ ← 200 OK │ │ + │ │ File opens! +``` + +## 🎨 Example + +See `apps/metacity-server/src/components/app.rs` for a full integration. A minimal snippet looks like: + +```rust +#[cfg(feature = "inspector")] +use dioxus_inspector::InspectorClient; + +#[component] +pub fn App() -> Element { + #[cfg(feature = "inspector")] + use_effect(|| { + InspectorClient::new("http://127.0.0.1:41235/api/inspector/open") + .install() + .ok(); + || {} + }); + + rsx! { div { class: "app", "Hello" } } +} +``` + +In debug builds the patched `rsx!` macro injects `data-inspector` automatically. + +## ✅ CI recommendations + +1. **FMT & Clippy** + ```bash + cargo fmt --workspace -- packages/rsx/src/element.rs packages/inspector packages/inspector-macros + cargo clippy -p dioxus-inspector -p dioxus-inspector-macros --all-features -- -D warnings + ``` + +2. **WASM 빌드 검사** (브라우저 클라이언트 확인) + ```bash + cargo check -p dioxus-inspector --features client --target wasm32-unknown-unknown + ``` + +3. **Downstream smoke test** (e.g., POS-agent) + ```bash + cd apps/metacity-server + cargo check --features inspector + # or run dx serve in CI with xvfb if 통합 테스트가 필요 + ``` + +4. **Inspector server lint (optional)** + ```bash + npm run lint -- scripts/inspector-server.js + ``` + +Add these steps to your CI pipeline to catch regressions in both the core RSX patch and the inspector runtime. + +## 📄 라이선스 + +MIT OR Apache-2.0 diff --git a/packages/inspector/README.zh.md b/packages/inspector/README.zh.md new file mode 100644 index 0000000000..96901f4cc7 --- /dev/null +++ b/packages/inspector/README.zh.md @@ -0,0 +1,215 @@ +# Dioxus Inspector / 迪氧索斯代码探查器 + +[English](./README.md) • [한국어](./README.ko.md) • 简体中文 + +按住 **Cmd/Ctrl + Shift** 点击任意渲染的元素,即可在 IDE 中直接跳转到对应的 Rust 源码行。 + +## 🚀 快速开始 + +### 1. 添加依赖 + +```toml +# Cargo.toml +[dependencies] +dioxus-inspector = { path = "../../crates/dioxus/packages/inspector", features = ["client"] } + +[features] +inspector = ["dioxus-inspector"] +``` + +### 2. 在组件中初始化客户端 + +```rust +use dioxus::prelude::*; +use dioxus_inspector::InspectorClient; + +#[component] +pub fn App() -> Element { + #[cfg(feature = "inspector")] + { + use_effect(|| { + if let Err(err) = InspectorClient::new("http://127.0.0.1:41235").install() { + tracing::warn!(?err, "Inspector client failed to initialize"); + } + || {} + }); + } + + rsx! { + div { + class: "app", + "Hello World" + } + } +} +``` + +### 3. 启动 Inspector Server 与应用 + +```bash +# Terminal 1: Inspector Server +npm run dev:inspector + +# Terminal 2: Dioxus App (Web) +cd apps/metacity-server +dx serve --features inspector + +# (可选)Desktop/Tauri +cargo tauri dev --features inspector +``` + +### 4. 使用 + +1. 在浏览器(Web 或 Desktop WebView)中打开应用 +2. 按住 **Cmd/Ctrl + Shift** 点击想要查看的元素 +3. Inspector server 会自动调用 IDE CLI(`code`/`cursor`/`windsurf`/JetBrains 等) + +## 📝 额外说明 + +### DOM 元数据自动注入 + +只要以 `--features inspector` 运行 **Debug 构建**,经过补丁的 `rsx!` 宏就会为每个 DOM 节点自动添加 `data-inspector`(包含文件、行、列、标签信息)。无需手动编写任何属性。 + +### 条件编译 + +- **Debug**:Inspector 启用 +- **Release**:Inspector 自动移除,对性能无影响 + +```bash +# Debug(含 inspector) +dx serve --features inspector + +# Release(不含 inspector) +dx build --release +``` + +## 🎯 支持的 IDE + +- VSCode / Code Insiders +- Cursor +- Windsurf +- WebStorm / IntelliJ / Fleet(JetBrains 家族) +- 任何提供 `--goto file:line[:column]` CLI 的 IDE(可自定义命令) + +Node 版 Inspector Server 会依据 `EDITOR`、`TERM_PROGRAM`、正在运行的进程或 CLI 是否存在(`which`/`where`)来自动识别 IDE。如需自定义顺序,可修改 `scripts/inspector-server.js`。 + +## 🔧 配置 / 自定义 + +### 修改端口 + +```rust +const INSPECTOR_ENDPOINT: &str = "http://127.0.0.1:8888"; +InspectorClient::new(INSPECTOR_ENDPOINT).install() +``` + +对应地,Node 服务器中: +```javascript +// scripts/inspector-server.js +const PORT = 8888; +``` + +### 自定义快捷键 + +```rust +use dioxus_inspector::client::ClickModifier; + +let client = InspectorClient::new(endpoint) + .with_modifier(ClickModifier { + meta: false, // 不需要 Cmd/Ctrl + shift: true, // 仅 Shift + }); +``` + +## 🐛 常见问题 + +### 点击无响应 +```bash +# 1. 检查 Inspector server 是否在运行 +npm run dev:inspector + +# 2. 打开浏览器控制台,确认看到 "Inspector client installed" +``` + +### IDE 没有打开 +```bash +# 1. 查看 server 日志 +[Inspector] Opening: code --goto /path/to/file.rs:42:1 + +# 2. 检查 IDE CLI 是否已安装 +which code # 或 cursor、windsurf + +# 3. 手动执行一次 +windsurf --goto /path/to/file.rs:42:1 +``` + +### CORS 报错 +➡ `scripts/inspector-server.js` 默认开启了 CORS,确认端口一致即可。 + +## 📚 架构 + +``` +Browser (WASM) Dev Server (Node.js) IDE + │ │ │ + │ Cmd/Ctrl+Shift+Click │ │ + │──────────────────────────────>│ │ + │ │ │ + │ POST /api/inspector/open │ spawn('code'/'cursor') │ + │ { file, line, column } │─────────────────────────>│ + │ │ │ + │ ← 200 OK │ │ + │ │ File opens! +``` + +## 🎨 示例 + +参考 `apps/metacity-server/src/components/app.rs`。简化示例: + +```rust +#[cfg(feature = "inspector")] +use dioxus_inspector::InspectorClient; + +#[component] +pub fn App() -> Element { + #[cfg(feature = "inspector")] + use_effect(|| { + InspectorClient::new("http://127.0.0.1:41235/api/inspector/open") + .install() + .ok(); + || {} + }); + + rsx! { div { class: "app", "Hello" } } +} +``` + +在 Debug 构建中,`rsx!` 会自动注入 `data-inspector`。 + +## ✅ CI 建议 + +1. **格式化 + Clippy** + ```bash + cargo fmt --workspace -- packages/rsx/src/element.rs packages/inspector packages/inspector-macros + cargo clippy -p dioxus-inspector -p dioxus-inspector-macros --all-features -- -D warnings + ``` + +2. **WASM 构建检查**(验证浏览器客户端) + ```bash + cargo check -p dioxus-inspector --features client --target wasm32-unknown-unknown + ``` + +3. **下游项目冒烟测试**(如 POS-agent) + ```bash + cd apps/metacity-server + cargo check --features inspector + ``` + +4. **Inspector Server Lint(可选)** + ```bash + npm run lint -- scripts/inspector-server.js + ``` + +在 CI 中加入这些命令即可防止 RSX 补丁或 Inspector runtime 回归。 + +## 📄 License + +MIT OR Apache-2.0 diff --git a/packages/inspector/src/client.rs b/packages/inspector/src/client.rs new file mode 100644 index 0000000000..f87cbb185b --- /dev/null +++ b/packages/inspector/src/client.rs @@ -0,0 +1,273 @@ +#![cfg(feature = "client")] + +use serde::{Deserialize, Serialize}; +use wasm_bindgen::{JsCast, closure::Closure, prelude::*}; +use wasm_bindgen_futures::{JsFuture, spawn_local}; +use web_sys::{ + Document, DomRect, Element, HtmlElement, KeyboardEvent, MouseEvent, Request, RequestInit, + RequestMode, Response, window, +}; + +const DATA_ATTRIBUTE: &str = "data-inspector"; + +/// Errors that can be raised while installing the inspector client. +#[derive(Debug)] +pub enum InspectorClientError { + WindowUnavailable, + DocumentUnavailable, + ListenerRegistrationFailed(String), +} + +/// Keyboard modifiers that must be pressed to trigger inspection. +#[derive(Debug, Copy, Clone)] +pub struct ClickModifier { + pub meta: bool, + pub shift: bool, +} + +impl Default for ClickModifier { + fn default() -> Self { + Self { + meta: true, + shift: true, + } + } +} + +impl ClickModifier { + fn matches(self, event: &MouseEvent) -> bool { + (!self.meta || event.meta_key() || event.ctrl_key()) && (!self.shift || event.shift_key()) + } +} + +/// Browser-side entry point that hooks click events. +#[derive(Debug, Clone)] +pub struct InspectorClient { + endpoint: String, + modifier: ClickModifier, +} + +impl InspectorClient { + /// Creates a new inspector client. + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + modifier: ClickModifier::default(), + } + } + + /// Overrides the keyboard modifiers. + pub fn with_modifier(mut self, modifier: ClickModifier) -> Self { + self.modifier = modifier; + self + } + + /// Installs click handlers on the document. + pub fn install(&self) -> Result<(), InspectorClientError> { + let window = window().ok_or(InspectorClientError::WindowUnavailable)?; + let document = window + .document() + .ok_or(InspectorClientError::DocumentUnavailable)?; + + let modifiers = self.modifier; + let endpoint = self.endpoint.clone(); + + let click_handler = + Closure::::wrap(Box::new(move |event: MouseEvent| { + if !modifiers.matches(&event) { + return; + } + + let target = match event.target().and_then(|t| t.dyn_into::().ok()) { + Some(element) => element, + None => return, + }; + + if let Some(marker) = find_marker(target) { + match serde_json::from_str::(&marker) { + Ok(payload) => dispatch(endpoint.clone(), payload), + Err(err) => warn(&format!("Failed to parse inspector payload: {err}")), + } + } + })); + + document + .add_event_listener_with_callback("click", click_handler.as_ref().unchecked_ref()) + .map_err(|err| InspectorClientError::ListenerRegistrationFailed(format!("{err:?}")))?; + click_handler.forget(); + + if let Some(overlay) = HighlightOverlay::create(&document) { + install_highlight_listeners(&document, modifiers, overlay)?; + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DomMetadata { + file: String, + line: u32, + column: u32, + #[serde(default)] + tag: Option, +} + +fn find_marker(mut element: Element) -> Option { + loop { + if let Some(attr) = element.get_attribute(DATA_ATTRIBUTE) { + return Some(attr); + } + + match element.parent_element() { + Some(parent) => element = parent, + None => return None, + } + } +} + +fn dispatch(endpoint: String, payload: DomMetadata) { + log(&format!( + "Inspector click captured for {} @{}:{} -> {}", + payload.tag.as_deref().unwrap_or("node"), + payload.file, + payload.line, + endpoint + )); + + spawn_local(async move { + if let Err(err) = send_to_server(&endpoint, &payload).await { + warn(&format!("Failed to send inspector event: {err}")); + } + }); +} + +async fn send_to_server(endpoint: &str, payload: &DomMetadata) -> Result<(), String> { + let window = window().ok_or("No window available")?; + + let opts = RequestInit::new(); + opts.set_method("POST"); + opts.set_mode(RequestMode::Cors); + + let body = serde_json::to_string(payload).map_err(|e| e.to_string())?; + let body_value = JsValue::from_str(&body); + opts.set_body(&body_value); + + let request = + Request::new_with_str_and_init(endpoint, &opts).map_err(|e| format!("{:?}", e))?; + + request + .headers() + .set("Content-Type", "application/json") + .map_err(|e| format!("{:?}", e))?; + + let resp_value = JsFuture::from(window.fetch_with_request(&request)) + .await + .map_err(|e| format!("{:?}", e))?; + + let _resp: Response = resp_value.dyn_into().map_err(|_| "Response mismatch")?; + + log("Inspector event sent successfully"); + Ok(()) +} + +fn log(message: &str) { + web_sys::console::log_1(&JsValue::from_str(message)); +} + +fn warn(message: &str) { + web_sys::console::warn_1(&JsValue::from_str(message)); +} + +#[derive(Clone)] +struct HighlightOverlay { + element: HtmlElement, +} + +impl HighlightOverlay { + fn create(document: &Document) -> Option { + let body = document.body()?; + let element: HtmlElement = document.create_element("div").ok()?.dyn_into().ok()?; + let style = element.style(); + let _ = style.set_property("position", "fixed"); + let _ = style.set_property("border", "2px solid #38bdf8"); + let _ = style.set_property("border-radius", "6px"); + let _ = style.set_property("box-shadow", "0 0 0 2px rgba(56,189,248,0.35)"); + let _ = style.set_property("background", "rgba(56,189,248,0.15)"); + let _ = style.set_property("pointer-events", "none"); + let _ = style.set_property("z-index", "2147483647"); + let _ = style.set_property("display", "none"); + body.append_child(&element).ok()?; + Some(Self { element }) + } + + fn show(&self, element: &Element) { + let rect = element.get_bounding_client_rect(); + self.apply_rect(&rect); + } + + fn apply_rect(&self, rect: &DomRect) { + if rect.width() <= 0.0 && rect.height() <= 0.0 { + self.hide(); + return; + } + + let style = self.element.style(); + let _ = style.set_property("display", "block"); + let _ = style.set_property("left", &format!("{}px", rect.left())); + let _ = style.set_property("top", &format!("{}px", rect.top())); + let _ = style.set_property("width", &format!("{}px", rect.width())); + let _ = style.set_property("height", &format!("{}px", rect.height())); + } + + fn hide(&self) { + let _ = self.element.style().set_property("display", "none"); + } +} + +fn install_highlight_listeners( + document: &Document, + modifiers: ClickModifier, + overlay: HighlightOverlay, +) -> Result<(), InspectorClientError> { + let move_overlay = overlay.clone(); + let highlight_handler = + Closure::::wrap(Box::new(move |event: MouseEvent| { + if !modifiers.matches(&event) { + move_overlay.hide(); + return; + } + + let target = match event.target().and_then(|t| t.dyn_into::().ok()) { + Some(element) => element, + None => { + move_overlay.hide(); + return; + } + }; + + if find_marker(target.clone()).is_some() { + move_overlay.show(&target); + } else { + move_overlay.hide(); + } + })); + + document + .add_event_listener_with_callback("mousemove", highlight_handler.as_ref().unchecked_ref()) + .map_err(|err| InspectorClientError::ListenerRegistrationFailed(format!("{err:?}")))?; + highlight_handler.forget(); + + let key_overlay = overlay.clone(); + let keyup_handler = + Closure::::wrap(Box::new(move |_event: KeyboardEvent| { + key_overlay.hide(); + })); + + document + .add_event_listener_with_callback("keyup", keyup_handler.as_ref().unchecked_ref()) + .map_err(|err| InspectorClientError::ListenerRegistrationFailed(format!("{err:?}")))?; + keyup_handler.forget(); + + Ok(()) +} diff --git a/packages/inspector/src/lib.rs b/packages/inspector/src/lib.rs new file mode 100644 index 0000000000..fbdb4226b0 --- /dev/null +++ b/packages/inspector/src/lib.rs @@ -0,0 +1,22 @@ +//! Runtime support for the Dioxus inspector tooling. + +#![allow(clippy::module_name_repetitions)] + +#[cfg(feature = "client")] +pub mod client; + +#[cfg(feature = "server")] +pub mod protocol; + +pub use dioxus_inspector_macros::inspector; + +#[cfg(feature = "client")] +pub use client::InspectorClient; + +#[cfg(feature = "server")] +pub use protocol::{IdeKind, InspectorRequest}; + +/// Prelude containing the most common exports. +pub mod prelude { + pub use crate::inspector; +} diff --git a/packages/inspector/src/protocol.rs b/packages/inspector/src/protocol.rs new file mode 100644 index 0000000000..22e15cef55 --- /dev/null +++ b/packages/inspector/src/protocol.rs @@ -0,0 +1,68 @@ +#![cfg(feature = "server")] + +use serde::{Deserialize, Serialize}; + +/// Supported IDE protocol handlers. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum IdeKind { + VsCode, + Cursor, + Windsurf, + CustomScheme { scheme: String }, +} + +impl IdeKind { + /// Returns the URI scheme associated with the IDE. + pub fn scheme(&self) -> String { + match self { + IdeKind::VsCode => "vscode".to_string(), + IdeKind::Cursor => "cursor".to_string(), + IdeKind::Windsurf => "windsurf".to_string(), + IdeKind::CustomScheme { scheme } => scheme.clone(), + } + } +} + +/// Payload emitted by the browser client towards the dev server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InspectorRequest { + pub file: String, + pub line: u32, + pub column: u32, + pub ide: IdeKind, +} + +impl InspectorRequest { + /// Builds a deep-link URI for the selected IDE. + pub fn ide_uri(&self) -> String { + format!("{}://file/{}:{}", self.ide.scheme(), self.file, self.line) + } +} + +/// Minimal HTTP client that forwards requests to the inspector middleware. +#[derive(Clone)] +pub struct InspectorServerClient { + endpoint: String, + http: reqwest::Client, +} + +impl InspectorServerClient { + /// Creates a new client pointing at the given endpoint. + pub fn new(endpoint: impl Into) -> Self { + Self { + endpoint: endpoint.into(), + http: reqwest::Client::new(), + } + } + + /// Sends the inspector payload to the middleware. + pub async fn send(&self, payload: &InspectorRequest) -> Result<(), reqwest::Error> { + self.http + .post(format!("{}/api/inspector/open", self.endpoint)) + .json(payload) + .send() + .await? + .error_for_status() + .map(|_| ()) + } +} diff --git a/packages/interpreter/src/js/hash.txt b/packages/interpreter/src/js/hash.txt index 83d5db4eb3..71ff999dee 100644 --- a/packages/interpreter/src/js/hash.txt +++ b/packages/interpreter/src/js/hash.txt @@ -1 +1 @@ -[17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 8889858244860485542, 5052021921702764563, 15193138787416885137, 11339769846046015954] \ No newline at end of file +[17669692872757955279, 11420464406527728232, 3770103091118609057, 5444526391971481782, 8889858244860485542, 5332100800588299388, 15193138787416885137, 11339769846046015954] \ No newline at end of file diff --git a/packages/interpreter/src/js/native.js b/packages/interpreter/src/js/native.js index 94c211790a..0ac2cfc56a 100644 --- a/packages/interpreter/src/js/native.js +++ b/packages/interpreter/src/js/native.js @@ -1 +1 @@ -function serializeEvent(event,target){let contents={},extend=(obj)=>contents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent<"u"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent){let files=[];if(event.dataTransfer&&event.dataTransfer.files)for(let i=0;i{if(value instanceof File){let fileData={path:value.name,size:value.size,last_modified:value.lastModified,content_type:value.type};contents.push({key,file:fileData})}else contents.push({key,text:value})}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;i{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){event.preventDefault();let contents=serializeEvent(event,target),target_name=target.getAttribute("name")||"",requestData={event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles,target_name,values:contents.values};this.fetchAgainstHost("__file_dialog",requestData).then((response)=>response.json()).then((resp)=>{let formObjects=resp.values,dataTransfer=new DataTransfer;for(let formObject of formObjects)if(formObject.key==target_name&&formObject.file!=null){let file=new File([],formObject.file.path,{type:formObject.file.content_type,lastModified:formObject.file.last_modified});dataTransfer.items.add(file)}target.files=dataTransfer.files;let body={data:contents,element:target_id,bubbles:event.bubbles};contents.values=formObjects,this.sendSerializedEvent({...body,name:"input"}),this.sendSerializedEvent({...body,name:"change"})});return}}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,!0);super.initialize(root,handler)}fetchAgainstHost(path,data){let encoded_data=new TextEncoder().encode(JSON.stringify(data)),base64data=btoa(String.fromCharCode.apply(null,Array.from(encoded_data)));return fetch(`${this.baseUri}/${path}`,{method:"GET",headers:{"x-dioxus-data":base64data}})}sendIpcMessage(method,params={}){let body=JSON.stringify({method,params});this.ipc.postMessage(body)}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;icontents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent!=="undefined"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent){let files=[];if(event.dataTransfer&&event.dataTransfer.files)for(let i=0;i{if(value instanceof File){let fileData={path:value.name,size:value.size,last_modified:value.lastModified,content_type:value.type};contents.push({key,file:fileData})}else contents.push({key,text:value})}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;i{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){event.preventDefault();let contents=serializeEvent(event,target),target_name=target.getAttribute("name")||"",requestData={event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles,target_name,values:contents.values};this.fetchAgainstHost("__file_dialog",requestData).then((response)=>response.json()).then((resp)=>{let formObjects=resp.values,dataTransfer=new DataTransfer;for(let formObject of formObjects)if(formObject.key==target_name&&formObject.file!=null){let file=new File([],formObject.file.path,{type:formObject.file.content_type,lastModified:formObject.file.last_modified});dataTransfer.items.add(file)}target.files=dataTransfer.files;let body={data:contents,element:target_id,bubbles:event.bubbles};contents.values=formObjects,this.sendSerializedEvent({...body,name:"input"}),this.sendSerializedEvent({...body,name:"change"})});return}}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,!0);super.initialize(root,handler)}fetchAgainstHost(path,data){let encoded_data=new TextEncoder().encode(JSON.stringify(data)),base64data=btoa(String.fromCharCode.apply(null,Array.from(encoded_data)));return fetch(`${this.baseUri}/${path}`,{method:"GET",headers:{"x-dioxus-data":base64data}})}sendIpcMessage(method,params={}){let body=JSON.stringify({method,params});this.ipc.postMessage(body)}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;iString(arg));if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"log",messages}}));log.apply(console,args)},console.info=function(...args){let messages=args.map((arg)=>String(arg));if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"info",messages}}));info.apply(console,args)},console.warn=function(...args){let messages=args.map((arg)=>String(arg));if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"warn",messages}}));warn.apply(console,args)},console.error=function(...args){let messages=args.map((arg)=>String(arg));if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"error",messages}}));error.apply(console,args)},console.debug=function(...args){let messages=args.map((arg)=>String(arg));if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({Log:{level:"debug",messages}}));debug.apply(console,args)}}export{monkeyPatchConsole}; diff --git a/packages/interpreter/src/ts/patch_console.ts b/packages/interpreter/src/ts/patch_console.ts index bcdbef9383..f004ffe6c8 100644 --- a/packages/interpreter/src/ts/patch_console.ts +++ b/packages/interpreter/src/ts/patch_console.ts @@ -8,45 +8,50 @@ export function monkeyPatchConsole(ws: WebSocket) { const debug = console.debug; console.log = function (...args: any[]) { + const messages = args.map((arg) => String(arg)); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - "Log": { level: "log", messages: args } + "Log": { level: "log", messages } })); } log.apply(console, args); }; console.info = function (...args: any[]) { + const messages = args.map((arg) => String(arg)); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - "Log": { level: "info", messages: args } + "Log": { level: "info", messages } })); } info.apply(console, args); }; console.warn = function (...args: any[]) { + const messages = args.map((arg) => String(arg)); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - "Log": { level: "warn", messages: args } + "Log": { level: "warn", messages } })); } warn.apply(console, args); }; console.error = function (...args: any[]) { + const messages = args.map((arg) => String(arg)); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - "Log": { level: "error", messages: args } + "Log": { level: "error", messages } })); } error.apply(console, args); }; console.debug = function (...args: any[]) { + const messages = args.map((arg) => String(arg)); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ - "Log": { level: "debug", messages: args } + "Log": { level: "debug", messages } })); } debug.apply(console, args); diff --git a/packages/rsx/src/element.rs b/packages/rsx/src/element.rs index 8dd9edb567..5f62d7eb4a 100644 --- a/packages/rsx/src/element.rs +++ b/packages/rsx/src/element.rs @@ -1,14 +1,14 @@ use crate::innerlude::*; use proc_macro2::{Span, TokenStream as TokenStream2}; use proc_macro2_diagnostics::SpanDiagnosticExt; -use quote::{quote, ToTokens, TokenStreamExt}; +use quote::{ToTokens, TokenStreamExt, quote, quote_spanned}; use std::fmt::{Display, Formatter}; use syn::{ + Ident, LitStr, Result, Token, parse::{Parse, ParseStream}, punctuated::Punctuated, spanned::Spanned, token::Brace, - Ident, LitStr, Result, Token, }; /// Parse the VNode::Element type @@ -116,7 +116,7 @@ impl ToTokens for Element { ElementName::Custom(_) => quote! { None }, }; - let static_attrs = el + let mut static_attrs = el .merged_attributes .iter() .map(|attr| { @@ -158,6 +158,11 @@ impl ToTokens for Element { }) .collect::>(); + #[cfg(debug_assertions)] + if let Some(inspector_attr) = inspector_attribute(el) { + static_attrs.push(inspector_attr); + } + // Render either the child let children = el.children.iter().map(|c| match c { BodyNode::Element(el) => quote! { #el }, @@ -399,6 +404,42 @@ impl Display for ElementName { } } +#[cfg(debug_assertions)] +fn inspector_attribute(el: &Element) -> Option { + use syn::spanned::Spanned; + + let span = el.name.span(); + let tag = el.name.to_string(); + let tag_literal = LitStr::new(&tag, Span::call_site()); + + Some(quote_spanned! { span => + { + const __INSPECTOR_DIR_TMP: &str = dioxus_core::const_format::str_replace!(env!("CARGO_MANIFEST_DIR"), "\\\\", "/"); + const __INSPECTOR_DIR: &str = dioxus_core::const_format::str_replace!(__INSPECTOR_DIR_TMP, '\\', "/"); + const __INSPECTOR_FILE_TMP: &str = dioxus_core::const_format::str_replace!(file!(), "\\\\", "/"); + const __INSPECTOR_FILE: &str = dioxus_core::const_format::str_replace!(__INSPECTOR_FILE_TMP, '\\', "/"); + const __INSPECTOR_PATH: &str = dioxus_core::const_format::concatcp!(__INSPECTOR_DIR, "/", __INSPECTOR_FILE); + + dioxus_core::TemplateAttribute::Static { + name: "data-inspector", + namespace: None, + value: dioxus_core::const_format::formatcp!( + "{{\"file\":\"{}\",\"line\":{},\"column\":{},\"tag\":\"{}\"}}", + __INSPECTOR_PATH, + line!(), + column!(), + #tag_literal + ), + } + } + }) +} + +#[cfg(not(debug_assertions))] +fn inspector_attribute(_el: &Element) -> Option { + None +} + #[cfg(test)] mod tests { use super::*; @@ -704,11 +745,13 @@ mod tests { assert_eq!(parsed.diagnostics.len(), 3); // style should not generate a diagnostic - assert!(!parsed - .diagnostics - .diagnostics - .into_iter() - .any(|f| f.emit_as_item_tokens().to_string().contains("style"))); + assert!( + !parsed + .diagnostics + .diagnostics + .into_iter() + .any(|f| f.emit_as_item_tokens().to_string().contains("style")) + ); } #[test] diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 3cd295dc2f..b7c697cd40 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -18,6 +18,7 @@ dioxus-history = { workspace = true } dioxus-document = { workspace = true } dioxus-devtools = { workspace = true } dioxus-signals = { workspace = true } +dioxus-inspector = { workspace = true, features = ["client"], optional = true } dioxus-interpreter-js = { workspace = true, features = [ "minimal_bindings", "webonly", @@ -94,6 +95,7 @@ lazy-js-bundle = { workspace = true } [features] default = ["mounted", "devtools", "document"] +inspector = ["dep:dioxus-inspector"] hydrate = ["web-sys/Comment", "dep:serde", "dep:dioxus-fullstack-core"] mounted = [ "web-sys/Element", diff --git a/packages/web/src/lib.rs b/packages/web/src/lib.rs index af275c0f22..8f9ca67da1 100644 --- a/packages/web/src/lib.rs +++ b/packages/web/src/lib.rs @@ -54,6 +54,16 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! { #[cfg(all(feature = "devtools", debug_assertions))] let mut hotreload_rx = devtools::init(&web_config); + // Auto-initialize inspector in debug builds + #[cfg(all(debug_assertions, feature = "inspector"))] + { + if let Err(err) = dioxus_inspector::InspectorClient::new("http://127.0.0.1:41235/api/inspector/open").install() { + web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!( + "Inspector client failed to initialize: {:?}", err + ))); + } + } + #[cfg(feature = "document")] if let Some(history) = web_config.history.clone() { virtual_dom.in_scope(ScopeId::ROOT, || dioxus_core::provide_context(history));