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, self.tab_index);
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 project_index: usize,
309 tab_index: usize,
310 location: &SerializedWorkspaceLocation,
311 paths: &PathList,
312 ) -> impl IntoElement {
313 let (icon, title) = match location {
314 SerializedWorkspaceLocation::Local => {
315 let path = paths.paths().first().map(|p| p.as_path());
316 let name = path
317 .and_then(|p| p.file_name())
318 .map(|n| n.to_string_lossy().to_string())
319 .unwrap_or_else(|| "Untitled".to_string());
320 (IconName::Folder, name)
321 }
322 SerializedWorkspaceLocation::Remote(_) => {
323 (IconName::Server, "Remote Project".to_string())
324 }
325 };
326
327 SectionButton::new(
328 title,
329 icon,
330 &OpenRecentProject {
331 index: project_index,
332 },
333 tab_index,
334 self.focus_handle.clone(),
335 )
336 }
337}
338
339impl Render for WelcomePage {
340 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
341 let (first_section, second_section) = CONTENT;
342 let first_section_entries = first_section.entries.len();
343 let last_index = first_section_entries + second_section.entries.len();
344
345 let recent_projects = self
346 .recent_workspaces
347 .as_ref()
348 .into_iter()
349 .flatten()
350 .take(5)
351 .enumerate()
352 .map(|(index, (_, loc, paths))| {
353 self.render_recent_project(index, first_section_entries + index, loc, paths)
354 })
355 .collect::<Vec<_>>();
356
357 let second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() {
358 self.render_recent_project_section(recent_projects)
359 .into_any_element()
360 } else {
361 second_section
362 .render(first_section_entries, &self.focus_handle, cx)
363 .into_any_element()
364 };
365
366 let welcome_label = if self.fallback_to_recent_projects {
367 "Welcome back to Zed"
368 } else {
369 "Welcome to Zed"
370 };
371
372 h_flex()
373 .key_context("Welcome")
374 .track_focus(&self.focus_handle(cx))
375 .on_action(cx.listener(Self::select_previous))
376 .on_action(cx.listener(Self::select_next))
377 .on_action(cx.listener(Self::open_recent_project))
378 .size_full()
379 .justify_center()
380 .overflow_hidden()
381 .bg(cx.theme().colors().editor_background)
382 .child(
383 h_flex()
384 .relative()
385 .size_full()
386 .px_12()
387 .max_w(px(1100.))
388 .child(
389 v_flex()
390 .flex_1()
391 .justify_center()
392 .max_w_128()
393 .mx_auto()
394 .gap_6()
395 .overflow_x_hidden()
396 .child(
397 h_flex()
398 .w_full()
399 .justify_center()
400 .mb_4()
401 .gap_4()
402 .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
403 .child(
404 v_flex().child(Headline::new(welcome_label)).child(
405 Label::new("The editor for what's next")
406 .size(LabelSize::Small)
407 .color(Color::Muted)
408 .italic(),
409 ),
410 ),
411 )
412 .child(first_section.render(Default::default(), &self.focus_handle, cx))
413 .child(second_section)
414 .when(!self.fallback_to_recent_projects, |this| {
415 this.child(
416 v_flex().gap_1().child(Divider::horizontal()).child(
417 Button::new("welcome-exit", "Return to Onboarding")
418 .tab_index(last_index as isize)
419 .full_width()
420 .label_size(LabelSize::XSmall)
421 .on_click(|_, window, cx| {
422 window.dispatch_action(
423 OpenOnboarding.boxed_clone(),
424 cx,
425 );
426 }),
427 ),
428 )
429 }),
430 ),
431 )
432 }
433}
434
435impl EventEmitter<ItemEvent> for WelcomePage {}
436
437impl Focusable for WelcomePage {
438 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
439 self.focus_handle.clone()
440 }
441}
442
443impl Item for WelcomePage {
444 type Event = ItemEvent;
445
446 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
447 "Welcome".into()
448 }
449
450 fn telemetry_event_text(&self) -> Option<&'static str> {
451 Some("New Welcome Page Opened")
452 }
453
454 fn show_toolbar(&self) -> bool {
455 false
456 }
457
458 fn to_item_events(event: &Self::Event, mut f: impl FnMut(crate::item::ItemEvent)) {
459 f(*event)
460 }
461}
462
463impl crate::SerializableItem for WelcomePage {
464 fn serialized_item_kind() -> &'static str {
465 "WelcomePage"
466 }
467
468 fn cleanup(
469 workspace_id: crate::WorkspaceId,
470 alive_items: Vec<crate::ItemId>,
471 _window: &mut Window,
472 cx: &mut App,
473 ) -> Task<gpui::Result<()>> {
474 crate::delete_unloaded_items(
475 alive_items,
476 workspace_id,
477 "welcome_pages",
478 &persistence::WELCOME_PAGES,
479 cx,
480 )
481 }
482
483 fn deserialize(
484 _project: Entity<project::Project>,
485 workspace: gpui::WeakEntity<Workspace>,
486 workspace_id: crate::WorkspaceId,
487 item_id: crate::ItemId,
488 window: &mut Window,
489 cx: &mut App,
490 ) -> Task<gpui::Result<Entity<Self>>> {
491 if persistence::WELCOME_PAGES
492 .get_welcome_page(item_id, workspace_id)
493 .ok()
494 .is_some_and(|is_open| is_open)
495 {
496 Task::ready(Ok(
497 cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
498 ))
499 } else {
500 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
501 }
502 }
503
504 fn serialize(
505 &mut self,
506 workspace: &mut Workspace,
507 item_id: crate::ItemId,
508 _closing: bool,
509 _window: &mut Window,
510 cx: &mut Context<Self>,
511 ) -> Option<Task<gpui::Result<()>>> {
512 let workspace_id = workspace.database_id()?;
513 Some(cx.background_spawn(async move {
514 persistence::WELCOME_PAGES
515 .save_welcome_page(item_id, workspace_id, true)
516 .await
517 }))
518 }
519
520 fn should_serialize(&self, event: &Self::Event) -> bool {
521 event == &ItemEvent::UpdateTab
522 }
523}
524
525mod persistence {
526 use crate::WorkspaceDb;
527 use db::{
528 query,
529 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
530 sqlez_macros::sql,
531 };
532
533 pub struct WelcomePagesDb(ThreadSafeConnection);
534
535 impl Domain for WelcomePagesDb {
536 const NAME: &str = stringify!(WelcomePagesDb);
537
538 const MIGRATIONS: &[&str] = (&[sql!(
539 CREATE TABLE welcome_pages (
540 workspace_id INTEGER,
541 item_id INTEGER UNIQUE,
542 is_open INTEGER DEFAULT FALSE,
543
544 PRIMARY KEY(workspace_id, item_id),
545 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
546 ON DELETE CASCADE
547 ) STRICT;
548 )]);
549 }
550
551 db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
552
553 impl WelcomePagesDb {
554 query! {
555 pub async fn save_welcome_page(
556 item_id: crate::ItemId,
557 workspace_id: crate::WorkspaceId,
558 is_open: bool
559 ) -> Result<()> {
560 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
561 VALUES (?, ?, ?)
562 }
563 }
564
565 query! {
566 pub fn get_welcome_page(
567 item_id: crate::ItemId,
568 workspace_id: crate::WorkspaceId
569 ) -> Result<bool> {
570 SELECT is_open
571 FROM welcome_pages
572 WHERE item_id = ? AND workspace_id = ?
573 }
574 }
575 }
576}