1use gpui::{
2 Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
3 ParentElement, Render, Styled, Task, Window, actions,
4};
5use menu::{SelectNext, SelectPrevious};
6use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
7use workspace::{
8 NewFile, Open,
9 item::{Item, ItemEvent},
10 with_active_or_new_workspace,
11};
12use zed_actions::{Extensions, OpenSettings, agent, command_palette};
13
14use crate::{Onboarding, OpenOnboarding};
15
16actions!(
17 zed,
18 [
19 /// Show the Zed welcome screen
20 ShowWelcome
21 ]
22);
23
24const CONTENT: (Section<4>, Section<3>) = (
25 Section {
26 title: "Get Started",
27 entries: [
28 SectionEntry {
29 icon: IconName::Plus,
30 title: "New File",
31 action: &NewFile,
32 },
33 SectionEntry {
34 icon: IconName::FolderOpen,
35 title: "Open Project",
36 action: &Open,
37 },
38 SectionEntry {
39 icon: IconName::CloudDownload,
40 title: "Clone Repository",
41 action: &git::Clone,
42 },
43 SectionEntry {
44 icon: IconName::ListCollapse,
45 title: "Open Command Palette",
46 action: &command_palette::Toggle,
47 },
48 ],
49 },
50 Section {
51 title: "Configure",
52 entries: [
53 SectionEntry {
54 icon: IconName::Settings,
55 title: "Open Settings",
56 action: &OpenSettings,
57 },
58 SectionEntry {
59 icon: IconName::ZedAssistant,
60 title: "View AI Settings",
61 action: &agent::OpenSettings,
62 },
63 SectionEntry {
64 icon: IconName::Blocks,
65 title: "Explore Extensions",
66 action: &Extensions {
67 category_filter: None,
68 id: None,
69 },
70 },
71 ],
72 },
73);
74
75struct Section<const COLS: usize> {
76 title: &'static str,
77 entries: [SectionEntry; COLS],
78}
79
80impl<const COLS: usize> Section<COLS> {
81 fn render(
82 self,
83 index_offset: usize,
84 focus: &FocusHandle,
85 window: &mut Window,
86 cx: &mut App,
87 ) -> impl IntoElement {
88 v_flex()
89 .min_w_full()
90 .child(
91 h_flex()
92 .px_1()
93 .mb_2()
94 .gap_2()
95 .child(
96 Label::new(self.title.to_ascii_uppercase())
97 .buffer_font(cx)
98 .color(Color::Muted)
99 .size(LabelSize::XSmall),
100 )
101 .child(Divider::horizontal().color(DividerColor::BorderVariant)),
102 )
103 .children(
104 self.entries
105 .iter()
106 .enumerate()
107 .map(|(index, entry)| entry.render(index_offset + index, focus, window, cx)),
108 )
109 }
110}
111
112struct SectionEntry {
113 icon: IconName,
114 title: &'static str,
115 action: &'static dyn Action,
116}
117
118impl SectionEntry {
119 fn render(
120 &self,
121 button_index: usize,
122 focus: &FocusHandle,
123 window: &Window,
124 cx: &App,
125 ) -> impl IntoElement {
126 ButtonLike::new(("onboarding-button-id", button_index))
127 .tab_index(button_index as isize)
128 .full_width()
129 .size(ButtonSize::Medium)
130 .child(
131 h_flex()
132 .w_full()
133 .justify_between()
134 .child(
135 h_flex()
136 .gap_2()
137 .child(
138 Icon::new(self.icon)
139 .color(Color::Muted)
140 .size(IconSize::XSmall),
141 )
142 .child(Label::new(self.title)),
143 )
144 .children(
145 KeyBinding::for_action_in(self.action, focus, window, cx)
146 .map(|s| s.size(rems_from_px(12.))),
147 ),
148 )
149 .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx))
150 }
151}
152
153pub struct WelcomePage {
154 first_paint: bool,
155 focus_handle: FocusHandle,
156}
157
158impl WelcomePage {
159 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
160 window.focus_next();
161 cx.notify();
162 }
163
164 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
165 window.focus_prev();
166 cx.notify();
167 }
168}
169
170impl Render for WelcomePage {
171 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
172 if self.first_paint {
173 window.request_animation_frame();
174 self.first_paint = false;
175 }
176 let (first_section, second_section) = CONTENT;
177 let first_section_entries = first_section.entries.len();
178 let last_index = first_section_entries + second_section.entries.len();
179
180 h_flex()
181 .size_full()
182 .justify_center()
183 .overflow_hidden()
184 .bg(cx.theme().colors().editor_background)
185 .key_context("Welcome")
186 .track_focus(&self.focus_handle(cx))
187 .on_action(cx.listener(Self::select_previous))
188 .on_action(cx.listener(Self::select_next))
189 .child(
190 h_flex()
191 .px_12()
192 .py_40()
193 .size_full()
194 .relative()
195 .max_w(px(1100.))
196 .child(
197 div()
198 .size_full()
199 .max_w_128()
200 .mx_auto()
201 .child(
202 h_flex()
203 .w_full()
204 .justify_center()
205 .gap_4()
206 .child(Vector::square(VectorName::ZedLogo, rems(2.)))
207 .child(
208 div().child(Headline::new("Welcome to Zed")).child(
209 Label::new("The editor for what's next")
210 .size(LabelSize::Small)
211 .color(Color::Muted)
212 .italic(),
213 ),
214 ),
215 )
216 .child(
217 v_flex()
218 .mt_10()
219 .gap_6()
220 .child(first_section.render(
221 Default::default(),
222 &self.focus_handle,
223 window,
224 cx,
225 ))
226 .child(second_section.render(
227 first_section_entries,
228 &self.focus_handle,
229 window,
230 cx,
231 ))
232 .child(
233 h_flex()
234 .w_full()
235 .pt_4()
236 .justify_center()
237 // We call this a hack
238 .rounded_b_xs()
239 .border_t_1()
240 .border_color(cx.theme().colors().border.opacity(0.6))
241 .border_dashed()
242 .child(
243 Button::new("welcome-exit", "Return to Setup")
244 .tab_index(last_index as isize)
245 .full_width()
246 .label_size(LabelSize::XSmall)
247 .on_click(|_, window, cx| {
248 window.dispatch_action(
249 OpenOnboarding.boxed_clone(),
250 cx,
251 );
252
253 with_active_or_new_workspace(cx, |workspace, window, cx| {
254 let Some((welcome_id, welcome_idx)) = workspace
255 .active_pane()
256 .read(cx)
257 .items()
258 .enumerate()
259 .find_map(|(idx, item)| {
260 let _ = item.downcast::<WelcomePage>()?;
261 Some((item.item_id(), idx))
262 })
263 else {
264 return;
265 };
266
267 workspace.active_pane().update(cx, |pane, cx| {
268 // Get the index here to get around the borrow checker
269 let idx = pane.items().enumerate().find_map(
270 |(idx, item)| {
271 let _ =
272 item.downcast::<Onboarding>()?;
273 Some(idx)
274 },
275 );
276
277 if let Some(idx) = idx {
278 pane.activate_item(
279 idx, true, true, window, cx,
280 );
281 } else {
282 let item =
283 Box::new(Onboarding::new(workspace, cx));
284 pane.add_item(
285 item,
286 true,
287 true,
288 Some(welcome_idx),
289 window,
290 cx,
291 );
292 }
293
294 pane.remove_item(
295 welcome_id,
296 false,
297 false,
298 window,
299 cx,
300 );
301 });
302 });
303 }),
304 ),
305 ),
306 ),
307 ),
308 )
309 }
310}
311
312impl WelcomePage {
313 pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
314 cx.new(|cx| {
315 let focus_handle = cx.focus_handle();
316 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
317 .detach();
318
319 WelcomePage {
320 first_paint: true,
321 focus_handle,
322 }
323 })
324 }
325}
326
327impl EventEmitter<ItemEvent> for WelcomePage {}
328
329impl Focusable for WelcomePage {
330 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
331 self.focus_handle.clone()
332 }
333}
334
335impl Item for WelcomePage {
336 type Event = ItemEvent;
337
338 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
339 "Welcome".into()
340 }
341
342 fn telemetry_event_text(&self) -> Option<&'static str> {
343 Some("New Welcome Page Opened")
344 }
345
346 fn show_toolbar(&self) -> bool {
347 false
348 }
349
350 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
351 f(*event)
352 }
353}
354
355impl workspace::SerializableItem for WelcomePage {
356 fn serialized_item_kind() -> &'static str {
357 "WelcomePage"
358 }
359
360 fn cleanup(
361 workspace_id: workspace::WorkspaceId,
362 alive_items: Vec<workspace::ItemId>,
363 _window: &mut Window,
364 cx: &mut App,
365 ) -> Task<gpui::Result<()>> {
366 workspace::delete_unloaded_items(
367 alive_items,
368 workspace_id,
369 "welcome_pages",
370 &persistence::WELCOME_PAGES,
371 cx,
372 )
373 }
374
375 fn deserialize(
376 _project: Entity<project::Project>,
377 _workspace: gpui::WeakEntity<workspace::Workspace>,
378 workspace_id: workspace::WorkspaceId,
379 item_id: workspace::ItemId,
380 window: &mut Window,
381 cx: &mut App,
382 ) -> Task<gpui::Result<Entity<Self>>> {
383 if persistence::WELCOME_PAGES
384 .get_welcome_page(item_id, workspace_id)
385 .ok()
386 .is_some_and(|is_open| is_open)
387 {
388 window.spawn(cx, async move |cx| cx.update(WelcomePage::new))
389 } else {
390 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
391 }
392 }
393
394 fn serialize(
395 &mut self,
396 workspace: &mut workspace::Workspace,
397 item_id: workspace::ItemId,
398 _closing: bool,
399 _window: &mut Window,
400 cx: &mut Context<Self>,
401 ) -> Option<Task<gpui::Result<()>>> {
402 let workspace_id = workspace.database_id()?;
403 Some(cx.background_spawn(async move {
404 persistence::WELCOME_PAGES
405 .save_welcome_page(item_id, workspace_id, true)
406 .await
407 }))
408 }
409
410 fn should_serialize(&self, event: &Self::Event) -> bool {
411 event == &ItemEvent::UpdateTab
412 }
413}
414
415mod persistence {
416 use db::{
417 query,
418 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
419 sqlez_macros::sql,
420 };
421 use workspace::WorkspaceDb;
422
423 pub struct WelcomePagesDb(ThreadSafeConnection);
424
425 impl Domain for WelcomePagesDb {
426 const NAME: &str = stringify!(WelcomePagesDb);
427
428 const MIGRATIONS: &[&str] = (&[sql!(
429 CREATE TABLE welcome_pages (
430 workspace_id INTEGER,
431 item_id INTEGER UNIQUE,
432 is_open INTEGER DEFAULT FALSE,
433
434 PRIMARY KEY(workspace_id, item_id),
435 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
436 ON DELETE CASCADE
437 ) STRICT;
438 )]);
439 }
440
441 db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
442
443 impl WelcomePagesDb {
444 query! {
445 pub async fn save_welcome_page(
446 item_id: workspace::ItemId,
447 workspace_id: workspace::WorkspaceId,
448 is_open: bool
449 ) -> Result<()> {
450 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
451 VALUES (?, ?, ?)
452 }
453 }
454
455 query! {
456 pub fn get_welcome_page(
457 item_id: workspace::ItemId,
458 workspace_id: workspace::WorkspaceId
459 ) -> Result<bool> {
460 SELECT is_open
461 FROM welcome_pages
462 WHERE item_id = ? AND workspace_id = ?
463 }
464 }
465 }
466}