Closes #39172 This refactors when we resolve UI keybindings in an effort to reduce flickering whilst painting these: Previously, we would always resolve these upon creating the binding. This could lead to cases where the corresponding context was not yet available and no binding could be resolved, even if the binding was then available on the next presented frame. Following that, on the next rerender of whatever requested this keybinding, the keybind for that context would then be found, we would render that and then also win a layout shift in that process, as we went from nothing rendered to something rendered between these frames. With these changes, this now happens less often, because we only look for the keybinding once the context can actually be resolved in the window. | Before | After | | --- | --- | | https://github.com/user-attachments/assets/adebf8ac-217d-4c7f-ae5a-bab3aa0b0ee8 | https://github.com/user-attachments/assets/70a82b4b-488f-4a9f-94d7-b6d0a49aada9 | Also reduced cloning in the keymap editor in this process, since that requiered changing due to this anyway. Release Notes: - Fixed some cases where keybinds would appear with a slight delay, causing a flicker in the process
444 lines
17 KiB
Rust
444 lines
17 KiB
Rust
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 = ?
|
|
}
|
|
}
|
|
}
|
|
}
|