diff --git a/Cargo.lock b/Cargo.lock index 4858c4ae01c7bdea2eaf46fba87707d3a2e0af24..72a65994b9eee32b3b2c84c846e2825ffd0ff723 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index aac9dcf706856703800068e9e4b7ce9e94d73ecb..bb49582ce0e939a5c43c24862a4e50f9d82125d2 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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], }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 224f6755465d63df0802e3b3919dbdf2ba82246d..3c6ec6e0423e5ea254ddcd9690f92ac11e0fa73a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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], }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 5626309ecb2e17fbbff53347da6059cd2db3be31..b15313fe75cc1265b5eb0c5560f26e4c148d4336 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -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], }, }, { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 05625d2f4e4e66de5c9fe55f62a02eef5d874df9..f4a83f900da68d90803b82c0aec1287fcaa71cd3 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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(); diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index a479379a674589c748e22fc18beb8ee7c85df652..f3fdb8f36c70d1bfde474f842a7bcbeff2668b50 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -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; +} diff --git a/crates/migrator/src/migrations/m_2025_12_15/settings.rs b/crates/migrator/src/migrations/m_2025_12_15/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..c875bdfdddffc62a58912bdc53bcf3e496e4eeab --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_12_15/settings.rs @@ -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, 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") +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 23a24ae199cd076b76b3df2b0d68712f059fd32e..8329d635ce321c1b6280f06cdabe105879cc03a0 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -232,6 +232,10 @@ pub fn migrate_settings(text: &str) -> Result> { &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 = LazyLock::new(|| { diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 2ff3467c4804f7c0a50488a2c4a1e283ea571292..e5e5b5cac93aa4021f8933bd38f8711d53b89902 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -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 diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 94581e142339cde9d4f1f01a3fb361ae810c1efa..66402f33d31c6e9ce5894c56872c8d92d2c4c36c 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -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); } diff --git a/crates/onboarding/src/welcome.rs b/crates/onboarding/src/welcome.rs deleted file mode 100644 index b2711cd52d61a51711bd8ec90581b981d7bcf784..0000000000000000000000000000000000000000 --- a/crates/onboarding/src/welcome.rs +++ /dev/null @@ -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 { - title: &'static str, - entries: [SectionEntry; COLS], -} - -impl Section { - 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) { - window.focus_next(); - cx.notify(); - } - - fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { - window.focus_prev(); - cx.notify(); - } -} - -impl Render for WelcomePage { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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::()?; - 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::()?; - 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 { - cx.new(|cx| { - let focus_handle = cx.focus_handle(); - cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify()) - .detach(); - - WelcomePage { focus_handle } - }) - } -} - -impl EventEmitter 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, - _window: &mut Window, - cx: &mut App, - ) -> Task> { - workspace::delete_unloaded_items( - alive_items, - workspace_id, - "welcome_pages", - &persistence::WELCOME_PAGES, - cx, - ) - } - - fn deserialize( - _project: Entity, - _workspace: gpui::WeakEntity, - workspace_id: workspace::WorkspaceId, - item_id: workspace::ItemId, - window: &mut Window, - cx: &mut App, - ) -> Task>> { - 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, - ) -> Option>> { - 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 { - SELECT is_open - FROM welcome_pages - WHERE item_id = ? AND workspace_id = ? - } - } - } -} diff --git a/crates/settings/src/settings_content/workspace.rs b/crates/settings/src/settings_content/workspace.rs index b809a8fa85a9b27da3f3af5242e99b280466a4bb..832f6ec409c8594c55beab1fd6f327c1215f8bdc 100644 --- a/crates/settings/src/settings_content/workspace.rs +++ b/crates/settings/src/settings_content/workspace.rs @@ -42,7 +42,7 @@ pub struct WorkspaceSettingsContent { /// Default: off pub autosave: Option, /// 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, /// 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] diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 5bd47d02691c9a5c7fec968b5ea6e97265b956b2..4d7397a0bc82142245b86c11ffdf441a6b781ad8 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -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) diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index acf95df37f5d20da65b6e9fa4460ba09b2ea81e3..c2554c63c4f6a1b9836a8ccc24ce4e567fefe601 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -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 diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 338a858f3c774deb1cc0750c56afd678f4eadf4a..036723c13755ff2a7b2b10e9684d822f239a8e0b 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -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>, + welcome_page: Option>, 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()) } } }) diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs new file mode 100644 index 0000000000000000000000000000000000000000..93ff1ea266ff9f40b64064ea03d9bd1b91161300 --- /dev/null +++ b/crates/workspace/src/welcome.rs @@ -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) -> 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, + tab_index: usize, + focus_handle: FocusHandle, +} + +impl SectionButton { + fn new( + label: impl Into, + 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 { + title: &'static str, + entries: [SectionEntry; COLS], +} + +impl Section { + 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, + focus_handle: FocusHandle, + fallback_to_recent_projects: bool, + recent_workspaces: Option>, +} + +impl WelcomePage { + pub fn new( + workspace: WeakEntity, + fallback_to_recent_projects: bool, + window: &mut Window, + cx: &mut Context, + ) -> 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, 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) { + window.focus_next(); + cx.notify(); + } + + fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { + window.focus_prev(); + cx.notify(); + } + + fn open_recent_project( + &mut self, + action: &OpenRecentProject, + window: &mut Window, + cx: &mut Context, + ) { + 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 { + 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) -> 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::>(); + + 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 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, + _window: &mut Window, + cx: &mut App, + ) -> Task> { + crate::delete_unloaded_items( + alive_items, + workspace_id, + "welcome_pages", + &persistence::WELCOME_PAGES, + cx, + ) + } + + fn deserialize( + _project: Entity, + workspace: gpui::WeakEntity, + workspace_id: crate::WorkspaceId, + item_id: crate::ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + 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, + ) -> Option>> { + 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 { + SELECT is_open + FROM welcome_pages + WHERE item_id = ? AND workspace_id = ? + } + } + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 7dfa5d634c73ee639be1e24373ca86b548180547..41304fd77f1eff8d890ff21a3051e57ce3ab295e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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; diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 6d94a15a666c6659f522d4b61962c932347b6304..674cc5f659f7a0d5d97eb7700505eb0ec4c5e5bc 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1157,7 +1157,13 @@ async fn restore_or_create_workspace(app_state: Arc, 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); + } + } }, ) })? diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a51e38bfe48976c8bf12ae1d546f8a8421288af2..c1d98936aa2ad20e6eef7f18bfed2d2c0615395a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4801,6 +4801,7 @@ mod tests { "keymap_editor", "keystroke_input", "language_selector", + "welcome", "line_ending_selector", "lsp_tool", "markdown", diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index f69baa03b002fdcac5207f977a23cfc924283e2d..458ca10ecdf8915eef3ee69c6334b1a14cc0c219 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -70,6 +70,8 @@ actions!( OpenTelemetryLog, /// Opens the performance profiler. OpenPerformanceProfiler, + /// Opens the onboarding view. + OpenOnboarding, ] ); diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 76c0b528fa106ae087297d3c9191ee70620116ba..549dbe6fbb47b03a372ee3ddac87b72dbc4d9c2e 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -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" } ```