Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T01:50:43.738Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

welcome.rs

717 lines · 23.0 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.