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