1use std::{
2 ops::Range,
3 path::PathBuf,
4 time::{Duration, Instant},
5};
6
7use gpui::{
8 App, AppContext, ClipboardItem, Context, Entity, Hsla, InteractiveElement, IntoElement,
9 ParentElement, Render, ScrollHandle, SerializedTaskTiming, SharedString,
10 StatefulInteractiveElement, Styled, Task, TaskTiming, TitlebarOptions, WindowBounds,
11 WindowHandle, WindowOptions, div, prelude::FluentBuilder, px, relative, size,
12};
13use util::ResultExt;
14use workspace::{
15 Workspace,
16 ui::{
17 ActiveTheme, Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, ToggleState, Tooltip,
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 = SharedString::from(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 .id(label.clone())
201 .w(px(200.0))
202 .flex_shrink_0()
203 .overflow_hidden()
204 .child(div().text_ellipsis().child(label.clone()))
205 .tooltip(Tooltip::text(label.clone()))
206 .on_click(move |_, _, cx| {
207 cx.write_to_clipboard(ClipboardItem::new_string(label.to_string()))
208 }),
209 )
210 .child(
211 div()
212 .flex_1()
213 .h(px(24.0))
214 .bg(cx.theme().colors().background)
215 .rounded_md()
216 .p(px(2.0))
217 .relative()
218 .child(
219 div()
220 .absolute()
221 .h_full()
222 .rounded_sm()
223 .bg(item.color)
224 .left(relative(start.max(0f32)))
225 .w(relative(bar_width)),
226 ),
227 )
228 .child(
229 div()
230 .min_w(px(60.0))
231 .flex_shrink_0()
232 .text_right()
233 .child(format!("{:.1}ms", time_ms)),
234 )
235 }
236}
237
238impl Render for ProfilerWindow {
239 fn render(
240 &mut self,
241 window: &mut gpui::Window,
242 cx: &mut gpui::Context<Self>,
243 ) -> impl gpui::IntoElement {
244 v_flex()
245 .id("profiler")
246 .w_full()
247 .h_full()
248 .gap_2()
249 .bg(cx.theme().colors().surface_background)
250 .text_color(cx.theme().colors().text)
251 .child(
252 h_flex()
253 .w_full()
254 .justify_between()
255 .child(
256 h_flex()
257 .gap_2()
258 .child(
259 Button::new(
260 "switch-mode",
261 match self.data {
262 DataMode::Snapshot { .. } => "Resume",
263 DataMode::Realtime(_) => "Pause",
264 },
265 )
266 .style(ButtonStyle::Filled)
267 .on_click(cx.listener(
268 |this, _, _window, cx| {
269 match &this.data {
270 DataMode::Realtime(Some(data)) => {
271 this._refresh = None;
272 this.data = DataMode::Snapshot(data.clone());
273 }
274 DataMode::Snapshot { .. } => {
275 this._refresh = Some(Self::begin_listen(cx));
276 this.data = DataMode::Realtime(None);
277 }
278 _ => {}
279 };
280 cx.notify();
281 },
282 )),
283 )
284 .child(
285 Button::new("export-data", "Save")
286 .style(ButtonStyle::Filled)
287 .on_click(cx.listener(|this, _, _window, cx| {
288 let Some(workspace) = this.workspace else {
289 return;
290 };
291
292 let Some(data) = this.get_timings() else {
293 return;
294 };
295 let timings =
296 SerializedTaskTiming::convert(this.startup_time, &data);
297
298 let active_path = workspace
299 .read_with(cx, |workspace, cx| {
300 workspace.most_recent_active_path(cx)
301 })
302 .log_err()
303 .flatten()
304 .and_then(|p| p.parent().map(|p| p.to_owned()))
305 .unwrap_or_else(|| PathBuf::default());
306
307 let path = cx.prompt_for_new_path(
308 &active_path,
309 Some("performance_profile.miniprof"),
310 );
311
312 cx.background_spawn(async move {
313 let path = path.await;
314 let path =
315 path.log_err().and_then(|p| p.log_err()).flatten();
316
317 let Some(path) = path else {
318 return;
319 };
320
321 let Some(timings) =
322 serde_json::to_string(&timings).log_err()
323 else {
324 return;
325 };
326
327 smol::fs::write(path, &timings).await.log_err();
328 })
329 .detach();
330 })),
331 ),
332 )
333 .child(
334 Checkbox::new("include-self", self.include_self_timings)
335 .label("Include profiler timings")
336 .on_click(cx.listener(|this, checked, _window, cx| {
337 this.include_self_timings = *checked;
338 cx.notify();
339 })),
340 ),
341 )
342 .when_some(self.get_timings(), |div, e| {
343 if e.len() == 0 {
344 return div;
345 }
346
347 let min = e[0].start;
348 let max = e[e.len() - 1].end.unwrap_or_else(|| Instant::now());
349 div.child(
350 v_flex()
351 .id("timings.bars")
352 .overflow_scroll()
353 .w_full()
354 .h_full()
355 .gap_2()
356 .track_scroll(&self.scroll_handle)
357 .on_scroll_wheel(cx.listener(|this, _, _, _cx| {
358 let scroll_offset = this.scroll_handle.offset();
359 let max_offset = this.scroll_handle.max_offset();
360 this.autoscroll = -scroll_offset.y >= (max_offset.height - px(5.0));
361 }))
362 .children(
363 e.iter()
364 .filter(|timing| {
365 timing
366 .end
367 .unwrap_or_else(|| Instant::now())
368 .duration_since(timing.start)
369 .as_millis()
370 >= 1
371 })
372 .filter(|timing| {
373 if self.include_self_timings.selected() {
374 true
375 } else {
376 !timing.location.file().ends_with("miniprofiler_ui.rs")
377 }
378 })
379 .enumerate()
380 .map(|(i, timing)| {
381 self.render_timing(
382 max.checked_sub(Duration::from_secs(10)).unwrap_or(min)
383 ..max,
384 TimingBar {
385 location: timing.location,
386 start: timing.start,
387 end: timing.end.unwrap_or_else(|| Instant::now()),
388 color: cx.theme().accents().color_for_index(i as u32),
389 },
390 cx,
391 )
392 }),
393 ),
394 )
395 .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
396 })
397 }
398}