Detailed changes
@@ -10843,7 +10843,6 @@ dependencies = [
"documented",
"fs",
"fuzzy",
- "git",
"gpui",
"menu",
"notifications",
@@ -20096,6 +20095,7 @@ dependencies = [
"feature_flags",
"fs",
"futures 0.3.31",
+ "git",
"gpui",
"http_client",
"itertools 0.14.0",
@@ -1263,6 +1263,11 @@
"ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }],
"ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }],
"ctrl-0": ["zed::ResetUiFontSize", { "persist": false }],
+ "ctrl-1": ["welcome::OpenRecentProject", 0],
+ "ctrl-2": ["welcome::OpenRecentProject", 1],
+ "ctrl-3": ["welcome::OpenRecentProject", 2],
+ "ctrl-4": ["welcome::OpenRecentProject", 3],
+ "ctrl-5": ["welcome::OpenRecentProject", 4],
},
},
{
@@ -1366,6 +1366,11 @@
"cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }],
"cmd--": ["zed::DecreaseUiFontSize", { "persist": false }],
"cmd-0": ["zed::ResetUiFontSize", { "persist": false }],
+ "cmd-1": ["welcome::OpenRecentProject", 0],
+ "cmd-2": ["welcome::OpenRecentProject", 1],
+ "cmd-3": ["welcome::OpenRecentProject", 2],
+ "cmd-4": ["welcome::OpenRecentProject", 3],
+ "cmd-5": ["welcome::OpenRecentProject", 4],
},
},
{
@@ -1295,6 +1295,11 @@
"ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }],
"ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }],
"ctrl-0": ["zed::ResetUiFontSize", { "persist": false }],
+ "ctrl-1": ["welcome::OpenRecentProject", 0],
+ "ctrl-2": ["welcome::OpenRecentProject", 1],
+ "ctrl-3": ["welcome::OpenRecentProject", 2],
+ "ctrl-4": ["welcome::OpenRecentProject", 3],
+ "ctrl-5": ["welcome::OpenRecentProject", 4],
},
},
{
@@ -3427,7 +3427,8 @@ impl Editor {
data.selections = inmemory_selections;
});
- if WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None
+ if WorkspaceSettings::get(None, cx).restore_on_startup
+ != RestoreOnStartupBehavior::EmptyTab
&& let Some(workspace_id) = self.workspace_serialization_id(cx)
{
let snapshot = self.buffer().read(cx).snapshot(cx);
@@ -3467,7 +3468,8 @@ impl Editor {
use text::ToPoint as _;
if self.mode.is_minimap()
- || WorkspaceSettings::get(None, cx).restore_on_startup == RestoreOnStartupBehavior::None
+ || WorkspaceSettings::get(None, cx).restore_on_startup
+ == RestoreOnStartupBehavior::EmptyTab
{
return;
}
@@ -23163,7 +23165,8 @@ impl Editor {
) {
if self.buffer_kind(cx) == ItemBufferKind::Singleton
&& !self.mode.is_minimap()
- && WorkspaceSettings::get(None, cx).restore_on_startup != RestoreOnStartupBehavior::None
+ && WorkspaceSettings::get(None, cx).restore_on_startup
+ != RestoreOnStartupBehavior::EmptyTab
{
let buffer_snapshot = OnceCell::new();
@@ -165,3 +165,9 @@ pub(crate) mod m_2025_12_08 {
pub(crate) use keymap::KEYMAP_PATTERNS;
}
+
+pub(crate) mod m_2025_12_15 {
+ mod settings;
+
+ pub(crate) use settings::SETTINGS_PATTERNS;
+}
@@ -0,0 +1,52 @@
+use std::ops::Range;
+use tree_sitter::{Query, QueryMatch};
+
+use crate::MigrationPatterns;
+use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN;
+
+pub const SETTINGS_PATTERNS: MigrationPatterns = &[(
+ SETTINGS_NESTED_KEY_VALUE_PATTERN,
+ rename_restore_on_startup_values,
+)];
+
+fn rename_restore_on_startup_values(
+ contents: &str,
+ mat: &QueryMatch,
+ query: &Query,
+) -> Option<(Range<usize>, String)> {
+ if !is_restore_on_startup_setting(contents, mat, query) {
+ return None;
+ }
+
+ let setting_value_ix = query.capture_index_for_name("setting_value")?;
+ let setting_value_range = mat
+ .nodes_for_capture_index(setting_value_ix)
+ .next()?
+ .byte_range();
+ let setting_value = contents.get(setting_value_range.clone())?;
+
+ // The value includes quotes, so we check for the quoted string
+ let new_value = match setting_value.trim() {
+ "\"none\"" => "\"empty_tab\"",
+ "\"welcome\"" => "\"launchpad\"",
+ _ => return None,
+ };
+
+ Some((setting_value_range, new_value.to_string()))
+}
+
+fn is_restore_on_startup_setting(contents: &str, mat: &QueryMatch, query: &Query) -> bool {
+ // Check that the parent key is "workspace" (since restore_on_startup is under workspace settings)
+ // Actually, restore_on_startup can be at the root level too, so we need to handle both cases
+ // The SETTINGS_NESTED_KEY_VALUE_PATTERN captures parent_key and setting_name
+
+ let setting_name_ix = match query.capture_index_for_name("setting_name") {
+ Some(ix) => ix,
+ None => return false,
+ };
+ let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() {
+ Some(node) => node.byte_range(),
+ None => return false,
+ };
+ contents.get(setting_name_range) == Some("restore_on_startup")
+}
@@ -232,6 +232,10 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
&SETTINGS_QUERY_2025_11_20,
),
MigrationType::Json(migrations::m_2025_11_25::remove_context_server_source),
+ MigrationType::TreeSitter(
+ migrations::m_2025_12_15::SETTINGS_PATTERNS,
+ &SETTINGS_QUERY_2025_12_15,
+ ),
];
run_migrations(text, migrations)
}
@@ -366,6 +370,10 @@ define_query!(
KEYMAP_QUERY_2025_12_08,
migrations::m_2025_12_08::KEYMAP_PATTERNS
);
+define_query!(
+ SETTINGS_QUERY_2025_12_15,
+ migrations::m_2025_12_15::SETTINGS_PATTERNS
+);
// custom query
static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
@@ -22,7 +22,6 @@ db.workspace = true
documented.workspace = true
fs.workspace = true
fuzzy.workspace = true
-git.workspace = true
gpui.workspace = true
menu.workspace = true
notifications.workspace = true
@@ -1,5 +1,4 @@
-pub use crate::welcome::ShowWelcome;
-use crate::{multibuffer_hint::MultibufferHint, welcome::WelcomePage};
+use crate::multibuffer_hint::MultibufferHint;
use client::{Client, UserStore, zed_urls};
use db::kvp::KEY_VALUE_STORE;
use fs::Fs;
@@ -17,6 +16,8 @@ use ui::{
Divider, KeyBinding, ParentElement as _, StatefulInteractiveElement, Vector, VectorName,
WithScrollbar as _, prelude::*, rems_from_px,
};
+pub use workspace::welcome::ShowWelcome;
+use workspace::welcome::WelcomePage;
use workspace::{
AppState, Workspace, WorkspaceId,
dock::DockPosition,
@@ -24,12 +25,12 @@ use workspace::{
notifications::NotifyResultExt as _,
open_new, register_serializable_item, with_active_or_new_workspace,
};
+use zed_actions::OpenOnboarding;
mod base_keymap_picker;
mod basics_page;
pub mod multibuffer_hint;
mod theme_preview;
-mod welcome;
/// Imports settings from Visual Studio Code.
#[derive(Copy, Clone, Debug, Default, PartialEq, Deserialize, JsonSchema, Action)]
@@ -52,14 +53,6 @@ pub struct ImportCursorSettings {
pub const FIRST_OPEN: &str = "first_open";
pub const DOCS_URL: &str = "https://zed.dev/docs/";
-actions!(
- zed,
- [
- /// Opens the onboarding view.
- OpenOnboarding
- ]
-);
-
actions!(
onboarding,
[
@@ -121,7 +114,8 @@ pub fn init(cx: &mut App) {
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
} else {
- let settings_page = WelcomePage::new(window, cx);
+ let settings_page = cx
+ .new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx));
workspace.add_item_to_active_pane(
Box::new(settings_page),
None,
@@ -427,7 +421,9 @@ fn go_to_welcome_page(cx: &mut App) {
if let Some(idx) = idx {
pane.activate_item(idx, true, true, window, cx);
} else {
- let item = Box::new(WelcomePage::new(window, cx));
+ let item = Box::new(
+ cx.new(|cx| WelcomePage::new(workspace.weak_handle(), false, window, cx)),
+ );
pane.add_item(item, true, true, Some(onboarding_idx), window, cx);
}
@@ -1,443 +0,0 @@
-use gpui::{
- Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
- ParentElement, Render, Styled, Task, Window, actions,
-};
-use menu::{SelectNext, SelectPrevious};
-use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
-use workspace::{
- NewFile, Open,
- item::{Item, ItemEvent},
- with_active_or_new_workspace,
-};
-use zed_actions::{Extensions, OpenSettings, agent, command_palette};
-
-use crate::{Onboarding, OpenOnboarding};
-
-actions!(
- zed,
- [
- /// Show the Zed welcome screen
- ShowWelcome
- ]
-);
-
-const CONTENT: (Section<4>, Section<3>) = (
- Section {
- title: "Get Started",
- entries: [
- SectionEntry {
- icon: IconName::Plus,
- title: "New File",
- action: &NewFile,
- },
- SectionEntry {
- icon: IconName::FolderOpen,
- title: "Open Project",
- action: &Open,
- },
- SectionEntry {
- icon: IconName::CloudDownload,
- title: "Clone Repository",
- action: &git::Clone,
- },
- SectionEntry {
- icon: IconName::ListCollapse,
- title: "Open Command Palette",
- action: &command_palette::Toggle,
- },
- ],
- },
- Section {
- title: "Configure",
- entries: [
- SectionEntry {
- icon: IconName::Settings,
- title: "Open Settings",
- action: &OpenSettings,
- },
- SectionEntry {
- icon: IconName::ZedAssistant,
- title: "View AI Settings",
- action: &agent::OpenSettings,
- },
- SectionEntry {
- icon: IconName::Blocks,
- title: "Explore Extensions",
- action: &Extensions {
- category_filter: None,
- id: None,
- },
- },
- ],
- },
-);
-
-struct Section<const COLS: usize> {
- title: &'static str,
- entries: [SectionEntry; COLS],
-}
-
-impl<const COLS: usize> Section<COLS> {
- fn render(self, index_offset: usize, focus: &FocusHandle, cx: &mut App) -> impl IntoElement {
- v_flex()
- .min_w_full()
- .child(
- h_flex()
- .px_1()
- .mb_2()
- .gap_2()
- .child(
- Label::new(self.title.to_ascii_uppercase())
- .buffer_font(cx)
- .color(Color::Muted)
- .size(LabelSize::XSmall),
- )
- .child(Divider::horizontal().color(DividerColor::BorderVariant)),
- )
- .children(
- self.entries
- .iter()
- .enumerate()
- .map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
- )
- }
-}
-
-struct SectionEntry {
- icon: IconName,
- title: &'static str,
- action: &'static dyn Action,
-}
-
-impl SectionEntry {
- fn render(&self, button_index: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement {
- ButtonLike::new(("onboarding-button-id", button_index))
- .tab_index(button_index as isize)
- .full_width()
- .size(ButtonSize::Medium)
- .child(
- h_flex()
- .w_full()
- .justify_between()
- .child(
- h_flex()
- .gap_2()
- .child(
- Icon::new(self.icon)
- .color(Color::Muted)
- .size(IconSize::XSmall),
- )
- .child(Label::new(self.title)),
- )
- .child(
- KeyBinding::for_action_in(self.action, focus, cx).size(rems_from_px(12.)),
- ),
- )
- .on_click(|_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx))
- }
-}
-
-pub struct WelcomePage {
- focus_handle: FocusHandle,
-}
-
-impl WelcomePage {
- fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
- window.focus_next();
- cx.notify();
- }
-
- fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
- window.focus_prev();
- cx.notify();
- }
-}
-
-impl Render for WelcomePage {
- fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
- let (first_section, second_section) = CONTENT;
- let first_section_entries = first_section.entries.len();
- let last_index = first_section_entries + second_section.entries.len();
-
- h_flex()
- .size_full()
- .justify_center()
- .overflow_hidden()
- .bg(cx.theme().colors().editor_background)
- .key_context("Welcome")
- .track_focus(&self.focus_handle(cx))
- .on_action(cx.listener(Self::select_previous))
- .on_action(cx.listener(Self::select_next))
- .child(
- h_flex()
- .px_12()
- .py_40()
- .size_full()
- .relative()
- .max_w(px(1100.))
- .child(
- div()
- .size_full()
- .max_w_128()
- .mx_auto()
- .child(
- h_flex()
- .w_full()
- .justify_center()
- .gap_4()
- .child(Vector::square(VectorName::ZedLogo, rems(2.)))
- .child(
- div().child(Headline::new("Welcome to Zed")).child(
- Label::new("The editor for what's next")
- .size(LabelSize::Small)
- .color(Color::Muted)
- .italic(),
- ),
- ),
- )
- .child(
- v_flex()
- .mt_10()
- .gap_6()
- .child(first_section.render(
- Default::default(),
- &self.focus_handle,
- cx,
- ))
- .child(second_section.render(
- first_section_entries,
- &self.focus_handle,
- cx,
- ))
- .child(
- h_flex()
- .w_full()
- .pt_4()
- .justify_center()
- // We call this a hack
- .rounded_b_xs()
- .border_t_1()
- .border_color(cx.theme().colors().border.opacity(0.6))
- .border_dashed()
- .child(
- Button::new("welcome-exit", "Return to Setup")
- .tab_index(last_index as isize)
- .full_width()
- .label_size(LabelSize::XSmall)
- .on_click(|_, window, cx| {
- window.dispatch_action(
- OpenOnboarding.boxed_clone(),
- cx,
- );
-
- with_active_or_new_workspace(cx, |workspace, window, cx| {
- let Some((welcome_id, welcome_idx)) = workspace
- .active_pane()
- .read(cx)
- .items()
- .enumerate()
- .find_map(|(idx, item)| {
- let _ = item.downcast::<WelcomePage>()?;
- Some((item.item_id(), idx))
- })
- else {
- return;
- };
-
- workspace.active_pane().update(cx, |pane, cx| {
- // Get the index here to get around the borrow checker
- let idx = pane.items().enumerate().find_map(
- |(idx, item)| {
- let _ =
- item.downcast::<Onboarding>()?;
- Some(idx)
- },
- );
-
- if let Some(idx) = idx {
- pane.activate_item(
- idx, true, true, window, cx,
- );
- } else {
- let item =
- Box::new(Onboarding::new(workspace, cx));
- pane.add_item(
- item,
- true,
- true,
- Some(welcome_idx),
- window,
- cx,
- );
- }
-
- pane.remove_item(
- welcome_id,
- false,
- false,
- window,
- cx,
- );
- });
- });
- }),
- ),
- ),
- ),
- ),
- )
- }
-}
-
-impl WelcomePage {
- pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
- cx.new(|cx| {
- let focus_handle = cx.focus_handle();
- cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
- .detach();
-
- WelcomePage { focus_handle }
- })
- }
-}
-
-impl EventEmitter<ItemEvent> for WelcomePage {}
-
-impl Focusable for WelcomePage {
- fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
- self.focus_handle.clone()
- }
-}
-
-impl Item for WelcomePage {
- type Event = ItemEvent;
-
- fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
- "Welcome".into()
- }
-
- fn telemetry_event_text(&self) -> Option<&'static str> {
- Some("New Welcome Page Opened")
- }
-
- fn show_toolbar(&self) -> bool {
- false
- }
-
- fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
- f(*event)
- }
-}
-
-impl workspace::SerializableItem for WelcomePage {
- fn serialized_item_kind() -> &'static str {
- "WelcomePage"
- }
-
- fn cleanup(
- workspace_id: workspace::WorkspaceId,
- alive_items: Vec<workspace::ItemId>,
- _window: &mut Window,
- cx: &mut App,
- ) -> Task<gpui::Result<()>> {
- workspace::delete_unloaded_items(
- alive_items,
- workspace_id,
- "welcome_pages",
- &persistence::WELCOME_PAGES,
- cx,
- )
- }
-
- fn deserialize(
- _project: Entity<project::Project>,
- _workspace: gpui::WeakEntity<workspace::Workspace>,
- workspace_id: workspace::WorkspaceId,
- item_id: workspace::ItemId,
- window: &mut Window,
- cx: &mut App,
- ) -> Task<gpui::Result<Entity<Self>>> {
- if persistence::WELCOME_PAGES
- .get_welcome_page(item_id, workspace_id)
- .ok()
- .is_some_and(|is_open| is_open)
- {
- window.spawn(cx, async move |cx| cx.update(WelcomePage::new))
- } else {
- Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
- }
- }
-
- fn serialize(
- &mut self,
- workspace: &mut workspace::Workspace,
- item_id: workspace::ItemId,
- _closing: bool,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Option<Task<gpui::Result<()>>> {
- let workspace_id = workspace.database_id()?;
- Some(cx.background_spawn(async move {
- persistence::WELCOME_PAGES
- .save_welcome_page(item_id, workspace_id, true)
- .await
- }))
- }
-
- fn should_serialize(&self, event: &Self::Event) -> bool {
- event == &ItemEvent::UpdateTab
- }
-}
-
-mod persistence {
- use db::{
- query,
- sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
- sqlez_macros::sql,
- };
- use workspace::WorkspaceDb;
-
- pub struct WelcomePagesDb(ThreadSafeConnection);
-
- impl Domain for WelcomePagesDb {
- const NAME: &str = stringify!(WelcomePagesDb);
-
- const MIGRATIONS: &[&str] = (&[sql!(
- CREATE TABLE welcome_pages (
- workspace_id INTEGER,
- item_id INTEGER UNIQUE,
- is_open INTEGER DEFAULT FALSE,
-
- PRIMARY KEY(workspace_id, item_id),
- FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
- ON DELETE CASCADE
- ) STRICT;
- )]);
- }
-
- db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
-
- impl WelcomePagesDb {
- query! {
- pub async fn save_welcome_page(
- item_id: workspace::ItemId,
- workspace_id: workspace::WorkspaceId,
- is_open: bool
- ) -> Result<()> {
- INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
- VALUES (?, ?, ?)
- }
- }
-
- query! {
- pub fn get_welcome_page(
- item_id: workspace::ItemId,
- workspace_id: workspace::WorkspaceId
- ) -> Result<bool> {
- SELECT is_open
- FROM welcome_pages
- WHERE item_id = ? AND workspace_id = ?
- }
- }
- }
-}
@@ -42,7 +42,7 @@ pub struct WorkspaceSettingsContent {
/// Default: off
pub autosave: Option<AutosaveSetting>,
/// Controls previous session restoration in freshly launched Zed instance.
- /// Values: none, last_workspace, last_session
+ /// Values: empty_tab, last_workspace, last_session, launchpad
/// Default: last_session
pub restore_on_startup: Option<RestoreOnStartupBehavior>,
/// Whether to attempt to restore previous file's state when opening it again.
@@ -382,13 +382,16 @@ impl CloseWindowWhenNoItems {
)]
#[serde(rename_all = "snake_case")]
pub enum RestoreOnStartupBehavior {
- /// Always start with an empty editor
- None,
+ /// Always start with an empty editor tab
+ #[serde(alias = "none")]
+ EmptyTab,
/// Restore the workspace that was closed last.
LastWorkspace,
/// Restore all workspaces that were open when quitting Zed.
#[default]
LastSession,
+ /// Show the launchpad with recent projects (no tabs).
+ Launchpad,
}
#[with_fallible_options]
@@ -479,7 +479,7 @@ impl TitleBar {
let name = if let Some(name) = name {
util::truncate_and_trailoff(&name, MAX_PROJECT_NAME_LENGTH)
} else {
- "Open recent project".to_string()
+ "Open Recent Project".to_string()
};
Button::new("project_name_trigger", name)
@@ -38,6 +38,7 @@ db.workspace = true
feature_flags.workspace = true
fs.workspace = true
futures.workspace = true
+git.workspace = true
gpui.workspace = true
http_client.workspace = true
itertools.workspace = true
@@ -47,10 +47,9 @@ use std::{
};
use theme::ThemeSettings;
use ui::{
- ButtonSize, Color, ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButton,
- IconButtonShape, IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label,
- PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, prelude::*,
- right_click_menu,
+ ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButtonShape, IconDecoration,
+ IconDecorationKind, Indicator, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition,
+ Tooltip, prelude::*, right_click_menu,
};
use util::{ResultExt, debug_panic, maybe, paths::PathStyle, truncate_and_remove_front};
@@ -398,6 +397,7 @@ pub struct Pane {
diagnostic_summary_update: Task<()>,
/// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here.
pub project_item_restoration_data: HashMap<ProjectItemKind, Box<dyn Any + Send>>,
+ welcome_page: Option<Entity<crate::welcome::WelcomePage>>,
pub in_center_group: bool,
pub is_upper_left: bool,
@@ -546,6 +546,7 @@ impl Pane {
zoom_out_on_close: true,
diagnostic_summary_update: Task::ready(()),
project_item_restoration_data: HashMap::default(),
+ welcome_page: None,
in_center_group: false,
is_upper_left: false,
is_upper_right: false,
@@ -635,6 +636,10 @@ impl Pane {
self.last_focus_handle_by_item
.insert(active_item.item_id(), focused.downgrade());
}
+ } else if let Some(welcome_page) = self.welcome_page.as_ref() {
+ if self.focus_handle.is_focused(window) {
+ welcome_page.read(cx).focus_handle(cx).focus(window);
+ }
}
}
@@ -4061,10 +4066,15 @@ impl Render for Pane {
if has_worktrees {
placeholder
} else {
- placeholder.child(
- Label::new("Open a file or project to get started.")
- .color(Color::Muted),
- )
+ if self.welcome_page.is_none() {
+ let workspace = self.workspace.clone();
+ self.welcome_page = Some(cx.new(|cx| {
+ crate::welcome::WelcomePage::new(
+ workspace, true, window, cx,
+ )
+ }));
+ }
+ placeholder.child(self.welcome_page.clone().unwrap())
}
}
})
@@ -0,0 +1,568 @@
+use crate::{
+ NewFile, Open, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId,
+ item::{Item, ItemEvent},
+};
+use git::Clone as GitClone;
+use gpui::WeakEntity;
+use gpui::{
+ Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
+ ParentElement, Render, Styled, Task, Window, actions,
+};
+use menu::{SelectNext, SelectPrevious};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
+use util::ResultExt;
+use zed_actions::{Extensions, OpenOnboarding, OpenSettings, agent, command_palette};
+
+#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)]
+#[action(namespace = welcome)]
+#[serde(transparent)]
+pub struct OpenRecentProject {
+ pub index: usize,
+}
+
+actions!(
+ zed,
+ [
+ /// Show the Zed welcome screen
+ ShowWelcome
+ ]
+);
+
+#[derive(IntoElement)]
+struct SectionHeader {
+ title: SharedString,
+}
+
+impl SectionHeader {
+ fn new(title: impl Into<SharedString>) -> Self {
+ Self {
+ title: title.into(),
+ }
+ }
+}
+
+impl RenderOnce for SectionHeader {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ h_flex()
+ .px_1()
+ .mb_2()
+ .gap_2()
+ .child(
+ Label::new(self.title.to_ascii_uppercase())
+ .buffer_font(cx)
+ .color(Color::Muted)
+ .size(LabelSize::XSmall),
+ )
+ .child(Divider::horizontal().color(DividerColor::BorderVariant))
+ }
+}
+
+#[derive(IntoElement)]
+struct SectionButton {
+ label: SharedString,
+ icon: IconName,
+ action: Box<dyn Action>,
+ tab_index: usize,
+ focus_handle: FocusHandle,
+}
+
+impl SectionButton {
+ fn new(
+ label: impl Into<SharedString>,
+ icon: IconName,
+ action: &dyn Action,
+ tab_index: usize,
+ focus_handle: FocusHandle,
+ ) -> Self {
+ Self {
+ label: label.into(),
+ icon,
+ action: action.boxed_clone(),
+ tab_index,
+ focus_handle,
+ }
+ }
+}
+
+impl RenderOnce for SectionButton {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let id = format!("onb-button-{}", self.label);
+ let action_ref: &dyn Action = &*self.action;
+
+ ButtonLike::new(id)
+ .tab_index(self.tab_index as isize)
+ .full_width()
+ .size(ButtonSize::Medium)
+ .child(
+ h_flex()
+ .w_full()
+ .justify_between()
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Icon::new(self.icon)
+ .color(Color::Muted)
+ .size(IconSize::Small),
+ )
+ .child(Label::new(self.label)),
+ )
+ .child(
+ KeyBinding::for_action_in(action_ref, &self.focus_handle, cx)
+ .size(rems_from_px(12.)),
+ ),
+ )
+ .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx))
+ }
+}
+
+struct SectionEntry {
+ icon: IconName,
+ title: &'static str,
+ action: &'static dyn Action,
+}
+
+impl SectionEntry {
+ fn render(&self, button_index: usize, focus: &FocusHandle, _cx: &App) -> impl IntoElement {
+ SectionButton::new(
+ self.title,
+ self.icon,
+ self.action,
+ button_index,
+ focus.clone(),
+ )
+ }
+}
+
+const CONTENT: (Section<4>, Section<3>) = (
+ Section {
+ title: "Get Started",
+ entries: [
+ SectionEntry {
+ icon: IconName::Plus,
+ title: "New File",
+ action: &NewFile,
+ },
+ SectionEntry {
+ icon: IconName::FolderOpen,
+ title: "Open Project",
+ action: &Open,
+ },
+ SectionEntry {
+ icon: IconName::CloudDownload,
+ title: "Clone Repository",
+ action: &GitClone,
+ },
+ SectionEntry {
+ icon: IconName::ListCollapse,
+ title: "Open Command Palette",
+ action: &command_palette::Toggle,
+ },
+ ],
+ },
+ Section {
+ title: "Configure",
+ entries: [
+ SectionEntry {
+ icon: IconName::Settings,
+ title: "Open Settings",
+ action: &OpenSettings,
+ },
+ SectionEntry {
+ icon: IconName::ZedAssistant,
+ title: "View AI Settings",
+ action: &agent::OpenSettings,
+ },
+ SectionEntry {
+ icon: IconName::Blocks,
+ title: "Explore Extensions",
+ action: &Extensions {
+ category_filter: None,
+ id: None,
+ },
+ },
+ ],
+ },
+);
+
+struct Section<const COLS: usize> {
+ title: &'static str,
+ entries: [SectionEntry; COLS],
+}
+
+impl<const COLS: usize> Section<COLS> {
+ fn render(self, index_offset: usize, focus: &FocusHandle, cx: &App) -> impl IntoElement {
+ v_flex()
+ .min_w_full()
+ .child(SectionHeader::new(self.title))
+ .children(
+ self.entries
+ .iter()
+ .enumerate()
+ .map(|(index, entry)| entry.render(index_offset + index, focus, cx)),
+ )
+ }
+}
+
+pub struct WelcomePage {
+ workspace: WeakEntity<Workspace>,
+ focus_handle: FocusHandle,
+ fallback_to_recent_projects: bool,
+ recent_workspaces: Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>>,
+}
+
+impl WelcomePage {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ fallback_to_recent_projects: bool,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let focus_handle = cx.focus_handle();
+ cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
+ .detach();
+
+ if fallback_to_recent_projects {
+ cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
+ let workspaces = WORKSPACE_DB
+ .recent_workspaces_on_disk()
+ .await
+ .log_err()
+ .unwrap_or_default();
+
+ this.update(cx, |this, cx| {
+ this.recent_workspaces = Some(workspaces);
+ cx.notify();
+ })
+ .ok();
+ })
+ .detach();
+ }
+
+ WelcomePage {
+ workspace,
+ focus_handle,
+ fallback_to_recent_projects,
+ recent_workspaces: None,
+ }
+ }
+
+ fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_next();
+ cx.notify();
+ }
+
+ fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
+ window.focus_prev();
+ cx.notify();
+ }
+
+ fn open_recent_project(
+ &mut self,
+ action: &OpenRecentProject,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(recent_workspaces) = &self.recent_workspaces {
+ if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) {
+ let paths = paths.clone();
+ let location = location.clone();
+ let is_local = matches!(location, SerializedWorkspaceLocation::Local);
+ let workspace = self.workspace.clone();
+
+ if is_local {
+ let paths = paths.paths().to_vec();
+ cx.spawn_in(window, async move |_, cx| {
+ let _ = workspace.update_in(cx, |workspace, window, cx| {
+ workspace
+ .open_workspace_for_paths(true, paths, window, cx)
+ .detach();
+ });
+ })
+ .detach();
+ } else {
+ use zed_actions::OpenRecent;
+ window.dispatch_action(OpenRecent::default().boxed_clone(), cx);
+ }
+ }
+ }
+ }
+
+ fn render_recent_project_section(
+ &self,
+ recent_projects: Vec<impl IntoElement>,
+ ) -> impl IntoElement {
+ v_flex()
+ .w_full()
+ .child(SectionHeader::new("Recent Projects"))
+ .children(recent_projects)
+ }
+
+ fn render_recent_project(
+ &self,
+ index: usize,
+ location: &SerializedWorkspaceLocation,
+ paths: &PathList,
+ ) -> impl IntoElement {
+ let (icon, title) = match location {
+ SerializedWorkspaceLocation::Local => {
+ let path = paths.paths().first().map(|p| p.as_path());
+ let name = path
+ .and_then(|p| p.file_name())
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_else(|| "Untitled".to_string());
+ (IconName::Folder, name)
+ }
+ SerializedWorkspaceLocation::Remote(_) => {
+ (IconName::Server, "Remote Project".to_string())
+ }
+ };
+
+ SectionButton::new(
+ title,
+ icon,
+ &OpenRecentProject { index },
+ 10,
+ self.focus_handle.clone(),
+ )
+ }
+}
+
+impl Render for WelcomePage {
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let (first_section, second_section) = CONTENT;
+ let first_section_entries = first_section.entries.len();
+ let last_index = first_section_entries + second_section.entries.len();
+
+ let recent_projects = self
+ .recent_workspaces
+ .as_ref()
+ .into_iter()
+ .flatten()
+ .take(5)
+ .enumerate()
+ .map(|(index, (_, loc, paths))| self.render_recent_project(index, loc, paths))
+ .collect::<Vec<_>>();
+
+ let second_section = if self.fallback_to_recent_projects && !recent_projects.is_empty() {
+ self.render_recent_project_section(recent_projects)
+ .into_any_element()
+ } else {
+ second_section
+ .render(first_section_entries, &self.focus_handle, cx)
+ .into_any_element()
+ };
+
+ let welcome_label = if self.fallback_to_recent_projects {
+ "Welcome back to Zed"
+ } else {
+ "Welcome to Zed"
+ };
+
+ h_flex()
+ .key_context("Welcome")
+ .track_focus(&self.focus_handle(cx))
+ .on_action(cx.listener(Self::select_previous))
+ .on_action(cx.listener(Self::select_next))
+ .on_action(cx.listener(Self::open_recent_project))
+ .size_full()
+ .justify_center()
+ .overflow_hidden()
+ .bg(cx.theme().colors().editor_background)
+ .child(
+ h_flex()
+ .relative()
+ .size_full()
+ .px_12()
+ .py_40()
+ .max_w(px(1100.))
+ .child(
+ v_flex()
+ .size_full()
+ .max_w_128()
+ .mx_auto()
+ .gap_6()
+ .overflow_x_hidden()
+ .child(
+ h_flex()
+ .w_full()
+ .justify_center()
+ .mb_4()
+ .gap_4()
+ .child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
+ .child(
+ v_flex().child(Headline::new(welcome_label)).child(
+ Label::new("The editor for what's next")
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .italic(),
+ ),
+ ),
+ )
+ .child(first_section.render(Default::default(), &self.focus_handle, cx))
+ .child(second_section)
+ .when(!self.fallback_to_recent_projects, |this| {
+ this.child(
+ v_flex().gap_1().child(Divider::horizontal()).child(
+ Button::new("welcome-exit", "Return to Onboarding")
+ .tab_index(last_index as isize)
+ .full_width()
+ .label_size(LabelSize::XSmall)
+ .on_click(|_, window, cx| {
+ window.dispatch_action(
+ OpenOnboarding.boxed_clone(),
+ cx,
+ );
+ }),
+ ),
+ )
+ }),
+ ),
+ )
+ }
+}
+
+impl EventEmitter<ItemEvent> for WelcomePage {}
+
+impl Focusable for WelcomePage {
+ fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl Item for WelcomePage {
+ type Event = ItemEvent;
+
+ fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
+ "Welcome".into()
+ }
+
+ fn telemetry_event_text(&self) -> Option<&'static str> {
+ Some("New Welcome Page Opened")
+ }
+
+ fn show_toolbar(&self) -> bool {
+ false
+ }
+
+ fn to_item_events(event: &Self::Event, mut f: impl FnMut(crate::item::ItemEvent)) {
+ f(*event)
+ }
+}
+
+impl crate::SerializableItem for WelcomePage {
+ fn serialized_item_kind() -> &'static str {
+ "WelcomePage"
+ }
+
+ fn cleanup(
+ workspace_id: crate::WorkspaceId,
+ alive_items: Vec<crate::ItemId>,
+ _window: &mut Window,
+ cx: &mut App,
+ ) -> Task<gpui::Result<()>> {
+ crate::delete_unloaded_items(
+ alive_items,
+ workspace_id,
+ "welcome_pages",
+ &persistence::WELCOME_PAGES,
+ cx,
+ )
+ }
+
+ fn deserialize(
+ _project: Entity<project::Project>,
+ workspace: gpui::WeakEntity<Workspace>,
+ workspace_id: crate::WorkspaceId,
+ item_id: crate::ItemId,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Task<gpui::Result<Entity<Self>>> {
+ if persistence::WELCOME_PAGES
+ .get_welcome_page(item_id, workspace_id)
+ .ok()
+ .is_some_and(|is_open| is_open)
+ {
+ Task::ready(Ok(
+ cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
+ ))
+ } else {
+ Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
+ }
+ }
+
+ fn serialize(
+ &mut self,
+ workspace: &mut Workspace,
+ item_id: crate::ItemId,
+ _closing: bool,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<Task<gpui::Result<()>>> {
+ let workspace_id = workspace.database_id()?;
+ Some(cx.background_spawn(async move {
+ persistence::WELCOME_PAGES
+ .save_welcome_page(item_id, workspace_id, true)
+ .await
+ }))
+ }
+
+ fn should_serialize(&self, event: &Self::Event) -> bool {
+ event == &ItemEvent::UpdateTab
+ }
+}
+
+mod persistence {
+ use crate::WorkspaceDb;
+ use db::{
+ query,
+ sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
+ sqlez_macros::sql,
+ };
+
+ pub struct WelcomePagesDb(ThreadSafeConnection);
+
+ impl Domain for WelcomePagesDb {
+ const NAME: &str = stringify!(WelcomePagesDb);
+
+ const MIGRATIONS: &[&str] = (&[sql!(
+ CREATE TABLE welcome_pages (
+ workspace_id INTEGER,
+ item_id INTEGER UNIQUE,
+ is_open INTEGER DEFAULT FALSE,
+
+ PRIMARY KEY(workspace_id, item_id),
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
+ ON DELETE CASCADE
+ ) STRICT;
+ )]);
+ }
+
+ db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]);
+
+ impl WelcomePagesDb {
+ query! {
+ pub async fn save_welcome_page(
+ item_id: crate::ItemId,
+ workspace_id: crate::WorkspaceId,
+ is_open: bool
+ ) -> Result<()> {
+ INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
+ VALUES (?, ?, ?)
+ }
+ }
+
+ query! {
+ pub fn get_welcome_page(
+ item_id: crate::ItemId,
+ workspace_id: crate::WorkspaceId
+ ) -> Result<bool> {
+ SELECT is_open
+ FROM welcome_pages
+ WHERE item_id = ? AND workspace_id = ?
+ }
+ }
+ }
+}
@@ -16,6 +16,7 @@ mod theme_preview;
mod toast_layer;
mod toolbar;
pub mod utility_pane;
+pub mod welcome;
mod workspace_settings;
pub use crate::notifications::NotificationFrame;
@@ -1157,7 +1157,13 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
app_state,
cx,
|workspace, window, cx| {
- Editor::new_file(workspace, &Default::default(), window, cx)
+ let restore_on_startup = WorkspaceSettings::get_global(cx).restore_on_startup;
+ match restore_on_startup {
+ workspace::RestoreOnStartupBehavior::Launchpad => {}
+ _ => {
+ Editor::new_file(workspace, &Default::default(), window, cx);
+ }
+ }
},
)
})?
@@ -4801,6 +4801,7 @@ mod tests {
"keymap_editor",
"keystroke_input",
"language_selector",
+ "welcome",
"line_ending_selector",
"lsp_tool",
"markdown",
@@ -70,6 +70,8 @@ actions!(
OpenTelemetryLog,
/// Opens the performance profiler.
OpenPerformanceProfiler,
+ /// Opens the onboarding view.
+ OpenOnboarding,
]
);
@@ -3142,7 +3142,15 @@ List of strings containing any combination of:
```json [settings]
{
- "restore_on_startup": "none"
+ "restore_on_startup": "empty_tab"
+}
+```
+
+4. Always start with the welcome launchpad:
+
+```json [settings]
+{
+ "restore_on_startup": "launchpad"
}
```