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