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