Skip to repository content717 lines · 23.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:55:15.045Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
welcome.rs
1use crate::{
2 NewFile, Open, OpenMode, PathList, RecentWorkspace, SerializedWorkspaceLocation,
3 ToggleWorkspaceSidebar, Workspace, WorkspaceSettings,
4 item::{Item, ItemEvent},
5 persistence::WorkspaceDb,
6};
7use agent_settings::AgentSettings;
8use git::Clone as GitClone;
9use gpui::{
10 Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
11 ParentElement, Render, Styled, Task, TaskExt, Window, actions,
12};
13use gpui::{WeakEntity, linear_color_stop, linear_gradient};
14use menu::{SelectNext, SelectPrevious};
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use settings::{DefaultOpenBehavior, Settings};
19use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
20use util::ResultExt;
21use zed_actions::{
22 Extensions, OpenEditorOnboarding, OpenKeymap, OpenSettings, assistant::ToggleFocus,
23 command_palette,
24};
25
26#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)]
27#[action(namespace = welcome)]
28#[serde(transparent)]
29pub struct OpenRecentProject {
30 pub index: usize,
31}
32
33actions!(
34 omega,
35 [
36 /// Show the Omega welcome screen
37 #[action(deprecated_aliases = ["zed::ShowWelcome"])]
38 ShowWelcome
39 ]
40);
41
42#[derive(IntoElement)]
43struct SectionHeader {
44 title: SharedString,
45}
46
47impl SectionHeader {
48 fn new(title: impl Into<SharedString>) -> Self {
49 Self {
50 title: title.into(),
51 }
52 }
53}
54
55impl RenderOnce for SectionHeader {
56 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
57 h_flex()
58 .px_1()
59 .mb_2()
60 .gap_2()
61 .child(
62 Label::new(self.title.to_ascii_uppercase())
63 .buffer_font(cx)
64 .color(Color::Muted)
65 .size(LabelSize::XSmall),
66 )
67 .child(Divider::horizontal().color(DividerColor::BorderVariant))
68 }
69}
70
71#[derive(IntoElement)]
72struct SectionButton {
73 label: SharedString,
74 icon: IconName,
75 action: Box<dyn Action>,
76 tab_index: usize,
77 focus_handle: FocusHandle,
78}
79
80impl SectionButton {
81 fn new(
82 label: impl Into<SharedString>,
83 icon: IconName,
84 action: &dyn Action,
85 tab_index: usize,
86 focus_handle: FocusHandle,
87 ) -> Self {
88 Self {
89 label: label.into(),
90 icon,
91 action: action.boxed_clone(),
92 tab_index,
93 focus_handle,
94 }
95 }
96}
97
98impl RenderOnce for SectionButton {
99 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
100 let id = format!("onb-button-{}-{}", self.label, self.tab_index);
101 let action_ref: &dyn Action = &*self.action;
102
103 ButtonLike::new(id)
104 .tab_index(self.tab_index as isize)
105 .full_width()
106 .size(ButtonSize::Medium)
107 .child(
108 h_flex()
109 .w_full()
110 .justify_between()
111 .child(
112 h_flex()
113 .gap_2()
114 .child(
115 Icon::new(self.icon)
116 .color(Color::Muted)
117 .size(IconSize::Small),
118 )
119 .child(Label::new(self.label)),
120 )
121 .child(
122 KeyBinding::for_action_in(action_ref, &self.focus_handle, cx)
123 .size(rems_from_px(12.)),
124 ),
125 )
126 .on_click(move |_, window, cx| {
127 self.focus_handle.dispatch_action(&*self.action, window, cx)
128 })
129 }
130}
131
132enum SectionVisibility {
133 Always,
134}
135
136impl SectionVisibility {
137 fn is_visible(&self) -> bool {
138 match self {
139 SectionVisibility::Always => true,
140 }
141 }
142}
143
144struct SectionEntry {
145 icon: IconName,
146 title: &'static str,
147 action: &'static dyn Action,
148 visibility_guard: SectionVisibility,
149}
150
151impl SectionEntry {
152 fn render(&self, button_index: usize, focus: &FocusHandle) -> Option<impl IntoElement> {
153 self.visibility_guard.is_visible().then(|| {
154 SectionButton::new(
155 self.title,
156 self.icon,
157 self.action,
158 button_index,
159 focus.clone(),
160 )
161 })
162 }
163}
164
165const CONTENT: (Section<4>, Section<3>) = (
166 Section {
167 title: "Get Started",
168 entries: [
169 SectionEntry {
170 icon: IconName::Plus,
171 title: "New File",
172 action: &NewFile,
173 visibility_guard: SectionVisibility::Always,
174 },
175 SectionEntry {
176 icon: IconName::FolderOpen,
177 title: "Open Project",
178 action: &Open::DEFAULT,
179 visibility_guard: SectionVisibility::Always,
180 },
181 SectionEntry {
182 icon: IconName::CloudDownload,
183 title: "Clone Repository",
184 action: &GitClone,
185 visibility_guard: SectionVisibility::Always,
186 },
187 SectionEntry {
188 icon: IconName::ListCollapse,
189 title: "Open Command Palette",
190 action: &command_palette::Toggle,
191 visibility_guard: SectionVisibility::Always,
192 },
193 ],
194 },
195 Section {
196 title: "Configure",
197 entries: [
198 SectionEntry {
199 icon: IconName::Settings,
200 title: "Open Settings",
201 action: &OpenSettings,
202 visibility_guard: SectionVisibility::Always,
203 },
204 SectionEntry {
205 icon: IconName::Keyboard,
206 title: "Customize Keymaps",
207 action: &OpenKeymap,
208 visibility_guard: SectionVisibility::Always,
209 },
210 SectionEntry {
211 icon: IconName::Blocks,
212 title: "Explore Extensions",
213 action: &Extensions {
214 category_filter: None,
215 id: None,
216 },
217 visibility_guard: SectionVisibility::Always,
218 },
219 ],
220 },
221);
222
223struct Section<const COLS: usize> {
224 title: &'static str,
225 entries: [SectionEntry; COLS],
226}
227
228impl<const COLS: usize> Section<COLS> {
229 fn render(self, index_offset: usize, focus: &FocusHandle) -> impl IntoElement {
230 v_flex()
231 .min_w_full()
232 .child(SectionHeader::new(self.title))
233 .children(
234 self.entries
235 .iter()
236 .enumerate()
237 .filter_map(|(index, entry)| entry.render(index_offset + index, focus)),
238 )
239 }
240}
241
242pub struct WelcomePage {
243 workspace: WeakEntity<Workspace>,
244 focus_handle: FocusHandle,
245 fallback_to_recent_projects: bool,
246 recent_workspaces: Option<Vec<RecentWorkspace>>,
247}
248
249impl WelcomePage {
250 pub fn new(
251 workspace: WeakEntity<Workspace>,
252 fallback_to_recent_projects: bool,
253 window: &mut Window,
254 cx: &mut Context<Self>,
255 ) -> Self {
256 let focus_handle = cx.focus_handle();
257 cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
258 .detach();
259
260 if fallback_to_recent_projects {
261 let fs = workspace
262 .upgrade()
263 .map(|ws| ws.read(cx).app_state().fs.clone());
264 let db = WorkspaceDb::global(cx);
265 cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
266 let Some(fs) = fs else { return };
267 let workspaces = db
268 .recent_project_workspaces(fs.as_ref())
269 .await
270 .log_err()
271 .unwrap_or_default();
272
273 this.update(cx, |this, cx| {
274 this.recent_workspaces = Some(workspaces);
275 cx.notify();
276 })
277 .ok();
278 })
279 .detach();
280 }
281
282 WelcomePage {
283 workspace,
284 focus_handle,
285 fallback_to_recent_projects,
286 recent_workspaces: None,
287 }
288 }
289
290 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
291 window.focus_next(cx);
292 cx.notify();
293 }
294
295 fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
296 window.focus_prev(cx);
297 cx.notify();
298 }
299
300 fn open_recent_project(
301 &mut self,
302 action: &OpenRecentProject,
303 window: &mut Window,
304 cx: &mut Context<Self>,
305 ) {
306 if let Some(recent_workspaces) = &self.recent_workspaces {
307 if let Some(workspace) = recent_workspaces.get(action.index) {
308 let is_local = matches!(workspace.location, SerializedWorkspaceLocation::Local);
309
310 if is_local {
311 let paths = workspace.paths.paths().to_vec();
312 let open_mode = match WorkspaceSettings::get_global(cx).default_open_behavior {
313 DefaultOpenBehavior::ExistingWindow => OpenMode::Activate,
314 DefaultOpenBehavior::NewWindow => OpenMode::NewWindow,
315 };
316 self.workspace
317 .update(cx, |workspace, cx| {
318 workspace
319 .open_workspace_for_paths(open_mode, paths, window, cx)
320 .detach_and_log_err(cx);
321 })
322 .log_err();
323 } else {
324 use zed_actions::OpenRecent;
325 window.dispatch_action(OpenRecent::default().boxed_clone(), cx);
326 }
327 }
328 }
329 }
330
331 fn render_agent_card(&self, tab_index: usize, cx: &mut Context<Self>) -> impl IntoElement {
332 let focus = self.focus_handle.clone();
333 let color = cx.theme().colors();
334
335 let description = "Run multiple threads at once, mix and match any ACP-compatible agent, and keep work conflict-free with worktrees.";
336
337 v_flex()
338 .w_full()
339 .p_2()
340 .rounded_md()
341 .border_1()
342 .border_color(color.border_variant)
343 .bg(linear_gradient(
344 360.,
345 linear_color_stop(color.panel_background, 1.0),
346 linear_color_stop(color.editor_background, 0.45),
347 ))
348 .child(
349 h_flex()
350 .gap_1p5()
351 .child(
352 Icon::new(IconName::OmegaAssistant)
353 .color(Color::Muted)
354 .size(IconSize::Small),
355 )
356 .child(Label::new("Collaborate with Agents")),
357 )
358 .child(
359 Label::new(description)
360 .size(LabelSize::Small)
361 .color(Color::Muted)
362 .mb_2(),
363 )
364 .child(
365 Button::new("open-agent", "Open Agent Panel")
366 .full_width()
367 .tab_index(tab_index as isize)
368 .style(ButtonStyle::Outlined)
369 .key_binding(
370 KeyBinding::for_action_in(&ToggleFocus, &self.focus_handle, cx)
371 .size(rems_from_px(12.)),
372 )
373 .on_click(move |_, window, cx| {
374 focus.dispatch_action(&ToggleWorkspaceSidebar, window, cx);
375 focus.dispatch_action(&ToggleFocus, window, cx);
376 }),
377 )
378 }
379
380 fn render_recent_project_section(
381 &self,
382 recent_projects: Vec<impl IntoElement>,
383 ) -> impl IntoElement {
384 v_flex()
385 .w_full()
386 .child(SectionHeader::new("Recent Projects"))
387 .children(recent_projects)
388 }
389
390 fn render_recent_project(
391 &self,
392 project_index: usize,
393 tab_index: usize,
394 location: &SerializedWorkspaceLocation,
395 paths: &PathList,
396 ) -> impl IntoElement {
397 let name = project_name(paths);
398
399 let (icon, title) = match location {
400 SerializedWorkspaceLocation::Local => (IconName::Folder, name),
401 SerializedWorkspaceLocation::Remote(_) => (IconName::Server, name),
402 };
403
404 SectionButton::new(
405 title,
406 icon,
407 &OpenRecentProject {
408 index: project_index,
409 },
410 tab_index,
411 self.focus_handle.clone(),
412 )
413 }
414}
415
416impl Render for WelcomePage {
417 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
418 let (first_section, second_section) = CONTENT;
419 let first_section_entries = first_section.entries.len();
420 let mut next_tab_index = first_section_entries + second_section.entries.len();
421
422 let ai_enabled = AgentSettings::get_global(cx).enabled(cx);
423
424 let recent_projects = self
425 .recent_workspaces
426 .as_ref()
427 .into_iter()
428 .flatten()
429 .take(5)
430 .enumerate()
431 .map(|(index, workspace)| {
432 self.render_recent_project(
433 index,
434 first_section_entries + index,
435 &workspace.location,
436 &workspace.identity_paths,
437 )
438 })
439 .collect::<Vec<_>>();
440
441 let showing_recent_projects =
442 self.fallback_to_recent_projects && !recent_projects.is_empty();
443 let second_section = if showing_recent_projects {
444 self.render_recent_project_section(recent_projects)
445 .into_any_element()
446 } else {
447 second_section
448 .render(first_section_entries, &self.focus_handle)
449 .into_any_element()
450 };
451
452 let welcome_label = if self.fallback_to_recent_projects {
453 "Welcome back to Omega"
454 } else {
455 "Welcome to Omega"
456 };
457
458 h_flex()
459 .key_context("Welcome")
460 .track_focus(&self.focus_handle(cx))
461 .on_action(cx.listener(Self::select_previous))
462 .on_action(cx.listener(Self::select_next))
463 .on_action(cx.listener(Self::open_recent_project))
464 .size_full()
465 .bg(cx.theme().colors().editor_background)
466 .justify_center()
467 .child(
468 v_flex()
469 .id("welcome-content")
470 .p_8()
471 .max_w_128()
472 .size_full()
473 .gap_6()
474 .justify_center()
475 .overflow_y_scroll()
476 .child(
477 h_flex()
478 .id("welcome-brand")
479 .w_full()
480 .justify_center()
481 .mb_4()
482 .gap_4()
483 .aria_label("Omega, published by OpenAgents")
484 .child(Vector::square(VectorName::OmegaLogo, rems_from_px(45.)))
485 .child(
486 v_flex().child(Headline::new(welcome_label)).child(
487 Label::new("Your last IDE.")
488 .size(LabelSize::Small)
489 .color(Color::Muted)
490 .italic(),
491 ),
492 ),
493 )
494 .child(first_section.render(Default::default(), &self.focus_handle))
495 .child(second_section)
496 .when(ai_enabled && !showing_recent_projects, |this| {
497 let agent_tab_index = next_tab_index;
498 next_tab_index += 1;
499 this.child(self.render_agent_card(agent_tab_index, cx))
500 })
501 .when(!self.fallback_to_recent_projects, |this| {
502 this.child(
503 v_flex()
504 .gap_2()
505 .child(Divider::horizontal())
506 .child(
507 Button::new("welcome-exit", "Return to Onboarding")
508 .tab_index(next_tab_index as isize)
509 .full_width()
510 .label_size(LabelSize::XSmall)
511 .on_click(|_, window, cx| {
512 window.dispatch_action(
513 OpenEditorOnboarding.boxed_clone(),
514 cx,
515 );
516 }),
517 )
518 .child(
519 Label::new(
520 "Or open the command palette (cmd-shift-p / ctrl-shift-p, not cmd-p / ctrl-p) and run Editor Onboarding.",
521 )
522 .size(LabelSize::XSmall)
523 .color(Color::Muted),
524 ),
525 )
526 }),
527 )
528 }
529}
530
531impl EventEmitter<ItemEvent> for WelcomePage {}
532
533impl Focusable for WelcomePage {
534 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
535 self.focus_handle.clone()
536 }
537}
538
539impl Item for WelcomePage {
540 type Event = ItemEvent;
541
542 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
543 "Welcome".into()
544 }
545
546 fn telemetry_event_text(&self) -> Option<&'static str> {
547 Some("New Welcome Page Opened")
548 }
549
550 fn show_toolbar(&self) -> bool {
551 false
552 }
553
554 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(crate::item::ItemEvent)) {
555 f(*event)
556 }
557}
558
559impl crate::SerializableItem for WelcomePage {
560 fn serialized_item_kind() -> &'static str {
561 "WelcomePage"
562 }
563
564 fn cleanup(
565 workspace_id: crate::WorkspaceId,
566 alive_items: Vec<crate::ItemId>,
567 _window: &mut Window,
568 cx: &mut App,
569 ) -> Task<gpui::Result<()>> {
570 crate::delete_unloaded_items(
571 alive_items,
572 workspace_id,
573 "welcome_pages",
574 &persistence::WelcomePagesDb::global(cx),
575 cx,
576 )
577 }
578
579 fn deserialize(
580 _project: Entity<project::Project>,
581 workspace: gpui::WeakEntity<Workspace>,
582 workspace_id: crate::WorkspaceId,
583 item_id: crate::ItemId,
584 window: &mut Window,
585 cx: &mut App,
586 ) -> Task<gpui::Result<Entity<Self>>> {
587 if persistence::WelcomePagesDb::global(cx)
588 .get_welcome_page(item_id, workspace_id)
589 .ok()
590 .is_some_and(|is_open| is_open)
591 {
592 Task::ready(Ok(
593 cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
594 ))
595 } else {
596 Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
597 }
598 }
599
600 fn serialize(
601 &mut self,
602 workspace: &mut Workspace,
603 item_id: crate::ItemId,
604 _closing: bool,
605 _window: &mut Window,
606 cx: &mut Context<Self>,
607 ) -> Option<Task<gpui::Result<()>>> {
608 let workspace_id = workspace.database_id()?;
609 let db = persistence::WelcomePagesDb::global(cx);
610 Some(cx.background_spawn(
611 async move { db.save_welcome_page(item_id, workspace_id, true).await },
612 ))
613 }
614
615 fn should_serialize(&self, event: &Self::Event) -> bool {
616 event == &ItemEvent::UpdateTab
617 }
618}
619
620mod persistence {
621 use crate::WorkspaceDb;
622 use db::{
623 query,
624 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
625 sqlez_macros::sql,
626 };
627
628 pub struct WelcomePagesDb(ThreadSafeConnection);
629
630 impl Domain for WelcomePagesDb {
631 const NAME: &str = stringify!(WelcomePagesDb);
632
633 const MIGRATIONS: &[&str] = (&[sql!(
634 CREATE TABLE welcome_pages (
635 workspace_id INTEGER,
636 item_id INTEGER UNIQUE,
637 is_open INTEGER DEFAULT FALSE,
638
639 PRIMARY KEY(workspace_id, item_id),
640 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
641 ON DELETE CASCADE
642 ) STRICT;
643 )]);
644 }
645
646 db::static_connection!(WelcomePagesDb, [WorkspaceDb]);
647
648 impl WelcomePagesDb {
649 query! {
650 pub async fn save_welcome_page(
651 item_id: crate::ItemId,
652 workspace_id: crate::WorkspaceId,
653 is_open: bool
654 ) -> Result<()> {
655 INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
656 VALUES (?, ?, ?)
657 }
658 }
659
660 query! {
661 pub fn get_welcome_page(
662 item_id: crate::ItemId,
663 workspace_id: crate::WorkspaceId
664 ) -> Result<bool> {
665 SELECT is_open
666 FROM welcome_pages
667 WHERE item_id = ? AND workspace_id = ?
668 }
669 }
670 }
671}
672
673fn project_name(paths: &PathList) -> String {
674 let joined = paths
675 .paths()
676 .iter()
677 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
678 .collect::<Vec<_>>()
679 .join(", ");
680 if joined.is_empty() {
681 "Untitled".to_string()
682 } else {
683 joined
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn test_project_name_empty() {
693 let paths = PathList::new::<&str>(&[]);
694 assert_eq!(project_name(&paths), "Untitled");
695 }
696
697 #[test]
698 fn test_project_name_single() {
699 let paths = PathList::new(&["/home/user/my-project"]);
700 assert_eq!(project_name(&paths), "my-project");
701 }
702
703 #[test]
704 fn test_project_name_multiple() {
705 // PathList sorts lexicographically, so filenames appear in alpha order
706 let paths = PathList::new(&["/home/user/zed", "/home/user/api"]);
707 assert_eq!(project_name(&paths), "api, zed");
708 }
709
710 #[test]
711 fn test_project_name_root_path_filtered() {
712 // A bare root "/" has no file_name(), falls back to "Untitled"
713 let paths = PathList::new(&["/"]);
714 assert_eq!(project_name(&paths), "Untitled");
715 }
716}
717