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