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}