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:
Michael Sloan 2025-05-23 17:08:59 -06:00 committed by GitHub
parent 685933b5c8
commit ab59982bf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2631 additions and 406 deletions

View 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

View file

@ -0,0 +1 @@
../../LICENSE-GPL

View 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.

View 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());
}

View 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()
}),
)
}

View 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(())
}
}

View 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>()]);
});
}