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