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_project_workspaces(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 name = project_name(paths);
403
404 let (icon, title) = match location {
405 SerializedWorkspaceLocation::Local => (IconName::Folder, name),
406 SerializedWorkspaceLocation::Remote(_) => (IconName::Server, name),
407 };
408
409 SectionButton::new(
410 title,
411 icon,
412 &OpenRecentProject {
413 index: project_index,
414 },
415 tab_index,
416 self.focus_handle.clone(),
417 )
418 }
419}
420
421impl Render for WelcomePage {
422 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
423 let (first_section, second_section) = CONTENT;
424 let first_section_entries = first_section.entries.len();
425 let mut next_tab_index = first_section_entries + second_section.entries.len();
426
427 let ai_enabled = AgentSettings::get_global(cx).enabled(cx);
428
429 let recent_projects = self
430 .recent_workspaces
431 .as_ref()
432 .into_iter()
433 .flatten()
434 .take(5)
435 .enumerate()
436 .map(|(index, (_, loc, paths, _))| {
437 self.render_recent_project(index, first_section_entries + index, loc, paths)
438 })
439 .collect::<Vec<_>>();
440
441 let showing_recent_projects =
442 self.fallback_to_recent_projects && !recent_projects.is_empty();
443 let second_section = if showing_recent_projects {
444 self.render_recent_project_section(recent_projects)
445 .into_any_element()
446 } else {
447 second_section
448 .render(first_section_entries, &self.focus_handle)
449 .into_any_element()
450 };
451
452 let welcome_label = if self.fallback_to_recent_projects {
453 "Welcome back to Zed"
454 } else {
455 "Welcome to Zed"
456 };
457
458 h_flex()
459 .key_context("Welcome")
460 .track_focus(&self.focus_handle(cx))
461 .on_action(cx.listener(Self::select_previous))
462 .on_action(cx.listener(Self::select_next))
463 .on_action(cx.listener(Self::open_recent_project))
464 .size_full()
465 .bg(cx.theme().colors().editor_background)
466 .justify_center()
467 .child(
468 v_flex()
469 .id("welcome-content")
470 .p_8()
471 .max_w_128()
472 .size_full()
473 .gap_6()
474 .justify_center()
475 .overflow_y_scroll()
476 .child(
477 h_flex()
478 .w_full()
479 .justify_center()
480 .mb_4()
481 .gap_4()
482 .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
483 .child(
484 v_flex().child(Headline::new(welcome_label)).child(
485 Label::new("The editor for what's next")
486 .size(LabelSize::Small)
487 .color(Color::Muted)
488 .italic(),
489 ),
490 ),
491 )
492 .child(first_section.render(Default::default(), &self.focus_handle))
493 .child(second_section)
494 .when(ai_enabled && !showing_recent_projects, |this| {
495 let agent_tab_index = next_tab_index;
496 next_tab_index += 1;
497 this.child(self.render_agent_card(agent_tab_index, cx))
498 })
499 .when(!self.fallback_to_recent_projects, |this| {
500 this.child(
501 v_flex().gap_4().child(Divider::horizontal()).child(
502 Button::new("welcome-exit", "Return to Onboarding")
503 .tab_index(next_tab_index as isize)
504 .full_width()
505 .label_size(LabelSize::XSmall)
506 .on_click(|_, window, cx| {
507 window.dispatch_action(OpenOnboarding.boxed_clone(), cx);
508 }),
509 ),
510 )
511 }),
512 )
513 }
514}
515
516impl EventEmitter<ItemEvent> for WelcomePage {}
517
518impl Focusable for WelcomePage {
519 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
520 self.focus_handle.clone()
521 }
522}
523
524impl Item for WelcomePage {
525 type Event = ItemEvent;
526
527 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
528 "Welcome".into()
529 }
530
531 fn telemetry_event_text(&self) -> Option<&'static str> {
532 Some("New Welcome Page Opened")
533 }
534
535 fn show_toolbar(&self) -> bool {
536 false
537 }
538
539 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(crate::item::ItemEvent)) {
540 f(*event)
541 }
542}
543
544impl crate::SerializableItem for WelcomePage {
545 fn serialized_item_kind() -> &'static str {
546 "WelcomePage"
547 }
548
549 fn cleanup(
550 workspace_id: crate::WorkspaceId,
551 alive_items: Vec<crate::ItemId>,
552 _window: &mut Window,
553 cx: &mut App,
554 ) -> Task<gpui::Result<()>> {
555 crate::delete_unloaded_items(
556 alive_items,
557 workspace_id,
558 "welcome_pages",
559 &persistence::WelcomePagesDb::global(cx),
560 cx,
561 )
562 }
563
564 fn deserialize(
565 _project: Entity<project::Project>,
566 workspace: gpui::WeakEntity<Workspace>,
567 workspace_id: crate::WorkspaceId,
568 item_id: crate::ItemId,
569 window: &mut Window,
570 cx: &mut App,
571 ) -> Task<gpui::Result<Entity<Self>>> {
572 if persistence::WelcomePagesDb::global(cx)
573 .get_welcome_page(item_id, workspace_id)
574 .ok()
575 .is_some_and(|is_open| is_open)
576 {
577 Task::ready(Ok(
578 cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
579 ))
580 } else {
581 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
582 }
583 }
584
585 fn serialize(
586 &mut self,
587 workspace: &mut Workspace,
588 item_id: crate::ItemId,
589 _closing: bool,
590 _window: &mut Window,
591 cx: &mut Context<Self>,
592 ) -> Option<Task<gpui::Result<()>>> {
593 let workspace_id = workspace.database_id()?;
594 let db = persistence::WelcomePagesDb::global(cx);
595 Some(cx.background_spawn(
596 async move { db.save_welcome_page(item_id, workspace_id, true).await },
597 ))
598 }
599
600 fn should_serialize(&self, event: &Self::Event) -> bool {
601 event == &ItemEvent::UpdateTab
602 }
603}
604
605mod persistence {
606 use crate::WorkspaceDb;
607 use db::{
608 query,
609 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
610 sqlez_macros::sql,
611 };
612
613 pub struct WelcomePagesDb(ThreadSafeConnection);
614
615 impl Domain for WelcomePagesDb {
616 const NAME: &str = stringify!(WelcomePagesDb);
617
618 const MIGRATIONS: &[&str] = (&[sql!(
619 CREATE TABLE welcome_pages (
620 workspace_id INTEGER,
621 item_id INTEGER UNIQUE,
622 is_open INTEGER DEFAULT FALSE,
623
624 PRIMARY KEY(workspace_id, item_id),
625 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
626 ON DELETE CASCADE
627 ) STRICT;
628 )]);
629 }
630
631 db::static_connection!(WelcomePagesDb, [WorkspaceDb]);
632
633 impl WelcomePagesDb {
634 query! {
635 pub async fn save_welcome_page(
636 item_id: crate::ItemId,
637 workspace_id: crate::WorkspaceId,
638 is_open: bool
639 ) -> Result<()> {
640 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
641 VALUES (?, ?, ?)
642 }
643 }
644
645 query! {
646 pub fn get_welcome_page(
647 item_id: crate::ItemId,
648 workspace_id: crate::WorkspaceId
649 ) -> Result<bool> {
650 SELECT is_open
651 FROM welcome_pages
652 WHERE item_id = ? AND workspace_id = ?
653 }
654 }
655 }
656}
657
658fn project_name(paths: &PathList) -> String {
659 let joined = paths
660 .paths()
661 .iter()
662 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
663 .collect::<Vec<_>>()
664 .join(", ");
665 if joined.is_empty() {
666 "Untitled".to_string()
667 } else {
668 joined
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn test_project_name_empty() {
678 let paths = PathList::new::<&str>(&[]);
679 assert_eq!(project_name(&paths), "Untitled");
680 }
681
682 #[test]
683 fn test_project_name_single() {
684 let paths = PathList::new(&["/home/user/my-project"]);
685 assert_eq!(project_name(&paths), "my-project");
686 }
687
688 #[test]
689 fn test_project_name_multiple() {
690 // PathList sorts lexicographically, so filenames appear in alpha order
691 let paths = PathList::new(&["/home/user/zed", "/home/user/api"]);
692 assert_eq!(project_name(&paths), "api, zed");
693 }
694
695 #[test]
696 fn test_project_name_root_path_filtered() {
697 // A bare root "/" has no file_name(), falls back to "Untitled"
698 let paths = PathList::new(&["/"]);
699 assert_eq!(project_name(&paths), "Untitled");
700 }
701}