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}