Add initial element inspector for Zed development (#31315)
Open inspector with `dev: toggle inspector` from command palette or `cmd-alt-i` on mac or `ctrl-alt-i` on linux. https://github.com/user-attachments/assets/54c43034-d40b-414e-ba9b-190bed2e6d2f * Picking of elements via the mouse, with scroll wheel to inspect occluded elements. * Temporary manipulation of the selected element. * Layout info and JSON-based style manipulation for `Div`. * Navigation to code that constructed the element. Big thanks to @as-cii and @maxdeviant for sorting out how to implement the core of an inspector. Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra <me@as-cii.com> Co-authored-by: Marshall Bowers <git@maxdeviant.com> Co-authored-by: Federico Dionisi <code@fdionisi.me>
This commit is contained in:
parent
685933b5c8
commit
ab59982bf7
74 changed files with 2631 additions and 406 deletions
28
crates/inspector_ui/Cargo.toml
Normal file
28
crates/inspector_ui/Cargo.toml
Normal file
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "inspector_ui"
|
||||
version = "0.1.0"
|
||||
publish.workspace = true
|
||||
edition.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/inspector_ui.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
command_palette_hooks.workspace = true
|
||||
editor.workspace = true
|
||||
gpui.workspace = true
|
||||
language.workspace = true
|
||||
project.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json_lenient.workspace = true
|
||||
theme.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
workspace-hack.workspace = true
|
||||
zed_actions.workspace = true
|
1
crates/inspector_ui/LICENSE-GPL
Symbolic link
1
crates/inspector_ui/LICENSE-GPL
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../LICENSE-GPL
|
84
crates/inspector_ui/README.md
Normal file
84
crates/inspector_ui/README.md
Normal file
|
@ -0,0 +1,84 @@
|
|||
# Inspector
|
||||
|
||||
This is a tool for inspecting and manipulating rendered elements in Zed. It is
|
||||
only available in debug builds. Use the `dev::ToggleInspector` action to toggle
|
||||
inspector mode and click on UI elements to inspect them.
|
||||
|
||||
# Current features
|
||||
|
||||
* Picking of elements via the mouse, with scroll wheel to inspect occluded elements.
|
||||
|
||||
* Temporary manipulation of the selected element.
|
||||
|
||||
* Layout info and JSON-based style manipulation for `Div`.
|
||||
|
||||
* Navigation to code that constructed the element.
|
||||
|
||||
# Known bugs
|
||||
|
||||
* The style inspector buffer will leak memory over time due to building up
|
||||
history on each change of inspected element. Instead of using `Project` to
|
||||
create it, should just directly build the `Buffer` and `File` each time the inspected element changes.
|
||||
|
||||
# Future features
|
||||
|
||||
* Info and manipulation of element types other than `Div`.
|
||||
|
||||
* Ability to highlight current element after it's been picked.
|
||||
|
||||
* Indicate when the picked element has disappeared.
|
||||
|
||||
* Hierarchy view?
|
||||
|
||||
## Better manipulation than JSON
|
||||
|
||||
The current approach is not easy to move back to the code. Possibilities:
|
||||
|
||||
* Editable list of style attributes to apply.
|
||||
|
||||
* Rust buffer of code that does a very lenient parse to get the style attributes. Some options:
|
||||
|
||||
- Take all the identifier-like tokens and use them if they are the name of an attribute. A custom completion provider in a buffer could be used.
|
||||
|
||||
- Use TreeSitter to parse out the fluent style method chain. With this approach the buffer could even be the actual code file. Tricky part of this is LSP - ideally the LSP already being used by the developer's Zed would be used.
|
||||
|
||||
## Source locations
|
||||
|
||||
* Mode to navigate to source code on every element change while picking.
|
||||
|
||||
* Tracking of more source locations - currently the source location is often in a ui compoenent. Ideally this would have a way for the components to indicate that they are probably not the source location the user is looking for.
|
||||
|
||||
## Persistent modification
|
||||
|
||||
Currently, element modifications disappear when picker mode is started. Handling this well is tricky. Potential features:
|
||||
|
||||
* Support modifying multiple elements at once. This requires a way to specify which elements are modified - possibly wildcards in a match of the `InspectorElementId` path. This might default to ignoring all numeric parts and just matching on the names.
|
||||
|
||||
* Show a list of active modifications in the UI.
|
||||
|
||||
* Support for modifications being partial overrides instead of snapshots. A trickiness here is that multiple modifications may apply to the same element.
|
||||
|
||||
* The code should probably distinguish the data that is provided by the element and the modifications from the inspector. Currently these are conflated in element states.
|
||||
|
||||
# Code cleanups
|
||||
|
||||
## Remove special side pane rendering
|
||||
|
||||
Currently the inspector has special rendering in the UI, but maybe it could just be a workspace item.
|
||||
|
||||
## Pull more inspector logic out of GPUI
|
||||
|
||||
Currently `crates/gpui/inspector.rs` and `crates/inspector_ui/inspector.rs` are quite entangled. It seems cleaner to pull as much logic a possible out of GPUI.
|
||||
|
||||
## Cleaner lifecycle for inspector state viewers / editors
|
||||
|
||||
Currently element state inspectors are just called on render. Ideally instead they would be implementors of some trait like:
|
||||
|
||||
```
|
||||
trait StateInspector: Render {
|
||||
fn new(cx: &mut App) -> Task<Self>;
|
||||
fn element_changed(inspector_id: &InspectorElementId, window: &mut Window, cx: &mut App);
|
||||
}
|
||||
```
|
||||
|
||||
See `div_inspector.rs` - it needs to initialize itself, keep track of its own loading state, and keep track of the last inspected ID in its render function.
|
20
crates/inspector_ui/build.rs
Normal file
20
crates/inspector_ui/build.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
fn main() {
|
||||
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let mut path = std::path::PathBuf::from(&cargo_manifest_dir);
|
||||
|
||||
if path.file_name().as_ref().and_then(|name| name.to_str()) != Some("inspector_ui") {
|
||||
panic!(
|
||||
"expected CARGO_MANIFEST_DIR to end with crates/inspector_ui, but got {cargo_manifest_dir}"
|
||||
);
|
||||
}
|
||||
path.pop();
|
||||
|
||||
if path.file_name().as_ref().and_then(|name| name.to_str()) != Some("crates") {
|
||||
panic!(
|
||||
"expected CARGO_MANIFEST_DIR to end with crates/inspector_ui, but got {cargo_manifest_dir}"
|
||||
);
|
||||
}
|
||||
path.pop();
|
||||
|
||||
println!("cargo:rustc-env=ZED_REPO_DIR={}", path.display());
|
||||
}
|
223
crates/inspector_ui/src/div_inspector.rs
Normal file
223
crates/inspector_ui/src/div_inspector.rs
Normal file
|
@ -0,0 +1,223 @@
|
|||
use anyhow::Result;
|
||||
use editor::{Editor, EditorEvent, EditorMode, MultiBuffer};
|
||||
use gpui::{
|
||||
AsyncWindowContext, DivInspectorState, Entity, InspectorElementId, IntoElement, WeakEntity,
|
||||
Window,
|
||||
};
|
||||
use language::Buffer;
|
||||
use language::language_settings::SoftWrap;
|
||||
use project::{Project, ProjectPath};
|
||||
use std::path::Path;
|
||||
use ui::{Label, LabelSize, Tooltip, prelude::*, v_flex};
|
||||
|
||||
/// Path used for unsaved buffer that contains style json. To support the json language server, this
|
||||
/// matches the name used in the generated schemas.
|
||||
const ZED_INSPECTOR_STYLE_PATH: &str = "/zed-inspector-style.json";
|
||||
|
||||
pub(crate) struct DivInspector {
|
||||
project: Entity<Project>,
|
||||
inspector_id: Option<InspectorElementId>,
|
||||
state: Option<DivInspectorState>,
|
||||
style_buffer: Option<Entity<Buffer>>,
|
||||
style_editor: Option<Entity<Editor>>,
|
||||
last_error: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl DivInspector {
|
||||
pub fn new(
|
||||
project: Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> DivInspector {
|
||||
// Open the buffer once, so it can then be used for each editor.
|
||||
cx.spawn_in(window, {
|
||||
let project = project.clone();
|
||||
async move |this, cx| Self::open_style_buffer(project, this, cx).await
|
||||
})
|
||||
.detach();
|
||||
|
||||
DivInspector {
|
||||
project,
|
||||
inspector_id: None,
|
||||
state: None,
|
||||
style_buffer: None,
|
||||
style_editor: None,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_style_buffer(
|
||||
project: Entity<Project>,
|
||||
this: WeakEntity<DivInspector>,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) -> Result<()> {
|
||||
let worktree = project
|
||||
.update(cx, |project, cx| {
|
||||
project.create_worktree(ZED_INSPECTOR_STYLE_PATH, false, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
let project_path = worktree.read_with(cx, |worktree, _cx| ProjectPath {
|
||||
worktree_id: worktree.id(),
|
||||
path: Path::new("").into(),
|
||||
})?;
|
||||
|
||||
let style_buffer = project
|
||||
.update(cx, |project, cx| project.open_path(project_path, cx))?
|
||||
.await?
|
||||
.1;
|
||||
|
||||
project.update(cx, |project, cx| {
|
||||
project.register_buffer_with_language_servers(&style_buffer, cx)
|
||||
})?;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.style_buffer = Some(style_buffer);
|
||||
if let Some(id) = this.inspector_id.clone() {
|
||||
let state =
|
||||
window.with_inspector_state(Some(&id), cx, |state, _window| state.clone());
|
||||
if let Some(state) = state {
|
||||
this.update_inspected_element(&id, state, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_inspected_element(
|
||||
&mut self,
|
||||
id: &InspectorElementId,
|
||||
state: DivInspectorState,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let base_style_json = serde_json::to_string_pretty(&state.base_style);
|
||||
self.state = Some(state);
|
||||
|
||||
if self.inspector_id.as_ref() == Some(id) {
|
||||
return;
|
||||
} else {
|
||||
self.inspector_id = Some(id.clone());
|
||||
}
|
||||
let Some(style_buffer) = self.style_buffer.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let base_style_json = match base_style_json {
|
||||
Ok(base_style_json) => base_style_json,
|
||||
Err(err) => {
|
||||
self.style_editor = None;
|
||||
self.last_error =
|
||||
Some(format!("Failed to convert base_style to JSON: {err}").into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.last_error = None;
|
||||
|
||||
style_buffer.update(cx, |style_buffer, cx| {
|
||||
style_buffer.set_text(base_style_json, cx)
|
||||
});
|
||||
|
||||
let style_editor = cx.new(|cx| {
|
||||
let multi_buffer = cx.new(|cx| MultiBuffer::singleton(style_buffer, cx));
|
||||
let mut editor = Editor::new(
|
||||
EditorMode::full(),
|
||||
multi_buffer,
|
||||
Some(self.project.clone()),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
||||
editor.set_show_line_numbers(false, cx);
|
||||
editor.set_show_code_actions(false, cx);
|
||||
editor.set_show_breakpoints(false, cx);
|
||||
editor.set_show_git_diff_gutter(false, cx);
|
||||
editor.set_show_runnables(false, cx);
|
||||
editor.set_show_edit_predictions(Some(false), window, cx);
|
||||
editor
|
||||
});
|
||||
|
||||
cx.subscribe_in(&style_editor, window, {
|
||||
let id = id.clone();
|
||||
move |this, editor, event: &EditorEvent, window, cx| match event {
|
||||
EditorEvent::BufferEdited => {
|
||||
let base_style_json = editor.read(cx).text(cx);
|
||||
match serde_json_lenient::from_str(&base_style_json) {
|
||||
Ok(new_base_style) => {
|
||||
window.with_inspector_state::<DivInspectorState, _>(
|
||||
Some(&id),
|
||||
cx,
|
||||
|state, _window| {
|
||||
if let Some(state) = state.as_mut() {
|
||||
*state.base_style = new_base_style;
|
||||
}
|
||||
},
|
||||
);
|
||||
window.refresh();
|
||||
this.last_error = None;
|
||||
}
|
||||
Err(err) => this.last_error = Some(err.to_string().into()),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
self.style_editor = Some(style_editor);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DivInspector {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.gap_2()
|
||||
.when_some(self.state.as_ref(), |this, state| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.child(Label::new("Layout").size(LabelSize::Large))
|
||||
.child(render_layout_state(state, cx)),
|
||||
)
|
||||
})
|
||||
.when_some(self.style_editor.as_ref(), |this, style_editor| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(Label::new("Style").size(LabelSize::Large))
|
||||
.child(div().h_128().child(style_editor.clone()))
|
||||
.when_some(self.last_error.as_ref(), |this, last_error| {
|
||||
this.child(
|
||||
div()
|
||||
.w_full()
|
||||
.border_1()
|
||||
.border_color(Color::Error.color(cx))
|
||||
.child(Label::new(last_error)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when_none(&self.style_editor, |this| {
|
||||
this.child(Label::new("Loading..."))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_layout_state(state: &DivInspectorState, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.child(div().text_ui(cx).child(format!("Bounds: {}", state.bounds)))
|
||||
.child(
|
||||
div()
|
||||
.id("content-size")
|
||||
.text_ui(cx)
|
||||
.tooltip(Tooltip::text("Size of the element's children"))
|
||||
.child(if state.content_size != state.bounds.size {
|
||||
format!("Content size: {}", state.content_size)
|
||||
} else {
|
||||
"".to_string()
|
||||
}),
|
||||
)
|
||||
}
|
168
crates/inspector_ui/src/inspector.rs
Normal file
168
crates/inspector_ui/src/inspector.rs
Normal file
|
@ -0,0 +1,168 @@
|
|||
use anyhow::{Context as _, anyhow};
|
||||
use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window};
|
||||
use std::{cell::OnceCell, path::Path, sync::Arc};
|
||||
use ui::{Label, Tooltip, prelude::*};
|
||||
use util::{ResultExt as _, command::new_smol_command};
|
||||
use workspace::AppState;
|
||||
|
||||
use crate::div_inspector::DivInspector;
|
||||
|
||||
pub fn init(app_state: Arc<AppState>, cx: &mut App) {
|
||||
cx.on_action(|_: &zed_actions::dev::ToggleInspector, cx| {
|
||||
let Some(active_window) = cx
|
||||
.active_window()
|
||||
.context("no active window to toggle inspector")
|
||||
.log_err()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// This is deferred to avoid double lease due to window already being updated.
|
||||
cx.defer(move |cx| {
|
||||
active_window
|
||||
.update(cx, |_, window, cx| window.toggle_inspector(cx))
|
||||
.log_err();
|
||||
});
|
||||
});
|
||||
|
||||
// Project used for editor buffers + LSP support
|
||||
let project = project::Project::local(
|
||||
app_state.client.clone(),
|
||||
app_state.node_runtime.clone(),
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
|
||||
let div_inspector = OnceCell::new();
|
||||
cx.register_inspector_element(move |id, state: &DivInspectorState, window, cx| {
|
||||
let div_inspector = div_inspector
|
||||
.get_or_init(|| cx.new(|cx| DivInspector::new(project.clone(), window, cx)));
|
||||
div_inspector.update(cx, |div_inspector, cx| {
|
||||
div_inspector.update_inspected_element(&id, state.clone(), window, cx);
|
||||
div_inspector.render(window, cx).into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
cx.set_inspector_renderer(Box::new(render_inspector));
|
||||
}
|
||||
|
||||
fn render_inspector(
|
||||
inspector: &mut Inspector,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Inspector>,
|
||||
) -> AnyElement {
|
||||
let ui_font = theme::setup_ui_font(window, cx);
|
||||
let colors = cx.theme().colors();
|
||||
let inspector_id = inspector.active_element_id();
|
||||
v_flex()
|
||||
.id("gpui-inspector")
|
||||
.size_full()
|
||||
.bg(colors.panel_background)
|
||||
.text_color(colors.text)
|
||||
.font(ui_font)
|
||||
.border_l_1()
|
||||
.border_color(colors.border)
|
||||
.overflow_y_scroll()
|
||||
.child(
|
||||
h_flex()
|
||||
.p_2()
|
||||
.border_b_1()
|
||||
.border_color(colors.border_variant)
|
||||
.child(
|
||||
IconButton::new("pick-mode", IconName::MagnifyingGlass)
|
||||
.tooltip(Tooltip::text("Start inspector pick mode"))
|
||||
.selected_icon_color(Color::Selected)
|
||||
.toggle_state(inspector.is_picking())
|
||||
.on_click(cx.listener(|inspector, _, window, _cx| {
|
||||
inspector.start_picking();
|
||||
window.refresh();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_end()
|
||||
.child(Label::new("GPUI Inspector").size(LabelSize::Large)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.when_some(inspector_id, |this, inspector_id| {
|
||||
this.child(render_inspector_id(inspector_id, cx))
|
||||
})
|
||||
.children(inspector.render_inspector_states(window, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_inspector_id(inspector_id: &InspectorElementId, cx: &App) -> Div {
|
||||
let source_location = inspector_id.path.source_location;
|
||||
v_flex()
|
||||
.child(Label::new("Element ID").size(LabelSize::Large))
|
||||
.when(inspector_id.instance_id != 0, |this| {
|
||||
this.child(
|
||||
div()
|
||||
.id("instance-id")
|
||||
.text_ui(cx)
|
||||
.tooltip(Tooltip::text(
|
||||
"Disambiguates elements from the same source location",
|
||||
))
|
||||
.child(format!("Instance {}", inspector_id.instance_id)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.id("source-location")
|
||||
.text_ui(cx)
|
||||
.bg(cx.theme().colors().editor_foreground.opacity(0.025))
|
||||
.underline()
|
||||
.child(format!("{}", source_location))
|
||||
.tooltip(Tooltip::text("Click to open by running zed cli"))
|
||||
.on_click(move |_, _window, cx| {
|
||||
cx.background_spawn(open_zed_source_location(source_location))
|
||||
.detach_and_log_err(cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("global-id")
|
||||
.text_ui(cx)
|
||||
.min_h_12()
|
||||
.tooltip(Tooltip::text(
|
||||
"GlobalElementId of the nearest ancestor with an ID",
|
||||
))
|
||||
.child(inspector_id.path.global_id.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
async fn open_zed_source_location(
|
||||
location: &'static std::panic::Location<'static>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut path = Path::new(env!("ZED_REPO_DIR")).to_path_buf();
|
||||
path.push(Path::new(location.file()));
|
||||
let path_arg = format!(
|
||||
"{}:{}:{}",
|
||||
path.display(),
|
||||
location.line(),
|
||||
location.column()
|
||||
);
|
||||
|
||||
let output = new_smol_command("zed")
|
||||
.arg(&path_arg)
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("running zed to open {path_arg} failed"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
Err(anyhow!(
|
||||
"running zed to open {path_arg} failed with stderr: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
24
crates/inspector_ui/src/inspector_ui.rs
Normal file
24
crates/inspector_ui/src/inspector_ui.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
#[cfg(debug_assertions)]
|
||||
mod div_inspector;
|
||||
#[cfg(debug_assertions)]
|
||||
mod inspector;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub use inspector::init;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub fn init(_app_state: std::sync::Arc<workspace::AppState>, cx: &mut gpui::App) {
|
||||
use std::any::TypeId;
|
||||
use workspace::notifications::NotifyResultExt as _;
|
||||
|
||||
cx.on_action(|_: &zed_actions::dev::ToggleInspector, cx| {
|
||||
Err::<(), anyhow::Error>(anyhow::anyhow!(
|
||||
"dev::ToggleInspector is only available in debug builds"
|
||||
))
|
||||
.notify_app_err(cx);
|
||||
});
|
||||
|
||||
command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| {
|
||||
filter.hide_action_types(&[TypeId::of::<zed_actions::dev::ToggleInspector>()]);
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue