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, WorkspaceId,
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 focus_handle: FocusHandle,
155}
156
157impl WelcomePage {
158 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
159 window.focus_next();
160 cx.notify();
161 }
162
163 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
164 window.focus_prev();
165 cx.notify();
166 }
167}
168
169impl Render for WelcomePage {
170 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
171 let (first_section, second_section) = CONTENT;
172 let first_section_entries = first_section.entries.len();
173 let last_index = first_section_entries + second_section.entries.len();
174
175 h_flex()
176 .size_full()
177 .justify_center()
178 .overflow_hidden()
179 .bg(cx.theme().colors().editor_background)
180 .key_context("Welcome")
181 .track_focus(&self.focus_handle(cx))
182 .on_action(cx.listener(Self::select_previous))
183 .on_action(cx.listener(Self::select_next))
184 .child(
185 h_flex()
186 .px_12()
187 .py_40()
188 .size_full()
189 .relative()
190 .max_w(px(1100.))
191 .child(
192 div()
193 .size_full()
194 .max_w_128()
195 .mx_auto()
196 .child(
197 h_flex()
198 .w_full()
199 .justify_center()
200 .gap_4()
201 .child(Vector::square(VectorName::ZedLogo, rems(2.)))
202 .child(
203 div().child(Headline::new("Welcome to Zed")).child(
204 Label::new("The editor for what's next")
205 .size(LabelSize::Small)
206 .color(Color::Muted)
207 .italic(),
208 ),
209 ),
210 )
211 .child(
212 v_flex()
213 .mt_10()
214 .gap_6()
215 .child(first_section.render(
216 Default::default(),
217 &self.focus_handle,
218 window,
219 cx,
220 ))
221 .child(second_section.render(
222 first_section_entries,
223 &self.focus_handle,
224 window,
225 cx,
226 ))
227 .child(
228 h_flex()
229 .w_full()
230 .pt_4()
231 .justify_center()
232 // We call this a hack
233 .rounded_b_xs()
234 .border_t_1()
235 .border_color(cx.theme().colors().border.opacity(0.6))
236 .border_dashed()
237 .child(
238 Button::new("welcome-exit", "Return to Setup")
239 .tab_index(last_index as isize)
240 .full_width()
241 .label_size(LabelSize::XSmall)
242 .on_click(|_, window, cx| {
243 window.dispatch_action(
244 OpenOnboarding.boxed_clone(),
245 cx,
246 );
247
248 with_active_or_new_workspace(cx, |workspace, window, cx| {
249 let Some((welcome_id, welcome_idx)) = workspace
250 .active_pane()
251 .read(cx)
252 .items()
253 .enumerate()
254 .find_map(|(idx, item)| {
255 let _ = item.downcast::<WelcomePage>()?;
256 Some((item.item_id(), idx))
257 })
258 else {
259 return;
260 };
261
262 workspace.active_pane().update(cx, |pane, cx| {
263 // Get the index here to get around the borrow checker
264 let idx = pane.items().enumerate().find_map(
265 |(idx, item)| {
266 let _ =
267 item.downcast::<Onboarding>()?;
268 Some(idx)
269 },
270 );
271
272 if let Some(idx) = idx {
273 pane.activate_item(
274 idx, true, true, window, cx,
275 );
276 } else {
277 let item =
278 Box::new(Onboarding::new(workspace, cx));
279 pane.add_item(
280 item,
281 true,
282 true,
283 Some(welcome_idx),
284 window,
285 cx,
286 );
287 }
288
289 pane.remove_item(
290 welcome_id,
291 false,
292 false,
293 window,
294 cx,
295 );
296 });
297 });
298 }),
299 ),
300 ),
301 ),
302 ),
303 )
304 }
305}
306
307impl WelcomePage {
308 pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
309 cx.new(|cx| {
310 let focus_handle = cx.focus_handle();
311 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
312 .detach();
313
314 WelcomePage { focus_handle }
315 })
316 }
317}
318
319impl EventEmitter<ItemEvent> for WelcomePage {}
320
321impl Focusable for WelcomePage {
322 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
323 self.focus_handle.clone()
324 }
325}
326
327impl Item for WelcomePage {
328 type Event = ItemEvent;
329
330 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
331 "Welcome".into()
332 }
333
334 fn telemetry_event_text(&self) -> Option<&'static str> {
335 Some("New Welcome Page Opened")
336 }
337
338 fn show_toolbar(&self) -> bool {
339 false
340 }
341
342 fn clone_on_split(
343 &self,
344 _workspace_id: Option<WorkspaceId>,
345 _: &mut Window,
346 _: &mut Context<Self>,
347 ) -> Option<Entity<Self>> {
348 None
349 }
350
351 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
352 f(*event)
353 }
354}
355
356impl workspace::SerializableItem for WelcomePage {
357 fn serialized_item_kind() -> &'static str {
358 "WelcomePage"
359 }
360
361 fn cleanup(
362 workspace_id: workspace::WorkspaceId,
363 alive_items: Vec<workspace::ItemId>,
364 _window: &mut Window,
365 cx: &mut App,
366 ) -> Task<gpui::Result<()>> {
367 workspace::delete_unloaded_items(
368 alive_items,
369 workspace_id,
370 "welcome_pages",
371 &persistence::WELCOME_PAGES,
372 cx,
373 )
374 }
375
376 fn deserialize(
377 _project: Entity<project::Project>,
378 _workspace: gpui::WeakEntity<workspace::Workspace>,
379 workspace_id: workspace::WorkspaceId,
380 item_id: workspace::ItemId,
381 window: &mut Window,
382 cx: &mut App,
383 ) -> Task<gpui::Result<Entity<Self>>> {
384 if persistence::WELCOME_PAGES
385 .get_welcome_page(item_id, workspace_id)
386 .ok()
387 .is_some_and(|is_open| is_open)
388 {
389 window.spawn(cx, async move |cx| cx.update(WelcomePage::new))
390 } else {
391 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
392 }
393 }
394
395 fn serialize(
396 &mut self,
397 workspace: &mut workspace::Workspace,
398 item_id: workspace::ItemId,
399 _closing: bool,
400 _window: &mut Window,
401 cx: &mut Context<Self>,
402 ) -> Option<Task<gpui::Result<()>>> {
403 let workspace_id = workspace.database_id()?;
404 Some(cx.background_spawn(async move {
405 persistence::WELCOME_PAGES
406 .save_welcome_page(item_id, workspace_id, true)
407 .await
408 }))
409 }
410
411 fn should_serialize(&self, event: &Self::Event) -> bool {
412 event == &ItemEvent::UpdateTab
413 }
414}
415
416mod persistence {
417 use db::{
418 query,
419 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
420 sqlez_macros::sql,
421 };
422 use workspace::WorkspaceDb;
423
424 pub struct WelcomePagesDb(ThreadSafeConnection);
425
426 impl Domain for WelcomePagesDb {
427 const NAME: &str = stringify!(WelcomePagesDb);
428
429 const MIGRATIONS: &[&str] = (&[sql!(
430 CREATE TABLE welcome_pages (
431 workspace_id INTEGER,
432 item_id INTEGER UNIQUE,
433 is_open INTEGER DEFAULT FALSE,
434
435 PRIMARY KEY(workspace_id, item_id),
436 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
437 ON DELETE CASCADE
438 ) STRICT;
439 )]);
440 }
441
442 db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
443
444 impl WelcomePagesDb {
445 query! {
446 pub async fn save_welcome_page(
447 item_id: workspace::ItemId,
448 workspace_id: workspace::WorkspaceId,
449 is_open: bool
450 ) -> Result<()> {
451 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
452 VALUES (?, ?, ?)
453 }
454 }
455
456 query! {
457 pub fn get_welcome_page(
458 item_id: workspace::ItemId,
459 workspace_id: workspace::WorkspaceId
460 ) -> Result<bool> {
461 SELECT is_open
462 FROM welcome_pages
463 WHERE item_id = ? AND workspace_id = ?
464 }
465 }
466 }
467}