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