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