1use crate::{
2 NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId,
3 item::{Item, ItemEvent},
4};
5use chrono::{DateTime, Utc};
6use git::Clone as GitClone;
7use gpui::WeakEntity;
8use gpui::{
9 Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
10 ParentElement, Render, Styled, Task, Window, actions,
11};
12use menu::{SelectNext, SelectPrevious};
13use project::DisableAiSettings;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use settings::Settings;
17use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
18use util::ResultExt;
19use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette};
20
21#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)]
22#[action(namespace = welcome)]
23#[serde(transparent)]
24pub struct OpenRecentProject {
25 pub index: usize,
26}
27
28actions!(
29 zed,
30 [
31 /// Show the Zed welcome screen
32 ShowWelcome
33 ]
34);
35
36#[derive(IntoElement)]
37struct SectionHeader {
38 title: SharedString,
39}
40
41impl SectionHeader {
42 fn new(title: impl Into<SharedString>) -> Self {
43 Self {
44 title: title.into(),
45 }
46 }
47}
48
49impl RenderOnce for SectionHeader {
50 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
51 h_flex()
52 .px_1()
53 .mb_2()
54 .gap_2()
55 .child(
56 Label::new(self.title.to_ascii_uppercase())
57 .buffer_font(cx)
58 .color(Color::Muted)
59 .size(LabelSize::XSmall),
60 )
61 .child(Divider::horizontal().color(DividerColor::BorderVariant))
62 }
63}
64
65#[derive(IntoElement)]
66struct SectionButton {
67 label: SharedString,
68 icon: IconName,
69 action: Box<dyn Action>,
70 tab_index: usize,
71 focus_handle: FocusHandle,
72}
73
74impl SectionButton {
75 fn new(
76 label: impl Into<SharedString>,
77 icon: IconName,
78 action: &dyn Action,
79 tab_index: usize,
80 focus_handle: FocusHandle,
81 ) -> Self {
82 Self {
83 label: label.into(),
84 icon,
85 action: action.boxed_clone(),
86 tab_index,
87 focus_handle,
88 }
89 }
90}
91
92impl RenderOnce for SectionButton {
93 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
94 let id = format!("onb-button-{}-{}", self.label, self.tab_index);
95 let action_ref: &dyn Action = &*self.action;
96
97 ButtonLike::new(id)
98 .tab_index(self.tab_index as isize)
99 .full_width()
100 .size(ButtonSize::Medium)
101 .child(
102 h_flex()
103 .w_full()
104 .justify_between()
105 .child(
106 h_flex()
107 .gap_2()
108 .child(
109 Icon::new(self.icon)
110 .color(Color::Muted)
111 .size(IconSize::Small),
112 )
113 .child(Label::new(self.label)),
114 )
115 .child(
116 KeyBinding::for_action_in(action_ref, &self.focus_handle, cx)
117 .size(rems_from_px(12.)),
118 ),
119 )
120 .on_click(move |_, window, cx| {
121 self.focus_handle.dispatch_action(&*self.action, window, cx)
122 })
123 }
124}
125
126enum SectionVisibility {
127 Always,
128 Conditional(fn(&App) -> bool),
129}
130
131impl SectionVisibility {
132 fn is_visible(&self, cx: &App) -> bool {
133 match self {
134 SectionVisibility::Always => true,
135 SectionVisibility::Conditional(f) => f(cx),
136 }
137 }
138}
139
140struct SectionEntry {
141 icon: IconName,
142 title: &'static str,
143 action: &'static dyn Action,
144 visibility_guard: SectionVisibility,
145}
146
147impl SectionEntry {
148 fn render(
149 &self,
150 button_index: usize,
151 focus: &FocusHandle,
152 cx: &App,
153 ) -> Option<impl IntoElement> {
154 self.visibility_guard.is_visible(cx).then(|| {
155 SectionButton::new(
156 self.title,
157 self.icon,
158 self.action,
159 button_index,
160 focus.clone(),
161 )
162 })
163 }
164}
165
166const CONTENT: (Section<4>, Section<3>) = (
167 Section {
168 title: "Get Started",
169 entries: [
170 SectionEntry {
171 icon: IconName::Plus,
172 title: "New File",
173 action: &NewFile,
174 visibility_guard: SectionVisibility::Always,
175 },
176 SectionEntry {
177 icon: IconName::FolderOpen,
178 title: "Open Project",
179 action: &Open::DEFAULT,
180 visibility_guard: SectionVisibility::Always,
181 },
182 SectionEntry {
183 icon: IconName::CloudDownload,
184 title: "Clone Repository",
185 action: &GitClone,
186 visibility_guard: SectionVisibility::Always,
187 },
188 SectionEntry {
189 icon: IconName::ListCollapse,
190 title: "Open Command Palette",
191 action: &command_palette::Toggle,
192 visibility_guard: SectionVisibility::Always,
193 },
194 ],
195 },
196 Section {
197 title: "Configure",
198 entries: [
199 SectionEntry {
200 icon: IconName::Settings,
201 title: "Open Settings",
202 action: &OpenSettings,
203 visibility_guard: SectionVisibility::Always,
204 },
205 SectionEntry {
206 icon: IconName::ZedAssistant,
207 title: "View AI Settings",
208 action: &agent::OpenSettings,
209 visibility_guard: SectionVisibility::Conditional(|cx| {
210 !DisableAiSettings::get_global(cx).disable_ai
211 }),
212 },
213 SectionEntry {
214 icon: IconName::Blocks,
215 title: "Explore Extensions",
216 action: &Extensions {
217 category_filter: None,
218 id: None,
219 },
220 visibility_guard: SectionVisibility::Always,
221 },
222 ],
223 },
224);
225
226struct Section<const COLS: usize> {
227 title: &'static str,
228 entries: [SectionEntry; COLS],
229}
230
231impl<const COLS: usize> Section<COLS> {
232 fn render(self, index_offset: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement {
233 v_flex()
234 .min_w_full()
235 .child(SectionHeader::new(self.title))
236 .children(
237 self.entries
238 .iter()
239 .enumerate()
240 .filter_map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
241 )
242 }
243}
244
245pub struct WelcomePage {
246 workspace: WeakEntity<Workspace>,
247 focus_handle: FocusHandle,
248 fallback_to_recent_projects: bool,
249 recent_workspaces: Option<
250 Vec<(
251 WorkspaceId,
252 SerializedWorkspaceLocation,
253 PathList,
254 DateTime<Utc>,
255 )>,
256 >,
257}
258
259impl WelcomePage {
260 pub fn new(
261 workspace: WeakEntity<Workspace>,
262 fallback_to_recent_projects: bool,
263 window: &mut Window,
264 cx: &mut Context<Self>,
265 ) -> Self {
266 let focus_handle = cx.focus_handle();
267 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
268 .detach();
269
270 if fallback_to_recent_projects {
271 let fs = workspace
272 .upgrade()
273 .map(|ws| ws.read(cx).app_state().fs.clone());
274 cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
275 let Some(fs) = fs else { return };
276 let workspaces = WORKSPACE_DB
277 .recent_workspaces_on_disk(fs.as_ref())
278 .await
279 .log_err()
280 .unwrap_or_default();
281
282 this.update(cx, |this, cx| {
283 this.recent_workspaces = Some(workspaces);
284 cx.notify();
285 })
286 .ok();
287 })
288 .detach();
289 }
290
291 WelcomePage {
292 workspace,
293 focus_handle,
294 fallback_to_recent_projects,
295 recent_workspaces: None,
296 }
297 }
298
299 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
300 window.focus_next(cx);
301 cx.notify();
302 }
303
304 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
305 window.focus_prev(cx);
306 cx.notify();
307 }
308
309 fn open_recent_project(
310 &mut self,
311 action: &OpenRecentProject,
312 window: &mut Window,
313 cx: &mut Context<Self>,
314 ) {
315 if let Some(recent_workspaces) = &self.recent_workspaces {
316 if let Some((_workspace_id, location, paths, _timestamp)) =
317 recent_workspaces.get(action.index)
318 {
319 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
320
321 if is_local {
322 let paths = paths.clone();
323 let paths = paths.paths().to_vec();
324 self.workspace
325 .update(cx, |workspace, cx| {
326 workspace
327 .open_workspace_for_paths(true, paths, window, cx)
328 .detach_and_log_err(cx);
329 })
330 .log_err();
331 } else {
332 use zed_actions::OpenRecent;
333 window.dispatch_action(OpenRecent::default().boxed_clone(), cx);
334 }
335 }
336 }
337 }
338
339 fn render_recent_project_section(
340 &self,
341 recent_projects: Vec<impl IntoElement>,
342 ) -> impl IntoElement {
343 v_flex()
344 .w_full()
345 .child(SectionHeader::new("Recent Projects"))
346 .children(recent_projects)
347 }
348
349 fn render_recent_project(
350 &self,
351 project_index: usize,
352 tab_index: usize,
353 location: &SerializedWorkspaceLocation,
354 paths: &PathList,
355 ) -> impl IntoElement {
356 let (icon, title) = match location {
357 SerializedWorkspaceLocation::Local => {
358 let path = paths.paths().first().map(|p| p.as_path());
359 let name = path
360 .and_then(|p| p.file_name())
361 .map(|n| n.to_string_lossy().to_string())
362 .unwrap_or_else(|| "Untitled".to_string());
363 (IconName::Folder, name)
364 }
365 SerializedWorkspaceLocation::Remote(_) => {
366 (IconName::Server, "Remote Project".to_string())
367 }
368 };
369
370 SectionButton::new(
371 title,
372 icon,
373 &OpenRecentProject {
374 index: project_index,
375 },
376 tab_index,
377 self.focus_handle.clone(),
378 )
379 }
380}
381
382impl Render for WelcomePage {
383 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
384 let (first_section, second_section) = CONTENT;
385 let first_section_entries = first_section.entries.len();
386 let last_index = first_section_entries + second_section.entries.len();
387
388 let recent_projects = self
389 .recent_workspaces
390 .as_ref()
391 .into_iter()
392 .flatten()
393 .take(5)
394 .enumerate()
395 .map(|(index, (_, loc, paths, _))| {
396 self.render_recent_project(index, first_section_entries + index, loc, paths)
397 })
398 .collect::<Vec<_>>();
399
400 let second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() {
401 self.render_recent_project_section(recent_projects)
402 .into_any_element()
403 } else {
404 second_section
405 .render(first_section_entries, &self.focus_handle, cx)
406 .into_any_element()
407 };
408
409 let welcome_label = if self.fallback_to_recent_projects {
410 "Welcome back to Zed"
411 } else {
412 "Welcome to Zed"
413 };
414
415 h_flex()
416 .key_context("Welcome")
417 .track_focus(&self.focus_handle(cx))
418 .on_action(cx.listener(Self::select_previous))
419 .on_action(cx.listener(Self::select_next))
420 .on_action(cx.listener(Self::open_recent_project))
421 .size_full()
422 .justify_center()
423 .overflow_hidden()
424 .bg(cx.theme().colors().editor_background)
425 .child(
426 h_flex()
427 .relative()
428 .size_full()
429 .px_12()
430 .max_w(px(1100.))
431 .child(
432 v_flex()
433 .flex_1()
434 .justify_center()
435 .max_w_128()
436 .mx_auto()
437 .gap_6()
438 .overflow_x_hidden()
439 .child(
440 h_flex()
441 .w_full()
442 .justify_center()
443 .mb_4()
444 .gap_4()
445 .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
446 .child(
447 v_flex().child(Headline::new(welcome_label)).child(
448 Label::new("The editor for what's next")
449 .size(LabelSize::Small)
450 .color(Color::Muted)
451 .italic(),
452 ),
453 ),
454 )
455 .child(first_section.render(Default::default(), &self.focus_handle, cx))
456 .child(second_section)
457 .when(!self.fallback_to_recent_projects, |this| {
458 this.child(
459 v_flex().gap_1().child(Divider::horizontal()).child(
460 Button::new("welcome-exit", "Return to Onboarding")
461 .tab_index(last_index as isize)
462 .full_width()
463 .label_size(LabelSize::XSmall)
464 .on_click(|_, window, cx| {
465 window.dispatch_action(
466 OpenOnboarding.boxed_clone(),
467 cx,
468 );
469 }),
470 ),
471 )
472 }),
473 ),
474 )
475 }
476}
477
478impl EventEmitter<ItemEvent> for WelcomePage {}
479
480impl Focusable for WelcomePage {
481 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
482 self.focus_handle.clone()
483 }
484}
485
486impl Item for WelcomePage {
487 type Event = ItemEvent;
488
489 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
490 "Welcome".into()
491 }
492
493 fn telemetry_event_text(&self) -> Option<&'static str> {
494 Some("New Welcome Page Opened")
495 }
496
497 fn show_toolbar(&self) -> bool {
498 false
499 }
500
501 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(crate::item::ItemEvent)) {
502 f(*event)
503 }
504}
505
506impl crate::SerializableItem for WelcomePage {
507 fn serialized_item_kind() -> &'static str {
508 "WelcomePage"
509 }
510
511 fn cleanup(
512 workspace_id: crate::WorkspaceId,
513 alive_items: Vec<crate::ItemId>,
514 _window: &mut Window,
515 cx: &mut App,
516 ) -> Task<gpui::Result<()>> {
517 crate::delete_unloaded_items(
518 alive_items,
519 workspace_id,
520 "welcome_pages",
521 &persistence::WELCOME_PAGES,
522 cx,
523 )
524 }
525
526 fn deserialize(
527 _project: Entity<project::Project>,
528 workspace: gpui::WeakEntity<Workspace>,
529 workspace_id: crate::WorkspaceId,
530 item_id: crate::ItemId,
531 window: &mut Window,
532 cx: &mut App,
533 ) -> Task<gpui::Result<Entity<Self>>> {
534 if persistence::WELCOME_PAGES
535 .get_welcome_page(item_id, workspace_id)
536 .ok()
537 .is_some_and(|is_open| is_open)
538 {
539 Task::ready(Ok(
540 cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
541 ))
542 } else {
543 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
544 }
545 }
546
547 fn serialize(
548 &mut self,
549 workspace: &mut Workspace,
550 item_id: crate::ItemId,
551 _closing: bool,
552 _window: &mut Window,
553 cx: &mut Context<Self>,
554 ) -> Option<Task<gpui::Result<()>>> {
555 let workspace_id = workspace.database_id()?;
556 Some(cx.background_spawn(async move {
557 persistence::WELCOME_PAGES
558 .save_welcome_page(item_id, workspace_id, true)
559 .await
560 }))
561 }
562
563 fn should_serialize(&self, event: &Self::Event) -> bool {
564 event == &ItemEvent::UpdateTab
565 }
566}
567
568mod persistence {
569 use crate::WorkspaceDb;
570 use db::{
571 query,
572 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
573 sqlez_macros::sql,
574 };
575
576 pub struct WelcomePagesDb(ThreadSafeConnection);
577
578 impl Domain for WelcomePagesDb {
579 const NAME: &str = stringify!(WelcomePagesDb);
580
581 const MIGRATIONS: &[&str] = (&[sql!(
582 CREATE TABLE welcome_pages (
583 workspace_id INTEGER,
584 item_id INTEGER UNIQUE,
585 is_open INTEGER DEFAULT FALSE,
586
587 PRIMARY KEY(workspace_id, item_id),
588 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
589 ON DELETE CASCADE
590 ) STRICT;
591 )]);
592 }
593
594 db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
595
596 impl WelcomePagesDb {
597 query! {
598 pub async fn save_welcome_page(
599 item_id: crate::ItemId,
600 workspace_id: crate::WorkspaceId,
601 is_open: bool
602 ) -> Result<()> {
603 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
604 VALUES (?, ?, ?)
605 }
606 }
607
608 query! {
609 pub fn get_welcome_page(
610 item_id: crate::ItemId,
611 workspace_id: crate::WorkspaceId
612 ) -> Result<bool> {
613 SELECT is_open
614 FROM welcome_pages
615 WHERE item_id = ? AND workspace_id = ?
616 }
617 }
618 }
619}