miniprofiler_ui.rs

  1use std::{
  2    ops::Range,
  3    path::PathBuf,
  4    rc::Rc,
  5    time::{Duration, Instant},
  6};
  7
  8use gpui::{
  9    App, AppContext, ClipboardItem, Context, Div, Entity, Hsla, InteractiveElement,
 10    ParentElement as _, Render, SerializedTaskTiming, SharedString, StatefulInteractiveElement,
 11    Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WeakEntity, WindowBounds,
 12    WindowOptions, div, prelude::FluentBuilder, px, relative, size, uniform_list,
 13};
 14use util::ResultExt;
 15use workspace::{
 16    Workspace,
 17    ui::{
 18        ActiveTheme, Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, Divider,
 19        ScrollableHandle as _, ToggleState, Tooltip, WithScrollbar, h_flex, v_flex,
 20    },
 21};
 22use zed_actions::OpenPerformanceProfiler;
 23
 24pub fn init(startup_time: Instant, cx: &mut App) {
 25    cx.observe_new(move |workspace: &mut workspace::Workspace, _, cx| {
 26        let workspace_handle = cx.entity().downgrade();
 27        workspace.register_action(move |_workspace, _: &OpenPerformanceProfiler, window, cx| {
 28            open_performance_profiler(startup_time, workspace_handle.clone(), window, cx);
 29        });
 30    })
 31    .detach();
 32}
 33
 34fn open_performance_profiler(
 35    startup_time: Instant,
 36    workspace_handle: WeakEntity<Workspace>,
 37    _window: &mut gpui::Window,
 38    cx: &mut App,
 39) {
 40    let existing_window = cx
 41        .windows()
 42        .into_iter()
 43        .find_map(|window| window.downcast::<ProfilerWindow>());
 44
 45    if let Some(existing_window) = existing_window {
 46        existing_window
 47            .update(cx, |profiler_window, window, _cx| {
 48                profiler_window.workspace = Some(workspace_handle.clone());
 49                window.activate_window();
 50            })
 51            .log_err();
 52        return;
 53    }
 54
 55    let default_bounds = size(px(1280.), px(720.)); // 16:9
 56
 57    cx.open_window(
 58        WindowOptions {
 59            titlebar: Some(TitlebarOptions {
 60                title: Some("Profiler Window".into()),
 61                appears_transparent: false,
 62                traffic_light_position: None,
 63            }),
 64            focus: true,
 65            show: true,
 66            is_movable: true,
 67            kind: gpui::WindowKind::Normal,
 68            window_background: cx.theme().window_background_appearance(),
 69            window_decorations: None,
 70            window_min_size: Some(default_bounds),
 71            window_bounds: Some(WindowBounds::centered(default_bounds, cx)),
 72            ..Default::default()
 73        },
 74        |_window, cx| ProfilerWindow::new(startup_time, Some(workspace_handle), cx),
 75    )
 76    .log_err();
 77}
 78
 79enum DataMode {
 80    Realtime(Option<Vec<TaskTiming>>),
 81    Snapshot(Vec<TaskTiming>),
 82}
 83
 84struct TimingBar {
 85    location: &'static core::panic::Location<'static>,
 86    start: Instant,
 87    end: Instant,
 88    color: Hsla,
 89}
 90
 91pub struct ProfilerWindow {
 92    startup_time: Instant,
 93    data: DataMode,
 94    include_self_timings: ToggleState,
 95    autoscroll: bool,
 96    scroll_handle: UniformListScrollHandle,
 97    workspace: Option<WeakEntity<Workspace>>,
 98    _refresh: Option<Task<()>>,
 99}
