miniprofiler_ui.rs

  1use std::{
  2    ops::Range,
  3    path::PathBuf,
  4    time::{Duration, Instant},
  5};
  6
  7use gpui::{
  8    App, AppContext, Context, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, Render,
  9    ScrollHandle, SerializedTaskTiming, StatefulInteractiveElement, Styled, Task, TaskTiming,
 10    TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, div, prelude::FluentBuilder, px,
 11    relative, size,
 12};
 13use util::ResultExt;
 14use workspace::{
 15    Workspace,
 16    ui::{
 17        ActiveTheme, Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, ToggleState,
 18        WithScrollbar, h_flex, v_flex,
 19    },
 20};
 21use zed_actions::OpenPerformanceProfiler;
 22
 23pub fn init(startup_time: Instant, cx: &mut App) {
 24    cx.observe_new(move |workspace: &mut workspace::Workspace, _, _| {
 25        workspace.register_action(move |workspace, _: &OpenPerformanceProfiler, window, cx| {
 26            let window_handle = window
 27                .window_handle()
 28                .downcast::<Workspace>()
 29                .expect("Workspaces are root Windows");
 30            open_performance_profiler(startup_time, workspace, window_handle, cx);
 31        });
 32    })
 33    .detach();
 34}
 35
 36fn open_performance_profiler(
 37    startup_time: Instant,
 38    _workspace: &mut workspace::Workspace,
 39    workspace_handle: WindowHandle<Workspace>,
 40    cx: &mut App,
 41) {
 42    let existing_window = cx
 43        .windows()
 44        .into_iter()
 45        .find_map(|window| window.downcast::<ProfilerWindow>());
 46
 47    if let Some(existing_window) = existing_window {
 48        existing_window
 49            .update(cx, |profiler_window, window, _cx| {
 50                profiler_window.workspace = Some(workspace_handle);
 51                window.activate_window();
 52            })
 53            .log_err();
 54        return;
 55    }
 56
 57    let default_bounds = size(px(1280.), px(720.)); // 16:9
 58
 59    cx.open_window(
 60        WindowOptions {
 61            titlebar: Some(TitlebarOptions {
 62                title: Some("Profiler Window".into()),
 63                appears_transparent: false,
 64                traffic_light_position: None,
 65            }),
 66            focus: true,
 67            show: true,
 68            is_movable: true,
 69            kind: gpui::WindowKind::Normal,
 70            window_background: cx.theme().window_background_appearance(),
 71            window_decorations: None,
 72            window_min_size: Some(default_bounds),
 73            window_bounds: Some(WindowBounds::centered(default_bounds, cx)),
 74            ..Default::default()
 75        },
 76        |_window, cx| ProfilerWindow::new(startup_time, Some(workspace_handle), cx),
 77    )
 78    .log_err();
 79}
 80
 81enum DataMode {
 82    Realtime(Option<Vec<TaskTiming>>),
 83    Snapshot(Vec<TaskTiming>),
 84}
 85
 86struct TimingBar {
 87    location: &'static core::panic::Location<'static>,
 88    start: Instant,
 89    end: Instant,
 90    color: Hsla,
 91}
 92
 93pub struct ProfilerWindow {
 94    startup_time: Instant,
 95    data: DataMode,
 96    include_self_timings: ToggleState,
 97    autoscroll: bool,
 98    scroll_handle: ScrollHandle,
 99    workspace: Option<WindowHandle<Workspace>>,
100    _refresh: Option<Task<()>>,
101}
102
103impl ProfilerWindow {
104    pub fn new(
105        startup_time: Instant,
106        workspace_handle: Option<WindowHandle<Workspace>>,
107        cx: &mut App,
108    ) -> Entity<Self> {
109        let entity = cx.new(|cx| ProfilerWindow {
110            startup_time,
111            data: DataMode::Realtime(None),
112            include_self_timings: ToggleState::Unselected,
113            autoscroll: true,
114            scroll_handle: ScrollHandle::new(),
115            workspace: workspace_handle,
116            _refresh: Some(Self::begin_listen(cx)),
117        });
118
119        entity
120    }
121
122    fn begin_listen(cx: &mut Context<Self>) -> Task<()> {
123        cx.spawn(async move |this, cx| {
124            loop {
125                let data = cx
126                    .foreground_executor()
127                    .dispatcher
128                    .get_current_thread_timings();
129
130                this.update(cx, |this: &mut ProfilerWindow, cx| {
131                    let scroll_offset = this.scroll_handle.offset();
132                    let max_offset = this.scroll_handle.max_offset();
133                    this.autoscroll = -scroll_offset.y >= (max_offset.height - px(5.0));
134
135                    this.data = DataMode::Realtime(Some(data));
136
137                    if this.autoscroll {
138                        this.scroll_handle.scroll_to_bottom();
139                    }
140
141                    cx.notify();
142                })
143                .ok();
144
145                // yield to the executor
146                cx.background_executor()
147                    .timer(Duration::from_micros(1))
148                    .await;
149            }
150        })
151    }
152
153    fn get_timings(&self) -> Option<&Vec<TaskTiming>> {
154        match &self.data {
155            DataMode::Realtime(data) => data.as_ref(),
156            DataMode::Snapshot(data) => Some(data),
157        }
158    }
159
160    fn render_timing(
161        &self,
162        value_range: Range<Instant>,
163        item: TimingBar,
164        cx: &App,
165    ) -> impl IntoElement {
166        let time_ms = item.end.duration_since(item.start).as_secs_f32() * 1000f32;
167
168        let remap = value_range
169            .end
170            .duration_since(value_range.start)
171            .as_secs_f32()
172            * 1000f32;
173
174        let start = (item.start.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
175        let end = (item.end.duration_since(value_range.start).as_secs_f32() * 1000f32) / remap;
176
177        let bar_width = end - start.abs();
178
179        let location = item
180            .location
181            .file()
182            .rsplit_once("/")
183            .unwrap_or(("", item.location.file()))
184            .1;
185        let location = location.rsplit_once("\\").unwrap_or(("", location)).1;
186
187        let label = format!(
188            "{}:{}:{}",
189            location,
190            item.location.line(),
191            item.location.column()
192        );
193
194        h_flex()
195            .gap_2()
196            .w_full()
197            .h(px(32.0))
198            .child(
199                div()
200                    .w(px(200.0))
201                    .flex_shrink_0()
202                    .overflow_hidden()
203                    .child(div().text_ellipsis().child(label)),
204            )
205            .child(
206                div()
207                    .flex_1()
208                    .h(px(24.0))
209                    .bg(cx.theme().colors().background)
210                    .rounded_md()
211                    .p(px(2.0))
212                    .relative()
213                    .child(
214                        div()
215                            .absolute()
216                            .h_full()
217                            .rounded_sm()
218                            .bg(item.color)
219                            .left(relative(start.max(0f32)))
220                            .w(relative(bar_width)),
221                    ),
222            )
223            .child(
224                div()
225                    .min_w(px(60.0))
226                    .flex_shrink_0()
227                    .text_right()
228                    .child(format!("{:.1}ms", time_ms)),
229            )
230    }
231}
232
233impl Render for ProfilerWindow {
234    fn render(
235        &mut self,
236        window: &mut gpui::Window,
237        cx: &mut gpui::Context<Self>,
238    ) -> impl gpui::IntoElement {
239        v_flex()
240            .id("profiler")
241            .w_full()
242            .h_full()
243            .gap_2()
244            .bg(cx.theme().colors().surface_background)
245            .text_color(cx.theme().colors().text)
246            .child(
247                h_flex()
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                div.child(
345                    v_flex()
346                        .id("timings.bars")
347                        .overflow_scroll()
348                        .w_full()
349                        .h_full()
350                        .gap_2()
351                        .track_scroll(&self.scroll_handle)
352                        .on_scroll_wheel(cx.listener(|this, _, _, _cx| {
353                            let scroll_offset = this.scroll_handle.offset();
354                            let max_offset = this.scroll_handle.max_offset();
355                            this.autoscroll = -scroll_offset.y >= (max_offset.height - px(5.0));
356                        }))
357                        .children(
358                            e.iter()
359                                .filter(|timing| {
360                                    timing
361                                        .end
362                                        .unwrap_or_else(|| Instant::now())
363                                        .duration_since(timing.start)
364                                        .as_millis()
365                                        >= 1
366                                })
367                                .filter(|timing| {
368                                    if self.include_self_timings.selected() {
369                                        true
370                                    } else {
371                                        !timing.location.file().ends_with("miniprofiler_ui.rs")
372                                    }
373                                })
374                                .enumerate()
375                                .map(|(i, timing)| {
376                                    self.render_timing(
377                                        max.checked_sub(Duration::from_secs(10)).unwrap_or(min)
378                                            ..max,
379                                        TimingBar {
380                                            location: timing.location,
381                                            start: timing.start,
382                                            end: timing.end.unwrap_or_else(|| Instant::now()),
383                                            color: cx.theme().accents().color_for_index(i as u32),
384                                        },
385                                        cx,
386                                    )
387                                }),
388                        ),
389                )
390                .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
391            })
392    }
393}