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 second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() {
449 self.render_recent_project_section(recent_projects)
450 .into_any_element()
451 } else {
452 second_section
453 .render(first_section_entries, &self.focus_handle)
454 .into_any_element()
455 };
456
457 let welcome_label = if self.fallback_to_recent_projects {
458 "Welcome back to Zed"
459 } else {
460 "Welcome to Zed"
461 };
462
463 h_flex()
464 .key_context("Welcome")
465 .track_focus(&self.focus_handle(cx))
466 .on_action(cx.listener(Self::select_previous))
467 .on_action(cx.listener(Self::select_next))
468 .on_action(cx.listener(Self::open_recent_project))
469 .size_full()
470 .bg(cx.theme().colors().editor_background)
471 .justify_center()
472 .child(
473 v_flex()
474 .id("welcome-content")
475 .p_8()
476 .max_w_128()
477 .size_full()
478 .gap_6()
479 .justify_center()
480 .overflow_y_scroll()
481 .child(
482 h_flex()
483 .w_full()
484 .justify_center()
485 .mb_4()
486 .gap_4()
487 .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
488 .child(
489 v_flex().child(Headline::new(welcome_label)).child(
490 Label::new("The editor for what's next")
491 .size(LabelSize::Small)
492 .color(Color::Muted)
493 .italic(),
494 ),
495 ),
496 )
497 .child(first_section.render(Default::default(), &self.focus_handle))
498 .child(second_section)
499 .when(ai_enabled, |this| {
500 let agent_tab_index = next_tab_index;
501 next_tab_index += 1;
502 this.child(self.render_agent_card(agent_tab_index, cx))
503 })
504 .when(!self.fallback_to_recent_projects, |this| {
505 this.child(
506 v_flex().gap_4().child(Divider::horizontal()).child(
507 Button::new("welcome-exit", "Return to Onboarding")
508 .tab_index(next_tab_index as isize)
509 .full_width()
510 .label_size(LabelSize::XSmall)
511 .on_click(|_, window, cx| {
512 window.dispatch_action(OpenOnboarding.boxed_clone(), cx);
513 }),
514 ),
515 )
516 }),
517 )
518 }
519}
520
521impl EventEmitter<ItemEvent> for WelcomePage {}
522
523impl Focusable for WelcomePage {
524 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
525 self.focus_handle.clone()
526 }
527}
528
529impl Item for WelcomePage {
530 type Event = ItemEvent;
531
532 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
533 "Welcome".into()
534 }
535
536 fn telemetry_event_text(&self) -> Option<&'static str> {
537 Some("New Welcome Page Opened")
538 }
539
540 fn show_toolbar(&self) -> bool {
541 false
542 }
543
544 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(crate::item::ItemEvent)) {
545 f(*event)
546 }
547}
548
549impl crate::SerializableItem for WelcomePage {
550 fn serialized_item_kind() -> &'static str {
551 "WelcomePage"
552 }
553
554 fn cleanup(
555 workspace_id: crate::WorkspaceId,
556 alive_items: Vec<crate::ItemId>,
557 _window: &mut Window,
558 cx: &mut App,
559 ) -> Task<gpui::Result<()>> {
560 crate::delete_unloaded_items(
561 alive_items,
562 workspace_id,
563 "welcome_pages",
564 &persistence::WelcomePagesDb::global(cx),
565 cx,
566 )
567 }
568
569 fn deserialize(
570 _project: Entity<project::Project>,
571 workspace: gpui::WeakEntity<Workspace>,
572 workspace_id: crate::WorkspaceId,
573 item_id: crate::ItemId,
574 window: &mut Window,
575 cx: &mut App,
576 ) -> Task<gpui::Result<Entity<Self>>> {
577 if persistence::WelcomePagesDb::global(cx)
578 .get_welcome_page(item_id, workspace_id)
579 .ok()
580 .is_some_and(|is_open| is_open)
581 {
582 Task::ready(Ok(
583 cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
584 ))
585 } else {
586 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
587 }
588 }
589
590 fn serialize(
591 &mut self,
592 workspace: &mut Workspace,
593 item_id: crate::ItemId,
594 _closing: bool,
595 _window: &mut Window,
596 cx: &mut Context<Self>,
597 ) -> Option<Task<gpui::Result<()>>> {
598 let workspace_id = workspace.database_id()?;
599 let db = persistence::WelcomePagesDb::global(cx);
600 Some(cx.background_spawn(
601 async move { db.save_welcome_page(item_id, workspace_id, true).await },
602 ))
603 }
604
605 fn should_serialize(&self, event: &Self::Event) -> bool {
606 event == &ItemEvent::UpdateTab
607 }
608}
609
610mod persistence {
611 use crate::WorkspaceDb;
612 use db::{
613 query,
614 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
615 sqlez_macros::sql,
616 };
617
618 pub struct WelcomePagesDb(ThreadSafeConnection);
619
620 impl Domain for WelcomePagesDb {
621 const NAME: &str = stringify!(WelcomePagesDb);
622
623 const MIGRATIONS: &[&str] = (&[sql!(
624 CREATE TABLE welcome_pages (
625 workspace_id INTEGER,
626 item_id INTEGER UNIQUE,
627 is_open INTEGER DEFAULT FALSE,
628
629 PRIMARY KEY(workspace_id, item_id),
630 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
631 ON DELETE CASCADE
632 ) STRICT;
633 )]);
634 }
635
636 db::static_connection!(WelcomePagesDb, [WorkspaceDb]);
637
638 impl WelcomePagesDb {
639 query! {
640 pub async fn save_welcome_page(
641 item_id: crate::ItemId,
642 workspace_id: crate::WorkspaceId,
643 is_open: bool
644 ) -> Result<()> {
645 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
646 VALUES (?, ?, ?)
647 }
648 }
649
650 query! {
651 pub fn get_welcome_page(
652 item_id: crate::ItemId,
653 workspace_id: crate::WorkspaceId
654 ) -> Result<bool> {
655 SELECT is_open
656 FROM welcome_pages
657 WHERE item_id = ? AND workspace_id = ?
658 }
659 }
660 }
661}