100
101impl ProfilerWindow {
102    pub fn new(
103        startup_time: Instant,
104        workspace_handle: Option<WeakEntity<Workspace>>,
105        cx: &mut App,
106    ) -> Entity<Self> {
107        let entity = cx.new(|cx| ProfilerWindow {
108            startup_time,
109            data: DataMode::Realtime(None),
110            include_self_timings: ToggleState::Unselected,
111            autoscroll: true,
112            scroll_handle: UniformListScrollHandle::default(),
113            workspace: workspace_handle,
114            _refresh: Some(Self::begin_listen(cx)),
115        });
116
117        entity
118    }
119
120    fn begin_listen(cx: &mut Context<Self>) -> Task<()> {
121        cx.spawn(async move |this, cx| {
122            loop {
123                let data = cx
124                    .foreground_executor()
125                    .dispatcher()
126                    .get_current_thread_timings();
127
128                this.update(cx, |this: &mut ProfilerWindow, cx| {
129                    this.data = DataMode::Realtime(Some(data));
130                    cx.notify();
131                })
132                .ok();
133
134                // yield to the executor
135                cx.background_executor()
136                    .timer(Duration::from_micros(1))
137                    .await;
138            }
139        })
140    }
141
142    fn get_timings(&self) -> Option<&Vec<TaskTiming>> {
143        match &self.data {
144            DataMode::Realtime(data) => data.as_ref(),
145            DataMode::Snapshot(data) => Some(data),
146        }
147    }
148
149    fn render_timing(value_range: Range<Instant>, item: TimingBar, cx: &App) -> Div {
150        let time_ms = item.end.duration_since(item.start).as_secs_f32() * 1000f32;
151
152        let remap = value_range
153            .end
154            .duration_since(value_range.start)
155            .as_secs_f32()
156            * 1000f32;
157
158        let start = (item.start.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
159        let end = (item.end.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
160
161        let bar_width = end - start.abs();
162
163        let location = item
164            .location
165            .file()
166            .rsplit_once("/")
167            .unwrap_or(("", item.location.file()))
168            .1;
169        let location = location.rsplit_once("\\").unwrap_or(("", location)).1;
170
171        let label = SharedString::from(format!(
172            "{}:{}:{}",
173            location,
174            item.location.line(),
175            item.location.column()
176        ));
177
178        h_flex()
179            .gap_2()
180            .w_full()
181            .h(px(32.0))
182            .child(
183                div()
184                    .id(label.clone())
185                    .w(px(200.0))
186                    .flex_shrink_0()
187                    .overflow_hidden()
188                    .child(div().text_ellipsis().child(label.clone()))
189                    .tooltip(Tooltip::text(label.clone()))
190                    .on_click(move |_, _, cx| {
191                        cx.write_to_clipboard(ClipboardItem::new_string(label.to_string()))
192                    }),
193            )
194            .child(
195                div()
196                    .flex_1()
197                    .h(px(24.0))
198                    .bg(cx.theme().colors().background)
199                    .rounded_md()
200                    .p(px(2.0))
201                    .relative()
202                    .child(
203                        div()
204                            .absolute()
205                            .h_full()
206                            .rounded_sm()
207                            .bg(item.color)
208                            .left(relative(start.max(0f32)))
209                            .w(relative(bar_width)),
210                    ),
211            )
212            .child(
213                div()
214                    .min_w(px(70.))
215                    .flex_shrink_0()
216                    .text_right()
217                    .child(format!("{:.1} ms", time_ms)),
218            )
219    }
220}
221
222impl Render for ProfilerWindow {
223    fn render(
224        &mut self,
225        window: &mut gpui::Window,
226        cx: &mut gpui::Context<Self>,
227    ) -> impl gpui::IntoElement {
228        let scroll_offset = self.scroll_handle.offset();
229        let max_offset = self.scroll_handle.max_offset();
230        self.autoscroll = -scroll_offset.y >= (max_offset.height - px(24.));
231        if self.autoscroll {
232            self.scroll_handle.scroll_to_bottom();
233        }
234
235        v_flex()
236            .id("profiler")
237            .w_full()
238            .h_full()
239            .bg(cx.theme().colors().surface_background)
240            .text_color(cx.theme().colors().text)
241            .child(
242                h_flex()
243                    .py_2()
244                    .px_4()
245                    .w_full()
246                    .justify_between()
247                    .child(
248                        h_flex()
249                            .gap_2()
250                            .child(
251                                Button::new(
252                                    "switch-mode",
253                                    match self.data {
254                                        DataMode::Snapshot { .. } => "Resume",
255                                        DataMode::Realtime(_) => "Pause",
256                                    },
257                                )
258                                .style(ButtonStyle::Filled)
259                                .on_click(cx.listener(
260                                    |this, _, _window, cx| {
261                                        match &this.data {
262                                            DataMode::Realtime(Some(data)) => {
263                                                this._refresh = None;
264                                                this.data = DataMode::Snapshot(data.clone());
265                                            }
266                                            DataMode::Snapshot { .. } => {
267                                                this._refresh = Some(Self::begin_listen(cx));
268                                                this.data = DataMode::Realtime(None);
269                                            }
270                                            _ => {}
271                                        };
272                                        cx.notify();
273                                    },
274                                )),
275                            )
276                            .child(
277                                Button::new("export-data", "Save")
278                                    .style(ButtonStyle::Filled)
279                                    .on_click(cx.listener(|this, _, _window, cx| {
280                                        let Some(workspace) = this.workspace.as_ref() else {
281                                            return;
282                                        };
283
284                                        let Some(data) = this.get_timings() else {
285                                            return;
286                                        };
287                                        let timings =
288                                            SerializedTaskTiming::convert(this.startup_time, &data);
289
290                                        let active_path = workspace
291                                            .read_with(cx, |workspace, cx| {
292                                                workspace.most_recent_active_path(cx)
293                                            })
294                                            .log_err()
295                                            .flatten()
296                                            .and_then(|p| p.parent().map(|p| p.to_owned()))
297                                            .unwrap_or_else(PathBuf::default);
298
299                                        let path = cx.prompt_for_new_path(
300                                            &active_path,
301                                            Some("performance_profile.miniprof"),
302                                        );
303
304                                        cx.background_spawn(async move {
305                                            let path = path.await;
306                                            let path =
307                                                path.log_err().and_then(|p| p.log_err()).flatten();
308
309                                            let Some(path) = path else {
310                                                return;
311                                            };
312
313                                            let Some(timings) =
314                                                serde_json::to_string(&timings).log_err()
315                                            else {
316                                                return;
317                                            };
318
319                                            smol::fs::write(path, &timings).await.log_err();
320                                        })
321                                        .detach();
322                                    })),
323                            ),
324                    )
325                    .child(
326                        Checkbox::new("include-self", self.include_self_timings)
327                            .label("Include profiler timings")
328                            .on_click(cx.listener(|this, checked, _window, cx| {
329                                this.include_self_timings = *checked;
330                                cx.notify();
331                            })),
332                    ),
333            )
334            .when_some(self.get_timings(), |div, e| {
335                if e.len() == 0 {
336                    return div;
337                }
338
339                let min = e[0].start;
340                let max = e[e.len() - 1].end.unwrap_or_else(|| Instant::now());
341                let timings = Rc::new(
342                    e.into_iter()
343                        .filter(|timing| {
344                            timing
345                                .end
346                                .unwrap_or_else(|| Instant::now())
347                                .duration_since(timing.start)
348                                .as_millis()
349                                >= 1
350                        })
351                        .filter(|timing| {
352                            if self.include_self_timings.selected() {
353                                true
354                            } else {
355                                !timing.location.file().ends_with("miniprofiler_ui.rs")
356                            }
357                        })
358                        .cloned()
359                        .collect::<Vec<_>>(),
360                );
361
362                div.child(Divider::horizontal()).child(
363                    v_flex()
364                        .id("timings.bars")
365                        .w_full()
366                        .h_full()
367                        .gap_2()
368                        .child(
369                            uniform_list("list", timings.len(), {
370                                let timings = timings.clone();
371                                move |visible_range, _, cx| {
372                                    let mut items = vec![];
373                                    for i in visible_range {
374                                        let timing = &timings[i];
375                                        let value_range =
376                                            max.checked_sub(Duration::from_secs(10)).unwrap_or(min)
377                                                ..max;
378                                        items.push(Self::render_timing(
379                                            value_range,
380                                            TimingBar {
381                                                location: timing.location,
382                                                start: timing.start,
383                                                end: timing.end.unwrap_or_else(|| Instant::now()),
384                                                color: cx
385                                                    .theme()
386                                                    .accents()
387                                                    .color_for_index(i as u32),
388                                            },
389                                            cx,
390                                        ));
391                                    }
392                                    items
393                                }
394                            })
395                            .p_4()
396                            .on_scroll_wheel(cx.listener(|this, _, _, cx| {
397                                this.autoscroll = false;
398                                cx.notify();
399                            }))
400                            .track_scroll(&self.scroll_handle)
401                            .size_full(),
402                        )
403                        .vertical_scrollbar_for(&self.scroll_handle, window, cx),
404                )
405            })
406    }
407}