1use gpui::{
2 Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
3 ParentElement, Render, Styled, Task, Window, actions,
4};
5use menu::{SelectNext, SelectPrevious};
6use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
7use workspace::{
8 NewFile, Open,
9 item::{Item, ItemEvent},
10 with_active_or_new_workspace,
11};
12use zed_actions::{Extensions, OpenSettings, agent, command_palette};
13
14use crate::{Onboarding, OpenOnboarding};
15
16actions!(
17 zed,
18 [
19 /// Show the Zed welcome screen
20 ShowWelcome
21 ]
22);
23
24const CONTENT: (Section<4>, Section<3>) = (
25 Section {
26 title: "Get Started",
27 entries: [
28 SectionEntry {
29 icon: IconName::Plus,
30 title: "New File",
31 action: &NewFile,
32 },
33 SectionEntry {
34 icon: IconName::FolderOpen,
35 title: "Open Project",
36 action: &Open,
37 },
38 SectionEntry {
39 icon: IconName::CloudDownload,
40 title: "Clone Repository",
41 action: &git::Clone,
42 },
43 SectionEntry {
44 icon: IconName::ListCollapse,
45 title: "Open Command Palette",
46 action: &command_palette::Toggle,
47 },
48 ],
49 },
50 Section {
51 title: "Configure",
52 entries: [
53 SectionEntry {
54 icon: IconName::Settings,
55 title: "Open Settings",
56 action: &OpenSettings,
57 },
58 SectionEntry {
59 icon: IconName::ZedAssistant,
60 title: "View AI Settings",
61 action: &agent::OpenSettings,
62 },
63 SectionEntry {
64 icon: IconName::Blocks,
65 title: "Explore Extensions",
66 action: &Extensions {
67 category_filter: None,
68 id: None,
69 },
70 },
71 ],
72 },
73);
74
75struct Section<const COLS: usize> {
76 title: &'static str,
77 entries: [SectionEntry; COLS],
78}
79
80impl<const COLS: usize> Section<COLS> {
81 fn render(self, index_offset: usize, focus: &FocusHandle, cx: &mut App) -> impl IntoElement {
82 v_flex()
83 .min_w_full()
84 .child(
85 h_flex()
86 .px_1()
87 .mb_2()
88 .gap_2()
89 .child(
90 Label::new(self.title.to_ascii_uppercase())
91 .buffer_font(cx)
92 .color(Color::Muted)
93 .size(LabelSize::XSmall),
94 )
95 .child(Divider::horizontal().color(DividerColor::BorderVariant)),
96 )
97 .children(
98 self.entries
99 .iter()
100 .enumerate()
101 .map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
102 )
103 }
104}
105
106struct SectionEntry {
107 icon: IconName,
108 title: &'static str,
109 action: &'static dyn Action,
110}
111
112impl SectionEntry {
113 fn render(&self, button_index: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement {
114 ButtonLike::new(("onboarding-button-id", button_index))
115 .tab_index(button_index as isize)
116 .full_width()
117 .size(ButtonSize::Medium)
118 .child(
119 h_flex()
120 .w_full()
121 .justify_between()
122 .child(
123 h_flex()
124 .gap_2()
125 .child(
126 Icon::new(self.icon)
127 .color(Color::Muted)
128 .size(IconSize::XSmall),
129 )
130 .child(Label::new(self.title)),
131 )
132 .child(
133 KeyBinding::for_action_in(self.action, focus, cx).size(rems_from_px(12.)),
134 ),
135 )
136 .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx))
137 }
138}
139
140pub struct WelcomePage {
141 focus_handle: FocusHandle,
142}
143
144impl WelcomePage {
145 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
146 window.focus_next();
147 cx.notify();
148 }
149
150 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
151 window.focus_prev();
152 cx.notify();
153 }
154}
155
156impl Render for WelcomePage {
157 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
158 let (first_section, second_section) = CONTENT;
159 let first_section_entries = first_section.entries.len();
160 let last_index = first_section_entries + second_section.entries.len();
161
162 h_flex()
163 .size_full()
164 .justify_center()
165 .overflow_hidden()
166 .bg(cx.theme().colors().editor_background)
167 .key_context("Welcome")
168 .track_focus(&self.focus_handle(cx))
169 .on_action(cx.listener(Self::select_previous))
170 .on_action(cx.listener(Self::select_next))
171 .child(
172 h_flex()
173 .px_12()
174 .py_40()
175 .size_full()
176 .relative()
177 .max_w(px(1100.))
178 .child(
179 div()
180 .size_full()
181 .max_w_128()
182 .mx_auto()
183 .child(
184 h_flex()
185 .w_full()
186 .justify_center()
187 .gap_4()
188 .child(Vector::square(VectorName::ZedLogo, rems(2.)))
189 .child(
190 div().child(Headline::new("Welcome to Zed")).child(
191 Label::new("The editor for what's next")
192 .size(LabelSize::Small)
193 .color(Color::Muted)
194 .italic(),
195 ),
196 ),
197 )
198 .child(
199 v_flex()
200 .mt_10()
201 .gap_6()
202 .child(first_section.render(
203 Default::default(),
204 &self.focus_handle,
205 cx,
206 ))
207 .child(second_section.render(
208 first_section_entries,
209 &self.focus_handle,
210 cx,
211 ))
212 .child(
213 h_flex()
214 .w_full()
215 .pt_4()
216 .justify_center()
217 // We call this a hack
218 .rounded_b_xs()
219 .border_t_1()
220 .border_color(cx.theme().colors().border.opacity(0.6))
221 .border_dashed()
222 .child(
223 Button::new("welcome-exit", "Return to Setup")
224 .tab_index(last_index as isize)
225 .full_width()
226 .label_size(LabelSize::XSmall)
227 .on_click(|_, window, cx| {
228 window.dispatch_action(
229 OpenOnboarding.boxed_clone(),
230 cx,
231 );
232
233 with_active_or_new_workspace(cx, |workspace, window, cx| {
234 let Some((welcome_id, welcome_idx)) = workspace
235 .active_pane()
236 .read(cx)
237 .items()
238 .enumerate()
239 .find_map(|(idx, item)| {
240 let _ = item.downcast::<WelcomePage>()?;
241 Some((item.item_id(), idx))
242 })
243 else {
244 return;
245 };
246
247 workspace.active_pane().update(cx, |pane, cx| {
248 // Get the index here to get around the borrow checker
249 let idx = pane.items().enumerate().find_map(
250 |(idx, item)| {
251 let _ =
252 item.downcast::<Onboarding>()?;
253 Some(idx)
254 },
255 );
256
257 if let Some(idx) = idx {
258 pane.activate_item(
259 idx, true, true, window, cx,
260 );
261 } else {
262 let item =
263 Box::new(Onboarding::new(workspace, cx));
264 pane.add_item(
265 item,
266 true,
267 true,
268 Some(welcome_idx),
269 window,
270 cx,
271 );
272 }
273
274 pane.remove_item(
275 welcome_id,
276 false,
277 false,
278 window,
279 cx,
280 );
281 });
282 });
283 }),
284 ),
285 ),
286 ),
287 ),
288 )
289 }
290}
291
292impl WelcomePage {
293 pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
294 cx.new(|cx| {
295 let focus_handle = cx.focus_handle();
296 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
297 .detach();
298
299 WelcomePage { focus_handle }
300 })
301 }
302}
303
304impl EventEmitter<ItemEvent> for WelcomePage {}
305
306impl Focusable for WelcomePage {
307 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
308 self.focus_handle.clone()
309 }
310}
311
312impl Item for WelcomePage {
313 type Event = ItemEvent;
314
315 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
316 "Welcome".into()
317 }
318
319 fn telemetry_event_text(&self) -> Option<&'static str> {
320 Some("New Welcome Page Opened")
321 }
322
323 fn show_toolbar(&self) -> bool {
324 false
325 }
326
327 fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
328 f(*event)
329 }
330}
331
332impl workspace::SerializableItem for WelcomePage {
333 fn serialized_item_kind() -> &'static str {
334 "WelcomePage"
335 }
336
337 fn cleanup(
338 workspace_id: workspace::WorkspaceId,
339 alive_items: Vec<workspace::ItemId>,
340 _window: &mut Window,
341 cx: &mut App,
342 ) -> Task<gpui::Result<()>> {
343 workspace::delete_unloaded_items(
344 alive_items,
345 workspace_id,
346 "welcome_pages",
347 &persistence::WELCOME_PAGES,
348 cx,
349 )
350 }
351
352 fn deserialize(
353 _project: Entity<project::Project>,
354 _workspace: gpui::WeakEntity<workspace::Workspace>,
355 workspace_id: workspace::WorkspaceId,
356 item_id: workspace::ItemId,
357 window: &mut Window,
358 cx: &mut App,
359 ) -> Task<gpui::Result<Entity<Self>>> {
360 if persistence::WELCOME_PAGES
361 .get_welcome_page(item_id, workspace_id)
362 .ok()
363 .is_some_and(|is_open| is_open)
364 {
365 window.spawn(cx, async move |cx| cx.update(WelcomePage::new))
366 } else {
367 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
368 }
369 }
370
371 fn serialize(
372 &mut self,
373 workspace: &mut workspace::Workspace,
374 item_id: workspace::ItemId,
375 _closing: bool,
376 _window: &mut Window,
377 cx: &mut Context<Self>,
378 ) -> Option<Task<gpui::Result<()>>> {
379 let workspace_id = workspace.database_id()?;
380 Some(cx.background_spawn(async move {
381 persistence::WELCOME_PAGES
382 .save_welcome_page(item_id, workspace_id, true)
383 .await
384 }))
385 }
386
387 fn should_serialize(&self, event: &Self::Event) -> bool {
388 event == &ItemEvent::UpdateTab
389 }
390}
391
392mod persistence {
393 use db::{
394 query,
395 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
396 sqlez_macros::sql,
397 };
398 use workspace::WorkspaceDb;
399
400 pub struct WelcomePagesDb(ThreadSafeConnection);
401
402 impl Domain for WelcomePagesDb {
403 const NAME: &str = stringify!(WelcomePagesDb);
404
405 const MIGRATIONS: &[&str] = (&[sql!(
406 CREATE TABLE welcome_pages (
407 workspace_id INTEGER,
408 item_id INTEGER UNIQUE,
409 is_open INTEGER DEFAULT FALSE,
410
411 PRIMARY KEY(workspace_id, item_id),
412 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
413 ON DELETE CASCADE
414 ) STRICT;
415 )]);
416 }
417
418 db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
419
420 impl WelcomePagesDb {
421 query! {
422 pub async fn save_welcome_page(
423 item_id: workspace::ItemId,
424 workspace_id: workspace::WorkspaceId,
425 is_open: bool
426 ) -> Result<()> {
427 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
428 VALUES (?, ?, ?)
429 }
430 }
431
432 query! {
433 pub fn get_welcome_page(
434 item_id: workspace::ItemId,
435 workspace_id: workspace::WorkspaceId
436 ) -> Result<bool> {
437 SELECT is_open
438 FROM welcome_pages
439 WHERE item_id = ? AND workspace_id = ?
440 }
441 }
442 }
443}