diff --git a/Cargo.lock b/Cargo.lock index 0f19d4455f..9b2ae7f167 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3018,6 +3018,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" +[[package]] +name = "install_cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "gpui", + "log", + "smol", + "util", +] + [[package]] name = "instant" version = "0.1.12" @@ -8030,6 +8041,26 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" +[[package]] +name = "welcome" +version = "0.1.0" +dependencies = [ + "anyhow", + "db", + "editor", + "fuzzy", + "gpui", + "install_cli", + "log", + "picker", + "project", + "settings", + "theme", + "theme_selector", + "util", + "workspace", +] + [[package]] name = "wepoll-ffi" version = "0.1.2" @@ -8305,6 +8336,7 @@ dependencies = [ "futures 0.3.25", "gpui", "indoc", + "install_cli", "language", "lazy_static", "log", @@ -8397,6 +8429,7 @@ dependencies = [ "command_palette", "context_menu", "ctor", + "db", "diagnostics", "easy-parallel", "editor", @@ -8412,6 +8445,7 @@ dependencies = [ "ignore", "image", "indexmap", + "install_cli", "isahc", "journal", "language", @@ -8477,6 +8511,7 @@ dependencies = [ "util", "uuid 1.2.2", "vim", + "welcome", "workspace", ] diff --git a/Cargo.toml b/Cargo.toml index 63882573ab..ab8bcd6de8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/go_to_line", "crates/gpui", "crates/gpui_macros", + "crates/install_cli", "crates/journal", "crates/language", "crates/language_selector", @@ -59,6 +60,7 @@ members = [ "crates/util", "crates/vim", "crates/workspace", + "crates/welcome", "crates/zed", ] default-members = ["crates/zed"] diff --git a/assets/icons/logo_96.svg b/assets/icons/logo_96.svg new file mode 100644 index 0000000000..dc98bb8bc2 --- /dev/null +++ b/assets/icons/logo_96.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/keymaps/atom.json b/assets/keymaps/atom.json new file mode 100644 index 0000000000..766c46c133 --- /dev/null +++ b/assets/keymaps/atom.json @@ -0,0 +1,68 @@ +[ + { + "bindings": { + "cmd-k cmd-p": "workspace::ActivatePreviousPane", + "cmd-k cmd-n": "workspace::ActivateNextPane" + } + }, + { + "context": "Editor", + "bindings": { + "cmd-b": "editor::GoToDefinition", + "cmd-<": "editor::ScrollCursorCenter", + "cmd-g": [ + "editor::SelectNext", + { + "replace_newest": true + } + ], + "ctrl-shift-down": "editor::AddSelectionBelow", + "ctrl-shift-up": "editor::AddSelectionAbove", + "cmd-shift-backspace": "editor::DeleteToBeginningOfLine" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "cmd-r": "outline::Toggle" + } + }, + { + "context": "BufferSearchBar", + "bindings": { + "cmd-f3": "search::SelectNextMatch", + "cmd-shift-f3": "search::SelectPrevMatch" + } + }, + { + "context": "Workspace", + "bindings": { + "cmd-\\": "workspace::ToggleLeftSidebar", + "cmd-k cmd-b": "workspace::ToggleLeftSidebar", + "cmd-t": "file_finder::Toggle", + "cmd-shift-r": "project_symbols::Toggle" + } + }, + { + "context": "Pane", + "bindings": { + "alt-cmd-/": "search::ToggleRegex", + "ctrl-0": "project_panel::ToggleFocus" + } + }, + { + "context": "ProjectPanel", + "bindings": { + "ctrl-[": "project_panel::CollapseSelectedEntry", + "ctrl-b": "project_panel::CollapseSelectedEntry", + "h": "project_panel::CollapseSelectedEntry", + "ctrl-]": "project_panel::ExpandSelectedEntry", + "ctrl-f": "project_panel::ExpandSelectedEntry", + "ctrl-shift-c": "project_panel::CopyPath" + } + }, + { + "context": "Dock", + "bindings": {} + } +] \ No newline at end of file diff --git a/assets/keymaps/jetbrains.json b/assets/keymaps/jetbrains.json new file mode 100644 index 0000000000..2e6e5e77e6 --- /dev/null +++ b/assets/keymaps/jetbrains.json @@ -0,0 +1,78 @@ +[ + { + "bindings": { + "cmd-shift-[": "pane::ActivatePrevItem", + "cmd-shift-]": "pane::ActivateNextItem" + } + }, + { + "context": "Editor", + "bindings": { + "ctrl->": "zed::IncreaseBufferFontSize", + "ctrl-<": "zed::DecreaseBufferFontSize", + "cmd-d": "editor::DuplicateLine", + "cmd-pagedown": "editor::MovePageDown", + "cmd-pageup": "editor::MovePageUp", + "ctrl-alt-shift-b": "editor::SelectToPreviousWordStart", + "shift-enter": "editor::NewlineBelow", + "cmd--": "editor::Fold", + "cmd-=": "editor::UnfoldLines", + "alt-shift-g": "editor::SplitSelectionIntoLines", + "ctrl-g": [ + "editor::SelectNext", + { + "replace_newest": false + } + ], + "cmd-/": [ + "editor::ToggleComments", + { + "advance_downwards": true + } + ], + "shift-alt-up": "editor::MoveLineUp", + "shift-alt-down": "editor::MoveLineDown", + "cmd-[": "pane::GoBack", + "cmd-]": "pane::GoForward", + "alt-f7": "editor::FindAllReferences", + "cmd-alt-f7": "editor::FindAllReferences", + "cmd-b": "editor::GoToDefinition", + "cmd-alt-b": "editor::GoToDefinition", + "cmd-shift-b": "editor::GoToTypeDefinition", + "alt-enter": "editor::ToggleCodeActions", + "f2": "editor::GoToDiagnostic", + "cmd-f2": "editor::GoToPrevDiagnostic", + "ctrl-alt-shift-down": "editor::GoToHunk", + "ctrl-alt-shift-up": "editor::GoToPrevHunk", + "cmd-home": "editor::MoveToBeginning", + "cmd-end": "editor::MoveToEnd", + "cmd-shift-home": "editor::SelectToBeginning", + "cmd-shift-end": "editor::SelectToEnd" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "cmd-f12": "outline::Toggle", + "cmd-7": "outline::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-l": "go_to_line::Toggle" + } + }, + { + "context": "Workspace", + "bindings": { + "cmd-shift-a": "command_palette::Toggle", + "cmd-alt-o": "project_symbols::Toggle", + "cmd-1": "workspace::ToggleLeftSidebar", + "cmd-6": "diagnostics::Deploy", + "alt-f12": "dock::FocusDock" + } + }, + { + "context": "Dock", + "bindings": { + "alt-f12": "dock::HideDock" + } + } +] \ No newline at end of file diff --git a/assets/keymaps/sublime_text.json b/assets/keymaps/sublime_text.json new file mode 100644 index 0000000000..1d3dd887d7 --- /dev/null +++ b/assets/keymaps/sublime_text.json @@ -0,0 +1,60 @@ +[ + { + "bindings": { + "cmd-shift-[": "pane::ActivatePrevItem", + "cmd-shift-]": "pane::ActivateNextItem", + "ctrl-pagedown": "pane::ActivatePrevItem", + "ctrl-pageup": "pane::ActivateNextItem", + "ctrl-shift-tab": "pane::ActivateNextItem", + "ctrl-tab": "pane::ActivatePrevItem", + "cmd-+": "zed::IncreaseBufferFontSize" + } + }, + { + "context": "Editor", + "bindings": { + "ctrl-shift-up": "editor::AddSelectionAbove", + "ctrl-shift-down": "editor::AddSelectionBelow", + "cmd-shift-space": "editor::SelectAll", + "ctrl-shift-m": "editor::SelectLargerSyntaxNode", + "cmd-shift-a": "editor::SelectLargerSyntaxNode", + "shift-f12": "editor::FindAllReferences", + "alt-cmd-down": "editor::GoToDefinition", + "alt-shift-cmd-down": "editor::FindAllReferences", + "ctrl-.": "editor::GoToHunk", + "ctrl-,": "editor::GoToPrevHunk", + "ctrl-backspace": "editor::DeleteToPreviousWordStart", + "ctrl-delete": "editor::DeleteToNextWordEnd" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "cmd-r": "outline::Toggle" + } + }, + { + "context": "Pane", + "bindings": { + "f4": "search::SelectNextMatch", + "shift-f4": "search::SelectPrevMatch" + } + }, + { + "context": "Workspace", + "bindings": { + "ctrl-`": "dock::FocusDock", + "cmd-k cmd-b": "workspace::ToggleLeftSidebar", + "cmd-t": "file_finder::Toggle", + "shift-cmd-r": "project_symbols::Toggle", + // Currently busted: https://github.com/zed-industries/feedback/issues/898 + "ctrl-0": "project_panel::ToggleFocus" + } + }, + { + "context": "Dock", + "bindings": { + "ctrl-`": "dock::HideDock" + } + } +] \ No newline at end of file diff --git a/assets/settings/default.json b/assets/settings/default.json index 2a5e05b401..90c47478f3 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -50,7 +50,7 @@ // "default_dock_anchor": "right" // 3. Position the dock full screen over the entire workspace" // "default_dock_anchor": "expanded" - "default_dock_anchor": "right", + "default_dock_anchor": "bottom", // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. "remove_trailing_whitespace_on_save": true, diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index e9ffecf246..80df0ed6df 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -197,7 +197,8 @@ impl TestServer { fs: fs.clone(), build_window_options: |_, _, _| Default::default(), initialize_workspace: |_, _, _| unimplemented!(), - dock_default_item_factory: |_, _| unimplemented!(), + dock_default_item_factory: |_, _| None, + background_actions: || &[], }); Project::init(&client); @@ -434,15 +435,7 @@ impl TestClient { cx: &mut TestAppContext, ) -> ViewHandle { let (_, root_view) = cx.add_window(|_| EmptyView); - cx.add_view(&root_view, |cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }) + cx.add_view(&root_view, |cx| Workspace::test_new(project.clone(), cx)) } fn create_new_root_dir(&mut self) -> PathBuf { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 4f027af758..44a2839f27 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -1449,15 +1449,7 @@ async fn test_host_disconnect( deterministic.run_until_parked(); assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - let (_, workspace_b) = cx_b.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project_b.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "b.txt"), None, true, cx) @@ -4706,15 +4698,7 @@ async fn test_collaborating_with_code_actions( // Join the project as client B. let project_b = client_b.build_remote_project(project_id, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project_b.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "main.rs"), None, true, cx) @@ -4937,15 +4921,7 @@ async fn test_collaborating_with_renames( .unwrap(); let project_b = client_b.build_remote_project(project_id, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project_b.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "one.rs"), None, true, cx) diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index c838f6a55f..e8c8951526 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -821,7 +821,7 @@ impl CollabTitlebarItem { avatar_style: AvatarStyle, background_color: Color, ) -> ElementBox { - Image::new(avatar) + Image::from_data(avatar) .with_style(avatar_style.image) .aligned() .contained() diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 6abfec21f7..2dd2b0e6b4 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -86,6 +86,7 @@ fn join_project(action: &JoinProject, app_state: Arc, cx: &mut Mutable 0, project, app_state.dock_default_item_factory, + app_state.background_actions, cx, ); (app_state.initialize_workspace)(&mut workspace, &app_state, cx); diff --git a/crates/collab_ui/src/contact_finder.rs b/crates/collab_ui/src/contact_finder.rs index 3b93414e1f..83f094ebad 100644 --- a/crates/collab_ui/src/contact_finder.rs +++ b/crates/collab_ui/src/contact_finder.rs @@ -128,7 +128,7 @@ impl PickerDelegate for ContactFinder { .style_for(mouse_state, selected); Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_finder.contact_avatar) .aligned() .left() diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs index dd6683292e..38444f6f12 100644 --- a/crates/collab_ui/src/contact_list.rs +++ b/crates/collab_ui/src/contact_list.rs @@ -736,7 +736,7 @@ impl ContactList { ) -> ElementBox { Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_avatar) .aligned() .left() @@ -1090,7 +1090,7 @@ impl ContactList { }; Stack::new() .with_child( - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_avatar) .aligned() .left() @@ -1183,7 +1183,7 @@ impl ContactList { let mut row = Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_avatar) .aligned() .left() diff --git a/crates/collab_ui/src/incoming_call_notification.rs b/crates/collab_ui/src/incoming_call_notification.rs index a0f54abe38..6fb0278218 100644 --- a/crates/collab_ui/src/incoming_call_notification.rs +++ b/crates/collab_ui/src/incoming_call_notification.rs @@ -108,7 +108,7 @@ impl IncomingCallNotification { .unwrap_or(&default_project); Flex::row() .with_children(self.call.calling_user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.caller_avatar) .aligned() .boxed() diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index 06b6cf2a90..21c2d2c218 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -24,7 +24,7 @@ pub fn render_user_notification( .with_child( Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.header_avatar) .aligned() .constrained() diff --git a/crates/collab_ui/src/project_shared_notification.rs b/crates/collab_ui/src/project_shared_notification.rs index edf0354eec..b24f3492da 100644 --- a/crates/collab_ui/src/project_shared_notification.rs +++ b/crates/collab_ui/src/project_shared_notification.rs @@ -108,7 +108,7 @@ impl ProjectSharedNotification { let theme = &cx.global::().theme.project_shared_notification; Flex::row() .with_children(self.owner.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.owner_avatar) .aligned() .boxed() diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 55522966fa..52a0e1cdc0 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -352,9 +352,7 @@ mod tests { }); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let editor = cx.add_view(&workspace, |cx| { let mut editor = Editor::single_line(None, cx); editor.set_text("abc", cx); diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index 20f2300d89..ae9325ea2e 100644 --- a/crates/db/src/db.rs +++ b/crates/db/src/db.rs @@ -4,6 +4,7 @@ pub mod query; // Re-export pub use anyhow; use anyhow::Context; +use gpui::MutableAppContext; pub use indoc::indoc; pub use lazy_static; use parking_lot::{Mutex, RwLock}; @@ -17,6 +18,7 @@ use sqlez::domain::Migrator; use sqlez::thread_safe_connection::ThreadSafeConnection; use sqlez_macros::sql; use std::fs::create_dir_all; +use std::future::Future; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -39,6 +41,7 @@ const FALLBACK_DB_NAME: &'static str = "FALLBACK_MEMORY_DB"; const DB_FILE_NAME: &'static str = "db.sqlite"; lazy_static::lazy_static! { + // !!!!!!! CHANGE BACK TO DEFAULT FALSE BEFORE SHIPPING static ref ZED_STATELESS: bool = std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty()); static ref DB_FILE_OPERATIONS: Mutex<()> = Mutex::new(()); pub static ref BACKUP_DB_PATH: RwLock> = RwLock::new(None); @@ -63,11 +66,11 @@ pub async fn open_db( let connection = async_iife!({ // Note: This still has a race condition where 1 set of migrations succeeds // (e.g. (Workspace, Editor)) and another fails (e.g. (Workspace, Terminal)) - // This will cause the first connection to have the database taken out + // This will cause the first connection to have the database taken out // from under it. This *should* be fine though. The second dabatase failure will // cause errors in the log and so should be observed by developers while writing // soon-to-be good migrations. If user databases are corrupted, we toss them out - // and try again from a blank. As long as running all migrations from start to end + // and try again from a blank. As long as running all migrations from start to end // on a blank database is ok, this race condition will never be triggered. // // Basically: Don't ever push invalid migrations to stable or everyone will have @@ -85,7 +88,7 @@ pub async fn open_db( }; } - // Take a lock in the failure case so that we move the db once per process instead + // Take a lock in the failure case so that we move the db once per process instead // of potentially multiple times from different threads. This shouldn't happen in the // normal path let _lock = DB_FILE_OPERATIONS.lock(); @@ -236,6 +239,15 @@ macro_rules! define_connection { }; } +pub fn write_and_log(cx: &mut MutableAppContext, db_write: impl FnOnce() -> F + Send + 'static) +where + F: Future> + Send, +{ + cx.background() + .spawn(async move { db_write().await.log_err() }) + .detach() +} + #[cfg(test)] mod tests { use std::{fs, thread}; diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index e447a26c6b..2232555442 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -805,15 +805,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); // Create some diagnostics project.update(cx, |project, cx| { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 269390f72f..4f61bde398 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -484,7 +484,7 @@ fn test_navigation_history(cx: &mut gpui::MutableAppContext) { cx.set_global(Settings::test(cx)); cx.set_global(DragAndDrop::::default()); use workspace::item::Item; - let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(None, cx)); + let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(0, None, || &[], cx)); let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); cx.add_view(&pane, |cx| { @@ -2353,12 +2353,16 @@ async fn test_clipboard(cx: &mut gpui::TestAppContext) { e.paste(&Paste, cx); e.handle_input(") ", cx); }); - cx.assert_editor_state(indoc! {" - ( one✅ - three - five ) ˇtwo one✅ four three six five ( one✅ - three - five ) ˇ"}); + cx.assert_editor_state( + &([ + "( one✅ ", + "three ", + "five ) ˇtwo one✅ four three six five ( one✅ ", + "three ", + "five ) ˇ", + ] + .join("\n")), + ); // Cut with three selections, one of which is full-line. cx.set_state(indoc! {" @@ -5562,7 +5566,7 @@ async fn test_following_with_multiple_excerpts(cx: &mut gpui::TestAppContext) { Settings::test_async(cx); let fs = FakeFs::new(cx.background()); let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; - let (_, pane) = cx.add_window(|cx| Pane::new(None, cx)); + let (_, pane) = cx.add_window(|cx| Pane::new(0, None, || &[], cx)); let leader = pane.update(cx, |_, cx| { let multibuffer = cx.add_model(|_| MultiBuffer::new(0)); @@ -5831,11 +5835,11 @@ async fn go_to_hunk(deterministic: Arc, cx: &mut gpui::TestAppCon cx.assert_editor_state( &r#" ˇuse some::modified; - - + + fn main() { println!("hello there"); - + println!("around the"); println!("world"); } diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 1b6d846e71..fe9a7909b8 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -65,15 +65,7 @@ impl<'a> EditorLspTestContext<'a> { .insert_tree("/root", json!({ "dir": { file_name.clone(): "" }})) .await; - let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); project .update(cx, |project, cx| { project.find_or_create_local_worktree("/root", true, cx) @@ -134,7 +126,7 @@ impl<'a> EditorLspTestContext<'a> { (let_chain) (await_expression) ] @indent - + (_ "[" "]" @end) @indent (_ "<" ">" @end) @indent (_ "{" "}" @end) @indent diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 273440dce2..51d4df45f5 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -329,9 +329,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); cx.dispatch_action(window_id, Toggle); let finder = cx.read(|cx| workspace.read(cx).modal::().unwrap()); @@ -385,9 +383,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let (_, finder) = cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx)); @@ -461,9 +457,7 @@ mod tests { cx, ) .await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let (_, finder) = cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx)); finder @@ -487,9 +481,7 @@ mod tests { cx, ) .await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let (_, finder) = cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx)); @@ -541,9 +533,7 @@ mod tests { cx, ) .await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let (_, finder) = cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx)); @@ -585,9 +575,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); // When workspace has an active item, sort items which are closer to that item // first when they have the same name. In this case, b.txt is closer to dir2's a.txt @@ -624,9 +612,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let (_, finder) = cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), None, cx)); finder diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 31563010b7..0397032de8 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -5086,7 +5086,7 @@ impl From> for AnyWeakModelHandle { } } -#[derive(Debug)] +#[derive(Debug, Copy)] pub struct WeakViewHandle { window_id: usize, view_id: usize, diff --git a/crates/gpui/src/assets.rs b/crates/gpui/src/assets.rs index ac0d72dee9..2170d215af 100644 --- a/crates/gpui/src/assets.rs +++ b/crates/gpui/src/assets.rs @@ -1,5 +1,8 @@ use anyhow::{anyhow, Result}; -use std::{borrow::Cow, cell::RefCell, collections::HashMap}; +use image::ImageFormat; +use std::{borrow::Cow, cell::RefCell, collections::HashMap, sync::Arc}; + +use crate::ImageData; pub trait AssetSource: 'static + Send + Sync { fn load(&self, path: &str) -> Result>; @@ -22,6 +25,7 @@ impl AssetSource for () { pub struct AssetCache { source: Box, svgs: RefCell>, + pngs: RefCell>>, } impl AssetCache { @@ -29,6 +33,7 @@ impl AssetCache { Self { source: Box::new(source), svgs: RefCell::new(HashMap::new()), + pngs: RefCell::new(HashMap::new()), } } @@ -43,4 +48,18 @@ impl AssetCache { Ok(svg) } } + + pub fn png(&self, path: &str) -> Result> { + let mut pngs = self.pngs.borrow_mut(); + if let Some(png) = pngs.get(path) { + Ok(png.clone()) + } else { + let bytes = self.source.load(path)?; + let image = ImageData::new( + image::load_from_memory_with_format(&bytes, ImageFormat::Png)?.into_bgra8(), + ); + pngs.insert(path.to_string(), image.clone()); + Ok(image) + } + } } diff --git a/crates/gpui/src/elements/constrained_box.rs b/crates/gpui/src/elements/constrained_box.rs index 2e232c6197..e4d51f5730 100644 --- a/crates/gpui/src/elements/constrained_box.rs +++ b/crates/gpui/src/elements/constrained_box.rs @@ -153,7 +153,9 @@ impl Element for ConstrainedBox { _: &mut Self::LayoutState, cx: &mut PaintContext, ) -> Self::PaintState { - self.child.paint(bounds.origin(), visible_bounds, cx); + cx.paint_layer(Some(visible_bounds), |cx| { + self.child.paint(bounds.origin(), visible_bounds, cx); + }) } fn rect_for_text_range( diff --git a/crates/gpui/src/elements/flex.rs b/crates/gpui/src/elements/flex.rs index ce595222f3..5df283bfee 100644 --- a/crates/gpui/src/elements/flex.rs +++ b/crates/gpui/src/elements/flex.rs @@ -22,6 +22,7 @@ pub struct Flex { axis: Axis, children: Vec, scroll_state: Option<(ElementStateHandle>, usize)>, + child_alignment: f32, } impl Flex { @@ -30,6 +31,7 @@ impl Flex { axis, children: Default::default(), scroll_state: None, + child_alignment: -1., } } @@ -41,6 +43,15 @@ impl Flex { Self::new(Axis::Vertical) } + /// Render children centered relative to the cross-axis of the parent flex. + /// + /// If this is a flex row, children will be centered vertically. If this is a + /// flex column, children will be centered horizontally. + pub fn align_children_center(mut self) -> Self { + self.child_alignment = 0.; + self + } + pub fn scrollable( mut self, element_id: usize, @@ -309,7 +320,30 @@ impl Element for Flex { } } - child.paint(child_origin, visible_bounds, cx); + // We use the child_alignment f32 to determine a point along the cross axis of the + // overall flex element and each child. We then align these points. So 0 would center + // each child relative to the overall height/width of the flex. -1 puts children at + // the start. 1 puts children at the end. + let aligned_child_origin = { + let cross_axis = self.axis.invert(); + let my_center = bounds.size().along(cross_axis) / 2.; + let my_target = my_center + my_center * self.child_alignment; + + let child_center = child.size().along(cross_axis) / 2.; + let child_target = child_center + child_center * self.child_alignment; + + let mut aligned_child_origin = child_origin; + match self.axis { + Axis::Horizontal => aligned_child_origin + .set_y(aligned_child_origin.y() - (child_target - my_target)), + Axis::Vertical => aligned_child_origin + .set_x(aligned_child_origin.x() - (child_target - my_target)), + } + + aligned_child_origin + }; + + child.paint(aligned_child_origin, visible_bounds, cx); match self.axis { Axis::Horizontal => child_origin += vec2f(child.size().x(), 0.0), diff --git a/crates/gpui/src/elements/image.rs b/crates/gpui/src/elements/image.rs index 37cb01ace8..cc49308e15 100644 --- a/crates/gpui/src/elements/image.rs +++ b/crates/gpui/src/elements/image.rs @@ -11,8 +11,13 @@ use crate::{ use serde::Deserialize; use std::{ops::Range, sync::Arc}; +enum ImageSource { + Path(&'static str), + Data(Arc), +} + pub struct Image { - data: Arc, + source: ImageSource, style: ImageStyle, } @@ -31,9 +36,16 @@ pub struct ImageStyle { } impl Image { - pub fn new(data: Arc) -> Self { + pub fn new(asset_path: &'static str) -> Self { Self { - data, + source: ImageSource::Path(asset_path), + style: Default::default(), + } + } + + pub fn from_data(data: Arc) -> Self { + Self { + source: ImageSource::Data(data), style: Default::default(), } } @@ -45,39 +57,53 @@ impl Image { } impl Element for Image { - type LayoutState = (); + type LayoutState = Option>; type PaintState = (); fn layout( &mut self, constraint: SizeConstraint, - _: &mut LayoutContext, + cx: &mut LayoutContext, ) -> (Vector2F, Self::LayoutState) { + let data = match &self.source { + ImageSource::Path(path) => match cx.asset_cache.png(path) { + Ok(data) => data, + Err(error) => { + log::error!("could not load image: {}", error); + return (Vector2F::zero(), None); + } + }, + ImageSource::Data(data) => data.clone(), + }; + let desired_size = vec2f( self.style.width.unwrap_or_else(|| constraint.max.x()), self.style.height.unwrap_or_else(|| constraint.max.y()), ); let size = constrain_size_preserving_aspect_ratio( constraint.constrain(desired_size), - self.data.size().to_f32(), + data.size().to_f32(), ); - (size, ()) + + (size, Some(data)) } fn paint( &mut self, bounds: RectF, _: RectF, - _: &mut Self::LayoutState, + layout: &mut Self::LayoutState, cx: &mut PaintContext, ) -> Self::PaintState { - cx.scene.push_image(scene::Image { - bounds, - border: self.style.border, - corner_radius: self.style.corner_radius, - grayscale: self.style.grayscale, - data: self.data.clone(), - }); + if let Some(data) = layout { + cx.scene.push_image(scene::Image { + bounds, + border: self.style.border, + corner_radius: self.style.corner_radius, + grayscale: self.style.grayscale, + data: data.clone(), + }); + } } fn rect_for_text_range( diff --git a/crates/install_cli/Cargo.toml b/crates/install_cli/Cargo.toml new file mode 100644 index 0000000000..bbbe989920 --- /dev/null +++ b/crates/install_cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "install_cli" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/install_cli.rs" + +[features] +test-support = [] + +[dependencies] +smol = "1.2.5" +anyhow = "1.0.38" +log = "0.4" +gpui = { path = "../gpui" } +util = { path = "../util" } diff --git a/crates/install_cli/src/install_cli.rs b/crates/install_cli/src/install_cli.rs new file mode 100644 index 0000000000..adf50586d7 --- /dev/null +++ b/crates/install_cli/src/install_cli.rs @@ -0,0 +1,55 @@ +use std::path::Path; + +use anyhow::{anyhow, Result}; +use gpui::{actions, AsyncAppContext}; +use util::ResultExt; + +actions!(cli, [Install]); + +pub async fn install_cli(cx: &AsyncAppContext) -> Result<()> { + let cli_path = cx.platform().path_for_auxiliary_executable("cli")?; + let link_path = Path::new("/usr/local/bin/zed"); + let bin_dir_path = link_path.parent().unwrap(); + + // Don't re-create symlink if it points to the same CLI binary. + if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) { + return Ok(()); + } + + // If the symlink is not there or is outdated, first try replacing it + // without escalating. + smol::fs::remove_file(link_path).await.log_err(); + if smol::fs::unix::symlink(&cli_path, link_path) + .await + .log_err() + .is_some() + { + return Ok(()); + } + + // The symlink could not be created, so use osascript with admin privileges + // to create it. + let status = smol::process::Command::new("osascript") + .args([ + "-e", + &format!( + "do shell script \" \ + mkdir -p \'{}\' && \ + ln -sf \'{}\' \'{}\' \ + \" with administrator privileges", + bin_dir_path.to_string_lossy(), + cli_path.to_string_lossy(), + link_path.to_string_lossy(), + ), + ]) + .stdout(smol::process::Stdio::inherit()) + .stderr(smol::process::Stdio::inherit()) + .output() + .await? + .status; + if status.success() { + Ok(()) + } else { + Err(anyhow!("error running osascript")) + } +} diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index 1ad97e61b1..6db078ee93 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -48,7 +48,7 @@ pub fn new_journal_entry(app_state: Arc, cx: &mut MutableAppContext) { async move { let (journal_dir, entry_path) = create_entry.await?; let (workspace, _) = cx - .update(|cx| workspace::open_paths(&[journal_dir], &app_state, cx)) + .update(|cx| workspace::open_paths(&[journal_dir], &app_state, None, cx)) .await; let opened = workspace diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 547e016036..fb4b51a163 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -102,7 +102,10 @@ impl View for Picker { .read(cx) .render_match(ix, state, ix == selected_ix, cx) }) - .on_down(MouseButton::Left, move |_, cx| { + // Capture mouse events + .on_down(MouseButton::Left, |_, _| {}) + .on_up(MouseButton::Left, |_, _| {}) + .on_click(MouseButton::Left, move |_, cx| { cx.dispatch_action(SelectIndex(ix)) }) .with_cursor_style(CursorStyle::PointingHand) diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 8b622ab607..d53e84f6b1 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -867,7 +867,7 @@ impl LocalWorktree { let old_path = self.entry_for_id(entry_id)?.path.clone(); let new_path = new_path.into(); let abs_old_path = self.absolutize(&old_path); - let abs_new_path = self.absolutize(&new_path); + let abs_new_path = self.absolutize(new_path.as_ref()); let rename = cx.background().spawn({ let fs = self.fs.clone(); let abs_new_path = abs_new_path.clone(); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 4b3c5b7bc5..079de79b11 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1262,54 +1262,89 @@ impl View for ProjectPanel { let padding = std::mem::take(&mut container_style.padding); let last_worktree_root_id = self.last_worktree_root_id; - Stack::new() - .with_child( - MouseEventHandler::::new(0, cx, |_, cx| { - UniformList::new( - self.list.clone(), - self.visible_entries - .iter() - .map(|(_, worktree_entries)| worktree_entries.len()) - .sum(), - cx, - move |this, range, items, cx| { - let theme = cx.global::().theme.clone(); - let mut dragged_entry_destination = - this.dragged_entry_destination.clone(); - this.for_each_visible_entry(range, cx, |id, details, cx| { - items.push(Self::render_entry( - id, - details, - &this.filename_editor, - &mut dragged_entry_destination, - &theme.project_panel, - cx, - )); - }); - this.dragged_entry_destination = dragged_entry_destination; - }, - ) - .with_padding_top(padding.top) - .with_padding_bottom(padding.bottom) - .contained() - .with_style(container_style) - .expanded() - .boxed() - }) - .on_down(MouseButton::Right, move |e, cx| { - // When deploying the context menu anywhere below the last project entry, - // act as if the user clicked the root of the last worktree. - if let Some(entry_id) = last_worktree_root_id { - cx.dispatch_action(DeployContextMenu { - entry_id, - position: e.position, - }) - } - }) - .boxed(), - ) - .with_child(ChildView::new(&self.context_menu, cx).boxed()) - .boxed() + let has_worktree = self.visible_entries.len() != 0; + + if has_worktree { + Stack::new() + .with_child( + MouseEventHandler::::new(0, cx, |_, cx| { + UniformList::new( + self.list.clone(), + self.visible_entries + .iter() + .map(|(_, worktree_entries)| worktree_entries.len()) + .sum(), + cx, + move |this, range, items, cx| { + let theme = cx.global::().theme.clone(); + let mut dragged_entry_destination = + this.dragged_entry_destination.clone(); + this.for_each_visible_entry(range, cx, |id, details, cx| { + items.push(Self::render_entry( + id, + details, + &this.filename_editor, + &mut dragged_entry_destination, + &theme.project_panel, + cx, + )); + }); + this.dragged_entry_destination = dragged_entry_destination; + }, + ) + .with_padding_top(padding.top) + .with_padding_bottom(padding.bottom) + .contained() + .with_style(container_style) + .expanded() + .boxed() + }) + .on_down(MouseButton::Right, move |e, cx| { + // When deploying the context menu anywhere below the last project entry, + // act as if the user clicked the root of the last worktree. + if let Some(entry_id) = last_worktree_root_id { + cx.dispatch_action(DeployContextMenu { + entry_id, + position: e.position, + }) + } + }) + .boxed(), + ) + .with_child(ChildView::new(&self.context_menu, cx).boxed()) + .boxed() + } else { + Flex::column() + .with_child( + MouseEventHandler::::new(2, cx, { + let button_style = theme.open_project_button.clone(); + let context_menu_item_style = + cx.global::().theme.context_menu.item.clone(); + move |state, cx| { + let button_style = button_style.style_for(state, false).clone(); + let context_menu_item = + context_menu_item_style.style_for(state, true).clone(); + + theme::ui::keystroke_label( + "Open a project", + &button_style, + &context_menu_item.keystroke, + Box::new(workspace::Open), + cx, + ) + .boxed() + } + }) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(workspace::Open) + }) + .with_cursor_style(CursorStyle::PointingHand) + .boxed(), + ) + .contained() + .with_style(container_style) + .boxed() + } } fn keymap_context(&self, _: &AppContext) -> KeymapContext { @@ -1404,15 +1439,7 @@ mod tests { .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx)); assert_eq!( visible_entries_as_strings(&panel, 0..50, cx), @@ -1504,15 +1531,7 @@ mod tests { .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx)); select_path(&panel, "root1", cx); diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 01992d9431..2235bc351d 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -1,4 +1,4 @@ -use crate::parse_json_with_comments; +use crate::{parse_json_with_comments, Settings}; use anyhow::{Context, Result}; use assets::Assets; use collections::BTreeMap; @@ -45,6 +45,10 @@ impl KeymapFileContent { for path in ["keymaps/default.json", "keymaps/vim.json"] { Self::load(path, cx).unwrap(); } + + if let Some(asset_path) = cx.global::().base_keymap.asset_path() { + Self::load(asset_path, cx).log_err(); + } } pub fn load(asset_path: &str, cx: &mut MutableAppContext) -> Result<()> { diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 6b51b06c9c..49f43e7a2d 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -18,12 +18,13 @@ use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; -use std::{collections::HashMap, fmt::Write as _, num::NonZeroU32, str, sync::Arc}; +use std::{collections::HashMap, num::NonZeroU32, str, sync::Arc}; use theme::{Theme, ThemeRegistry}; use tree_sitter::Query; use util::ResultExt as _; pub use keymap_file::{keymap_file_json_schema, KeymapFileContent}; +pub use watched_json::watch_files; #[derive(Clone)] pub struct Settings { @@ -54,6 +55,46 @@ pub struct Settings { pub telemetry_defaults: TelemetrySettings, pub telemetry_overrides: TelemetrySettings, pub auto_update: bool, + pub base_keymap: BaseKeymap, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] +pub enum BaseKeymap { + #[default] + VSCode, + JetBrains, + Sublime, + Atom, +} + +impl BaseKeymap { + pub const OPTIONS: [(&'static str, Self); 4] = [ + ("VSCode (Default)", Self::VSCode), + ("Atom", Self::Atom), + ("JetBrains", Self::JetBrains), + ("Sublime", Self::Sublime), + ]; + + pub fn asset_path(&self) -> Option<&'static str> { + match self { + BaseKeymap::JetBrains => Some("keymaps/jetbrains.json"), + BaseKeymap::Sublime => Some("keymaps/sublime_text.json"), + BaseKeymap::Atom => Some("keymaps/atom.json"), + BaseKeymap::VSCode => None, + } + } + + pub fn names() -> impl Iterator { + Self::OPTIONS.iter().map(|(name, _)| *name) + } + + pub fn from_names(option: &str) -> BaseKeymap { + Self::OPTIONS + .iter() + .copied() + .find_map(|(name, value)| (name == option).then(|| value)) + .unwrap_or_default() + } } #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] @@ -66,9 +107,18 @@ impl TelemetrySettings { pub fn metrics(&self) -> bool { self.metrics.unwrap() } + pub fn diagnostics(&self) -> bool { self.diagnostics.unwrap() } + + pub fn set_metrics(&mut self, value: bool) { + self.metrics = Some(value); + } + + pub fn set_diagnostics(&mut self, value: bool) { + self.diagnostics = Some(value); + } } #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] @@ -317,6 +367,8 @@ pub struct SettingsFileContent { pub telemetry: TelemetrySettings, #[serde(default)] pub auto_update: Option, + #[serde(default)] + pub base_keymap: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -387,6 +439,7 @@ impl Settings { telemetry_defaults: defaults.telemetry, telemetry_overrides: Default::default(), auto_update: defaults.auto_update.unwrap(), + base_keymap: Default::default(), } } @@ -424,6 +477,7 @@ impl Settings { merge(&mut self.vim_mode, data.vim_mode); merge(&mut self.autosave, data.autosave); merge(&mut self.default_dock_anchor, data.default_dock_anchor); + merge(&mut self.base_keymap, data.base_keymap); // Ensure terminal font is loaded, so we can request it in terminal_element layout if let Some(terminal_font) = &data.terminal.font_family { @@ -601,6 +655,7 @@ impl Settings { }, telemetry_overrides: Default::default(), auto_update: true, + base_keymap: Default::default(), } } @@ -677,13 +732,19 @@ pub fn settings_file_json_schema( serde_json::to_value(root_schema).unwrap() } -/// Expects the key to be unquoted, and the value to be valid JSON -/// (e.g. values should be unquoted for numbers and bools, quoted for strings) -pub fn write_top_level_setting( - mut settings_content: String, - top_level_key: &str, - new_val: &str, -) -> String { +fn merge(target: &mut T, value: Option) { + if let Some(value) = value { + *target = value; + } +} + +pub fn parse_json_with_comments(content: &str) -> Result { + Ok(serde_json::from_reader( + json_comments::CommentSettings::c_style().strip_comments(content.as_bytes()), + )?) +} + +fn write_settings_key(settings_content: &mut String, key_path: &[&str], new_value: &Value) { let mut parser = tree_sitter::Parser::new(); parser.set_language(tree_sitter_json::language()).unwrap(); let tree = parser.parse(&settings_content, None).unwrap(); @@ -693,56 +754,64 @@ pub fn write_top_level_setting( let query = Query::new( tree_sitter_json::language(), " - (document - (object - (pair - key: (string) @key - value: (_) @value))) - ", + (pair + key: (string) @key + value: (_) @value) + ", ) .unwrap(); + let mut depth = 0; let mut first_key_start = None; - let mut existing_value_range = None; + let mut existing_value_range = 0..settings_content.len(); let matches = cursor.matches(&query, tree.root_node(), settings_content.as_bytes()); for mat in matches { if mat.captures.len() != 2 { continue; } - let key = mat.captures[0]; - let value = mat.captures[1]; + let key_range = mat.captures[0].node.byte_range(); + let value_range = mat.captures[1].node.byte_range(); - first_key_start.get_or_insert_with(|| key.node.start_byte()); + if key_range.start > existing_value_range.end { + break; + } - if let Some(key_text) = settings_content.get(key.node.byte_range()) { - if key_text == format!("\"{top_level_key}\"") { - existing_value_range = Some(value.node.byte_range()); + first_key_start.get_or_insert_with(|| key_range.start); + + let found_key = settings_content + .get(key_range.clone()) + .map(|key_text| key_text == format!("\"{}\"", key_path[depth])) + .unwrap_or(false); + + if found_key { + existing_value_range = value_range; + depth += 1; + + if depth == key_path.len() { break; + } else { + first_key_start = None; } } } - match (first_key_start, existing_value_range) { - (None, None) => { - // No document, create a new object and overwrite - settings_content.clear(); - write!( - settings_content, - "{{\n \"{}\": {new_val}\n}}\n", - top_level_key - ) - .unwrap(); + // We found the exact key we want, insert the new value + if depth == key_path.len() { + let new_val = serde_json::to_string_pretty(new_value) + .expect("Could not serialize new json field to string"); + settings_content.replace_range(existing_value_range, &new_val); + } else { + // We have key paths, construct the sub objects + let new_key = key_path[depth]; + + // We don't have the key, construct the nested objects + let mut new_value = serde_json::to_value(new_value).unwrap(); + for key in key_path[(depth + 1)..].iter().rev() { + new_value = serde_json::json!({ key.to_string(): new_value }); } - (_, Some(existing_value_range)) => { - // Existing theme key, overwrite - settings_content.replace_range(existing_value_range, &new_val); - } - - (Some(first_key_start), None) => { - // No existing theme key, but other settings. Prepend new theme settings and - // match style of first key + if let Some(first_key_start) = first_key_start { let mut row = 0; let mut column = 0; for (ix, char) in settings_content.char_indices() { @@ -757,142 +826,374 @@ pub fn write_top_level_setting( } } - let content = format!(r#""{top_level_key}": {new_val},"#); - settings_content.insert_str(first_key_start, &content); - if row > 0 { + let new_val = to_pretty_json(&new_value, column, column); + let content = format!(r#""{new_key}": {new_val},"#); + settings_content.insert_str(first_key_start, &content); + settings_content.insert_str( first_key_start + content.len(), &format!("\n{:width$}", ' ', width = column), ) } else { - settings_content.insert_str(first_key_start + content.len(), " ") + let new_val = serde_json::to_string(&new_value).unwrap(); + let mut content = format!(r#""{new_key}": {new_val},"#); + content.push(' '); + settings_content.insert_str(first_key_start, &content); + } + } else { + new_value = serde_json::json!({ new_key.to_string(): new_value }); + let indent_prefix_len = 4 * depth; + let new_val = to_pretty_json(&new_value, 4, indent_prefix_len); + + settings_content.replace_range(existing_value_range, &new_val); + if depth == 0 { + settings_content.push('\n'); + } + } + } +} + +fn to_pretty_json( + value: &serde_json::Value, + indent_size: usize, + indent_prefix_len: usize, +) -> String { + const SPACES: [u8; 32] = [b' '; 32]; + + debug_assert!(indent_size <= SPACES.len()); + debug_assert!(indent_prefix_len <= SPACES.len()); + + let mut output = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter( + &mut output, + serde_json::ser::PrettyFormatter::with_indent(&SPACES[0..indent_size.min(SPACES.len())]), + ); + + value.serialize(&mut ser).unwrap(); + let text = String::from_utf8(output).unwrap(); + + let mut adjusted_text = String::new(); + for (i, line) in text.split('\n').enumerate() { + if i > 0 { + adjusted_text.push_str(str::from_utf8(&SPACES[0..indent_prefix_len]).unwrap()); + } + adjusted_text.push_str(line); + adjusted_text.push('\n'); + } + adjusted_text.pop(); + adjusted_text +} + +pub fn update_settings_file( + mut text: String, + old_file_content: SettingsFileContent, + update: impl FnOnce(&mut SettingsFileContent), +) -> String { + let mut new_file_content = old_file_content.clone(); + + update(&mut new_file_content); + + let old_object = to_json_object(old_file_content); + let new_object = to_json_object(new_file_content); + + fn apply_changes_to_json_text( + old_object: &serde_json::Map, + new_object: &serde_json::Map, + current_key_path: Vec<&str>, + json_text: &mut String, + ) { + for (key, old_value) in old_object.iter() { + // We know that these two are from the same shape of object, so we can just unwrap + let new_value = new_object.get(key).unwrap(); + if old_value != new_value { + match new_value { + Value::Bool(_) | Value::Number(_) | Value::String(_) => { + let mut key_path = current_key_path.clone(); + key_path.push(key); + write_settings_key(json_text, &key_path, &new_value); + } + Value::Object(new_sub_object) => { + let mut key_path = current_key_path.clone(); + key_path.push(key); + if let Value::Object(old_sub_object) = old_value { + apply_changes_to_json_text( + old_sub_object, + new_sub_object, + key_path, + json_text, + ); + } else { + unimplemented!("This function doesn't support changing values from simple values to objects yet"); + } + } + Value::Null | Value::Array(_) => { + unimplemented!("We only support objects and simple values"); + } + } } } } - settings_content + apply_changes_to_json_text(&old_object, &new_object, vec![], &mut text); + + text } -fn merge(target: &mut T, value: Option) { - if let Some(value) = value { - *target = value; +fn to_json_object(settings_file: SettingsFileContent) -> serde_json::Map { + let tmp = serde_json::to_value(settings_file).unwrap(); + match tmp { + Value::Object(map) => map, + _ => unreachable!("SettingsFileContent represents a JSON map"), } } -pub fn parse_json_with_comments(content: &str) -> Result { - Ok(serde_json::from_reader( - json_comments::CommentSettings::c_style().strip_comments(content.as_bytes()), - )?) -} - #[cfg(test)] mod tests { - use crate::write_top_level_setting; + use super::*; use unindent::Unindent; + fn assert_new_settings, S2: Into>( + old_json: S1, + update: fn(&mut SettingsFileContent), + expected_new_json: S2, + ) { + let old_json = old_json.into(); + let old_content: SettingsFileContent = serde_json::from_str(&old_json).unwrap_or_default(); + let new_json = update_settings_file(old_json, old_content, update); + assert_eq!(new_json, expected_new_json.into()); + } + + #[test] + fn test_update_telemetry_setting_multiple_fields() { + assert_new_settings( + r#" + { + "telemetry": { + "metrics": false, + "diagnostics": false + } + } + "# + .unindent(), + |settings| { + settings.telemetry.set_diagnostics(true); + settings.telemetry.set_metrics(true); + }, + r#" + { + "telemetry": { + "metrics": true, + "diagnostics": true + } + } + "# + .unindent(), + ); + } + + #[test] + fn test_update_telemetry_setting_weird_formatting() { + assert_new_settings( + r#"{ + "telemetry": { "metrics": false, "diagnostics": true } + }"# + .unindent(), + |settings| settings.telemetry.set_diagnostics(false), + r#"{ + "telemetry": { "metrics": false, "diagnostics": false } + }"# + .unindent(), + ); + } + + #[test] + fn test_update_telemetry_setting_other_fields() { + assert_new_settings( + r#" + { + "telemetry": { + "metrics": false, + "diagnostics": true + } + } + "# + .unindent(), + |settings| settings.telemetry.set_diagnostics(false), + r#" + { + "telemetry": { + "metrics": false, + "diagnostics": false + } + } + "# + .unindent(), + ); + } + + #[test] + fn test_update_telemetry_setting_empty_telemetry() { + assert_new_settings( + r#" + { + "telemetry": {} + } + "# + .unindent(), + |settings| settings.telemetry.set_diagnostics(false), + r#" + { + "telemetry": { + "diagnostics": false + } + } + "# + .unindent(), + ); + } + + #[test] + fn test_update_telemetry_setting_pre_existing() { + assert_new_settings( + r#" + { + "telemetry": { + "diagnostics": true + } + } + "# + .unindent(), + |settings| settings.telemetry.set_diagnostics(false), + r#" + { + "telemetry": { + "diagnostics": false + } + } + "# + .unindent(), + ); + } + + #[test] + fn test_update_telemetry_setting() { + assert_new_settings( + "{}", + |settings| settings.telemetry.set_diagnostics(true), + r#" + { + "telemetry": { + "diagnostics": true + } + } + "# + .unindent(), + ); + } + + #[test] + fn test_update_object_empty_doc() { + assert_new_settings( + "", + |settings| settings.telemetry.set_diagnostics(true), + r#" + { + "telemetry": { + "diagnostics": true + } + } + "# + .unindent(), + ); + } + #[test] fn test_write_theme_into_settings_with_theme() { - let settings = r#" - { - "theme": "One Dark" - } - "# - .unindent(); - - let new_settings = r#" - { - "theme": "summerfruit-light" - } - "# - .unindent(); - - let settings_after_theme = - write_top_level_setting(settings, "theme", "\"summerfruit-light\""); - - assert_eq!(settings_after_theme, new_settings) + assert_new_settings( + r#" + { + "theme": "One Dark" + } + "# + .unindent(), + |settings| settings.theme = Some("summerfruit-light".to_string()), + r#" + { + "theme": "summerfruit-light" + } + "# + .unindent(), + ); } #[test] fn test_write_theme_into_empty_settings() { - let settings = r#" - { - } - "# - .unindent(); - - let new_settings = r#" - { - "theme": "summerfruit-light" - } - "# - .unindent(); - - let settings_after_theme = - write_top_level_setting(settings, "theme", "\"summerfruit-light\""); - - assert_eq!(settings_after_theme, new_settings) + assert_new_settings( + r#" + { + } + "# + .unindent(), + |settings| settings.theme = Some("summerfruit-light".to_string()), + r#" + { + "theme": "summerfruit-light" + } + "# + .unindent(), + ); } #[test] - fn test_write_theme_into_no_settings() { - let settings = "".to_string(); - - let new_settings = r#" - { - "theme": "summerfruit-light" - } - "# - .unindent(); - - let settings_after_theme = - write_top_level_setting(settings, "theme", "\"summerfruit-light\""); - - assert_eq!(settings_after_theme, new_settings) + fn write_key_no_document() { + assert_new_settings( + "", + |settings| settings.theme = Some("summerfruit-light".to_string()), + r#" + { + "theme": "summerfruit-light" + } + "# + .unindent(), + ); } #[test] fn test_write_theme_into_single_line_settings_without_theme() { - let settings = r#"{ "a": "", "ok": true }"#.to_string(); - let new_settings = r#"{ "theme": "summerfruit-light", "a": "", "ok": true }"#; - - let settings_after_theme = - write_top_level_setting(settings, "theme", "\"summerfruit-light\""); - - assert_eq!(settings_after_theme, new_settings) + assert_new_settings( + r#"{ "a": "", "ok": true }"#, + |settings| settings.theme = Some("summerfruit-light".to_string()), + r#"{ "theme": "summerfruit-light", "a": "", "ok": true }"#, + ); } #[test] fn test_write_theme_pre_object_whitespace() { - let settings = r#" { "a": "", "ok": true }"#.to_string(); - let new_settings = r#" { "theme": "summerfruit-light", "a": "", "ok": true }"#; - - let settings_after_theme = - write_top_level_setting(settings, "theme", "\"summerfruit-light\""); - - assert_eq!(settings_after_theme, new_settings) + assert_new_settings( + r#" { "a": "", "ok": true }"#, + |settings| settings.theme = Some("summerfruit-light".to_string()), + r#" { "theme": "summerfruit-light", "a": "", "ok": true }"#.unindent(), + ); } #[test] fn test_write_theme_into_multi_line_settings_without_theme() { - let settings = r#" - { - "a": "b" - } - "# - .unindent(); - - let new_settings = r#" - { - "theme": "summerfruit-light", - "a": "b" - } - "# - .unindent(); - - let settings_after_theme = - write_top_level_setting(settings, "theme", "\"summerfruit-light\""); - - assert_eq!(settings_after_theme, new_settings) + assert_new_settings( + r#" + { + "a": "b" + } + "# + .unindent(), + |settings| settings.theme = Some("summerfruit-light".to_string()), + r#" + { + "theme": "summerfruit-light", + "a": "b" + } + "# + .unindent(), + ); } } diff --git a/crates/settings/src/settings_file.rs b/crates/settings/src/settings_file.rs index 506ebc8c3d..50638205c5 100644 --- a/crates/settings/src/settings_file.rs +++ b/crates/settings/src/settings_file.rs @@ -1,8 +1,7 @@ -use crate::{watched_json::WatchedJsonFile, write_top_level_setting, SettingsFileContent}; +use crate::{update_settings_file, watched_json::WatchedJsonFile, SettingsFileContent}; use anyhow::Result; use fs::Fs; use gpui::MutableAppContext; -use serde_json::Value; use std::{path::Path, sync::Arc}; // TODO: Switch SettingsFile to open a worktree and buffer for synchronization @@ -27,57 +26,24 @@ impl SettingsFile { } } - pub fn update(cx: &mut MutableAppContext, update: impl FnOnce(&mut SettingsFileContent)) { + pub fn update( + cx: &mut MutableAppContext, + update: impl 'static + Send + FnOnce(&mut SettingsFileContent), + ) { let this = cx.global::(); let current_file_content = this.settings_file_content.current(); - let mut new_file_content = current_file_content.clone(); - - update(&mut new_file_content); let fs = this.fs.clone(); let path = this.path.clone(); cx.background() .spawn(async move { - // Unwrap safety: These values are all guarnteed to be well formed, and we know - // that they will deserialize to our settings object. All of the following unwraps - // are therefore safe. - let tmp = serde_json::to_value(current_file_content).unwrap(); - let old_json = tmp.as_object().unwrap(); + let old_text = fs.load(path).await?; - let new_tmp = serde_json::to_value(new_file_content).unwrap(); - let new_json = new_tmp.as_object().unwrap(); + let new_text = update_settings_file(old_text, current_file_content, update); - // Find changed fields - let mut diffs = vec![]; - for (key, old_value) in old_json.iter() { - let new_value = new_json.get(key).unwrap(); - if old_value != new_value { - if matches!( - new_value, - &Value::Null | &Value::Object(_) | &Value::Array(_) - ) { - unimplemented!( - "We only support updating basic values at the top level" - ); - } - - let new_json = serde_json::to_string_pretty(new_value) - .expect("Could not serialize new json field to string"); - - diffs.push((key, new_json)); - } - } - - // Have diffs, rewrite the settings file now. - let mut content = fs.load(path).await?; - - for (key, new_value) in diffs { - content = write_top_level_setting(content, key, &new_value) - } - - fs.atomic_write(path.to_path_buf(), content).await?; + fs.atomic_write(path.to_path_buf(), new_text).await?; Ok(()) as Result<()> }) @@ -88,10 +54,164 @@ impl SettingsFile { #[cfg(test)] mod tests { use super::*; - use crate::{watched_json::watch_settings_file, EditorSettings, Settings, SoftWrap}; + use crate::{ + watch_files, watched_json::watch_settings_file, EditorSettings, Settings, SoftWrap, + }; use fs::FakeFs; + use gpui::{actions, Action}; use theme::ThemeRegistry; + #[gpui::test] + async fn test_base_keymap(cx: &mut gpui::TestAppContext) { + let executor = cx.background(); + let fs = FakeFs::new(executor.clone()); + let font_cache = cx.font_cache(); + + actions!(test, [A, B]); + // From the Atom keymap + actions!(workspace, [ActivatePreviousPane]); + // From the JetBrains keymap + actions!(pane, [ActivatePrevItem]); + + fs.save( + "/settings.json".as_ref(), + &r#" + { + "base_keymap": "Atom" + } + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + fs.save( + "/keymap.json".as_ref(), + &r#" + [ + { + "bindings": { + "backspace": "test::A" + } + } + ] + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + let settings_file = + WatchedJsonFile::new(fs.clone(), &executor, "/settings.json".as_ref()).await; + let keymaps_file = + WatchedJsonFile::new(fs.clone(), &executor, "/keymap.json".as_ref()).await; + + let default_settings = cx.read(Settings::test); + + cx.update(|cx| { + cx.add_global_action(|_: &A, _cx| {}); + cx.add_global_action(|_: &B, _cx| {}); + cx.add_global_action(|_: &ActivatePreviousPane, _cx| {}); + cx.add_global_action(|_: &ActivatePrevItem, _cx| {}); + watch_files( + default_settings, + settings_file, + ThemeRegistry::new((), font_cache), + keymaps_file, + cx, + ) + }); + + cx.foreground().run_until_parked(); + + // Test loading the keymap base at all + cx.update(|cx| { + assert_keybindings_for( + cx, + vec![("backspace", &A), ("k", &ActivatePreviousPane)], + line!(), + ); + }); + + // Test modifying the users keymap, while retaining the base keymap + fs.save( + "/keymap.json".as_ref(), + &r#" + [ + { + "bindings": { + "backspace": "test::B" + } + } + ] + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.foreground().run_until_parked(); + + cx.update(|cx| { + assert_keybindings_for( + cx, + vec![("backspace", &B), ("k", &ActivatePreviousPane)], + line!(), + ); + }); + + // Test modifying the base, while retaining the users keymap + fs.save( + "/settings.json".as_ref(), + &r#" + { + "base_keymap": "JetBrains" + } + "# + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.foreground().run_until_parked(); + + cx.update(|cx| { + assert_keybindings_for( + cx, + vec![("backspace", &B), ("[", &ActivatePrevItem)], + line!(), + ); + }); + } + + fn assert_keybindings_for<'a>( + cx: &mut MutableAppContext, + actions: Vec<(&'static str, &'a dyn Action)>, + line: u32, + ) { + for (key, action) in actions { + // assert that... + assert!( + cx.available_actions(0, 0).any(|(_, bound_action, b)| { + // action names match... + bound_action.name() == action.name() + && bound_action.namespace() == action.namespace() + // and key strokes contain the given key + && b.iter() + .any(|binding| binding.keystrokes().iter().any(|k| k.key == key)) + }), + "On {} Failed to find {} with keybinding {}", + line, + action.name(), + key + ); + } + } + #[gpui::test] async fn test_watch_settings_files(cx: &mut gpui::TestAppContext) { let executor = cx.background(); diff --git a/crates/settings/src/watched_json.rs b/crates/settings/src/watched_json.rs index e304842aa2..c4cc64cd62 100644 --- a/crates/settings/src/watched_json.rs +++ b/crates/settings/src/watched_json.rs @@ -62,7 +62,18 @@ where } } -pub fn watch_settings_file( +pub fn watch_files( + defaults: Settings, + settings_file: WatchedJsonFile, + theme_registry: Arc, + keymap_file: WatchedJsonFile, + cx: &mut MutableAppContext, +) { + watch_settings_file(defaults, settings_file, theme_registry, cx); + watch_keymap_file(keymap_file, cx); +} + +pub(crate) fn watch_settings_file( defaults: Settings, mut file: WatchedJsonFile, theme_registry: Arc, @@ -77,13 +88,13 @@ pub fn watch_settings_file( .detach(); } -pub fn keymap_updated(content: KeymapFileContent, cx: &mut MutableAppContext) { +fn keymap_updated(content: KeymapFileContent, cx: &mut MutableAppContext) { cx.clear_bindings(); KeymapFileContent::load_defaults(cx); content.add_to_cx(cx).log_err(); } -pub fn settings_updated( +fn settings_updated( defaults: &Settings, content: SettingsFileContent, theme_registry: &Arc, @@ -95,10 +106,20 @@ pub fn settings_updated( cx.refresh_windows(); } -pub fn watch_keymap_file(mut file: WatchedJsonFile, cx: &mut MutableAppContext) { +fn watch_keymap_file(mut file: WatchedJsonFile, cx: &mut MutableAppContext) { cx.spawn(|mut cx| async move { + let mut settings_subscription = None; while let Some(content) = file.0.recv().await { - cx.update(|cx| keymap_updated(content, cx)); + cx.update(|cx| { + let old_base_keymap = cx.global::().base_keymap; + keymap_updated(content.clone(), cx); + settings_subscription = Some(cx.observe_global::(move |cx| { + let settings = cx.global::(); + if settings.base_keymap != old_base_keymap { + keymap_updated(content.clone(), cx); + } + })); + }); } }) .detach(); diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 08ed3ecc2d..5d03d6304e 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -720,7 +720,7 @@ impl Element for TerminalElement { cx.paint_layer(clip_bounds, |cx| { let origin = bounds.origin() + vec2f(layout.size.cell_width, 0.); - //Elements are ephemeral, only at paint time do we know what could be clicked by a mouse + // Elements are ephemeral, only at paint time do we know what could be clicked by a mouse self.attach_mouse_handlers(origin, self.view.id(), visible_bounds, layout.mode, cx); cx.scene.push_cursor_region(gpui::CursorRegion { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 3821185ec0..110815e870 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -970,15 +970,7 @@ mod tests { let params = cx.update(AppState::test); let project = Project::test(params.fs.clone(), [], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); (project, workspace) } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 484c542ede..8ee58a8af2 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -9,6 +9,9 @@ use gpui::{ use serde::{de::DeserializeOwned, Deserialize}; use serde_json::Value; use std::{collections::HashMap, sync::Arc}; +use ui::{CheckboxStyle, IconStyle}; + +pub mod ui; pub use theme_registry::*; @@ -37,6 +40,7 @@ pub struct Theme { pub tooltip: TooltipStyle, pub terminal: TerminalStyle, pub feedback: FeedbackStyle, + pub welcome: WelcomeStyle, pub color_scheme: ColorScheme, } @@ -49,6 +53,7 @@ pub struct ThemeMeta { #[derive(Deserialize, Default)] pub struct Workspace { pub background: Color, + pub blank_pane: BlankPaneStyle, pub titlebar: Titlebar, pub tab_bar: TabBar, pub pane_divider: Border, @@ -68,6 +73,16 @@ pub struct Workspace { pub drop_target_overlay_color: Color, } +#[derive(Clone, Deserialize, Default)] +pub struct BlankPaneStyle { + pub logo: IconStyle, + pub logo_shadow: IconStyle, + pub logo_container: ContainerStyle, + pub keyboard_hints: ContainerStyle, + pub keyboard_hint: Interactive, + pub keyboard_hint_width: f32, +} + #[derive(Clone, Deserialize, Default)] pub struct Titlebar { #[serde(flatten)] @@ -345,6 +360,7 @@ pub struct ProjectPanel { pub cut_entry: Interactive, pub filename_editor: FieldEditor, pub indent_width: f32, + pub open_project_button: Interactive, } #[derive(Clone, Debug, Deserialize, Default)] @@ -850,13 +866,25 @@ pub struct FeedbackStyle { pub link_text_hover: ContainedText, } +#[derive(Clone, Deserialize, Default)] +pub struct WelcomeStyle { + pub page_width: f32, + pub logo: IconStyle, + pub logo_subheading: ContainedText, + pub usage_note: ContainedText, + pub checkbox: CheckboxStyle, + pub checkbox_container: ContainerStyle, + pub button: Interactive, + pub button_group: ContainerStyle, + pub heading_group: ContainerStyle, + pub checkbox_group: ContainerStyle, +} + #[derive(Clone, Deserialize, Default)] pub struct ColorScheme { pub name: String, pub is_light: bool, - pub ramps: RampSet, - pub lowest: Layer, pub middle: Layer, pub highest: Layer, diff --git a/crates/theme/src/ui.rs b/crates/theme/src/ui.rs new file mode 100644 index 0000000000..5441e71168 --- /dev/null +++ b/crates/theme/src/ui.rs @@ -0,0 +1,149 @@ +use gpui::{ + color::Color, + elements::{ + ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label, + MouseEventHandler, ParentElement, Svg, + }, + Action, Element, ElementBox, EventContext, RenderContext, View, +}; +use serde::Deserialize; + +use crate::ContainedText; + +#[derive(Clone, Deserialize, Default)] +pub struct CheckboxStyle { + pub icon: IconStyle, + pub label: ContainedText, + pub default: ContainerStyle, + pub checked: ContainerStyle, + pub hovered: ContainerStyle, + pub hovered_and_checked: ContainerStyle, +} + +pub fn checkbox( + label: &'static str, + style: &CheckboxStyle, + checked: bool, + cx: &mut RenderContext, + change: fn(checked: bool, cx: &mut EventContext) -> (), +) -> MouseEventHandler { + let label = Label::new(label, style.label.text.clone()) + .contained() + .with_style(style.label.container) + .boxed(); + + checkbox_with_label(label, style, checked, cx, change) +} + +pub fn checkbox_with_label( + label: ElementBox, + style: &CheckboxStyle, + checked: bool, + cx: &mut RenderContext, + change: fn(checked: bool, cx: &mut EventContext) -> (), +) -> MouseEventHandler { + MouseEventHandler::::new(0, cx, |state, _| { + let indicator = if checked { + icon(&style.icon) + } else { + Empty::new() + .constrained() + .with_width(style.icon.dimensions.width) + .with_height(style.icon.dimensions.height) + }; + + Flex::row() + .with_children([ + indicator + .contained() + .with_style(if checked { + if state.hovered() { + style.hovered_and_checked + } else { + style.checked + } + } else { + if state.hovered() { + style.hovered + } else { + style.default + } + }) + .boxed(), + label, + ]) + .align_children_center() + .boxed() + }) + .on_click(gpui::MouseButton::Left, move |_, cx| change(!checked, cx)) + .with_cursor_style(gpui::CursorStyle::PointingHand) +} + +#[derive(Clone, Deserialize, Default)] +pub struct IconStyle { + pub color: Color, + pub icon: String, + pub dimensions: Dimensions, +} + +#[derive(Clone, Deserialize, Default)] +pub struct Dimensions { + pub width: f32, + pub height: f32, +} + +pub fn icon(style: &IconStyle) -> ConstrainedBox { + Svg::new(style.icon.clone()) + .with_color(style.color) + .constrained() + .with_width(style.dimensions.width) + .with_height(style.dimensions.height) +} + +pub fn keystroke_label( + label_text: &'static str, + label_style: &ContainedText, + keystroke_style: &ContainedText, + action: Box, + cx: &mut RenderContext, +) -> Container { + // FIXME: Put the theme in it's own global so we can + // query the keystroke style on our own + keystroke_label_for( + cx.window_id(), + cx.handle().id(), + label_text, + label_style, + keystroke_style, + action, + ) +} + +pub fn keystroke_label_for( + window_id: usize, + view_id: usize, + label_text: &'static str, + label_style: &ContainedText, + keystroke_style: &ContainedText, + action: Box, +) -> Container { + Flex::row() + .with_child( + Label::new(label_text, label_style.text.clone()) + .contained() + .boxed(), + ) + .with_child({ + KeystrokeLabel::new( + window_id, + view_id, + action, + keystroke_style.container, + keystroke_style.text.clone(), + ) + .flex_float() + .boxed() + }) + .contained() + .with_style(label_style.container) +} diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index d999730a0d..ae3278b711 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -47,12 +47,7 @@ impl ThemeSelector { let mut theme_names = registry .list(**cx.default_global::()) .collect::>(); - theme_names.sort_unstable_by(|a, b| { - a.is_light - .cmp(&b.is_light) - .reverse() - .then(a.name.cmp(&b.name)) - }); + theme_names.sort_unstable_by(|a, b| a.is_light.cmp(&b.is_light).then(a.name.cmp(&b.name))); let matches = theme_names .iter() .map(|meta| StringMatch { diff --git a/crates/welcome/Cargo.toml b/crates/welcome/Cargo.toml new file mode 100644 index 0000000000..3da90deb2d --- /dev/null +++ b/crates/welcome/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "welcome" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/welcome.rs" + +[features] +test-support = [] + +[dependencies] +anyhow = "1.0.38" +log = "0.4" +editor = { path = "../editor" } +fuzzy = { path = "../fuzzy" } +gpui = { path = "../gpui" } +db = { path = "../db" } +install_cli = { path = "../install_cli" } +project = { path = "../project" } +settings = { path = "../settings" } +theme = { path = "../theme" } +theme_selector = { path = "../theme_selector" } +util = { path = "../util" } +picker = { path = "../picker" } +workspace = { path = "../workspace" } \ No newline at end of file diff --git a/crates/welcome/src/base_keymap_picker.rs b/crates/welcome/src/base_keymap_picker.rs new file mode 100644 index 0000000000..a37bcb1837 --- /dev/null +++ b/crates/welcome/src/base_keymap_picker.rs @@ -0,0 +1,175 @@ +use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; +use gpui::{ + actions, + elements::{ChildView, Element as _, Label}, + AnyViewHandle, Entity, MutableAppContext, View, ViewContext, ViewHandle, +}; +use picker::{Picker, PickerDelegate}; +use settings::{settings_file::SettingsFile, BaseKeymap, Settings}; +use workspace::Workspace; + +pub struct BaseKeymapSelector { + matches: Vec, + picker: ViewHandle>, + selected_index: usize, +} + +actions!(welcome, [ToggleBaseKeymapSelector]); + +pub fn init(cx: &mut MutableAppContext) { + Picker::::init(cx); + cx.add_action({ + move |workspace, _: &ToggleBaseKeymapSelector, cx| BaseKeymapSelector::toggle(workspace, cx) + }); +} + +pub enum Event { + Dismissed, +} + +impl BaseKeymapSelector { + fn toggle(workspace: &mut Workspace, cx: &mut ViewContext) { + workspace.toggle_modal(cx, |_, cx| { + let this = cx.add_view(|cx| Self::new(cx)); + cx.subscribe(&this, Self::on_event).detach(); + this + }); + } + + fn new(cx: &mut ViewContext) -> Self { + let base = cx.global::().base_keymap; + let selected_index = BaseKeymap::OPTIONS + .iter() + .position(|(_, value)| *value == base) + .unwrap_or(0); + + let this = cx.weak_handle(); + Self { + picker: cx.add_view(|cx| Picker::new("Select a base keymap", this, cx)), + matches: Vec::new(), + selected_index, + } + } + + fn on_event( + workspace: &mut Workspace, + _: ViewHandle, + event: &Event, + cx: &mut ViewContext, + ) { + match event { + Event::Dismissed => { + workspace.dismiss_modal(cx); + } + } + } +} + +impl Entity for BaseKeymapSelector { + type Event = Event; +} + +impl View for BaseKeymapSelector { + fn ui_name() -> &'static str { + "BaseKeymapSelector" + } + + fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox { + ChildView::new(self.picker.clone(), cx).boxed() + } + + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + if cx.is_self_focused() { + cx.focus(&self.picker); + } + } +} + +impl PickerDelegate for BaseKeymapSelector { + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext) { + self.selected_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext) -> gpui::Task<()> { + let background = cx.background().clone(); + let candidates = BaseKeymap::names() + .enumerate() + .map(|(id, name)| StringMatchCandidate { + id, + char_bag: name.into(), + string: name.into(), + }) + .collect::>(); + + cx.spawn(|this, mut cx| async move { + let matches = if query.is_empty() { + candidates + .into_iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string, + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + match_strings( + &candidates, + &query, + false, + 100, + &Default::default(), + background, + ) + .await + }; + + this.update(&mut cx, |this, cx| { + this.matches = matches; + this.selected_index = this + .selected_index + .min(this.matches.len().saturating_sub(1)); + cx.notify(); + }); + }) + } + + fn confirm(&mut self, cx: &mut ViewContext) { + if let Some(selection) = self.matches.get(self.selected_index) { + let base_keymap = BaseKeymap::from_names(&selection.string); + SettingsFile::update(cx, move |settings| settings.base_keymap = Some(base_keymap)); + } + cx.emit(Event::Dismissed); + } + + fn dismiss(&mut self, cx: &mut ViewContext) { + cx.emit(Event::Dismissed) + } + + fn render_match( + &self, + ix: usize, + mouse_state: &mut gpui::MouseState, + selected: bool, + cx: &gpui::AppContext, + ) -> gpui::ElementBox { + let theme = &cx.global::().theme; + let keymap_match = &self.matches[ix]; + let style = theme.picker.item.style_for(mouse_state, selected); + + Label::new(keymap_match.string.clone(), style.label.clone()) + .with_highlights(keymap_match.positions.clone()) + .contained() + .with_style(style.container) + .boxed() + } +} diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs new file mode 100644 index 0000000000..3a35920b88 --- /dev/null +++ b/crates/welcome/src/welcome.rs @@ -0,0 +1,316 @@ +mod base_keymap_picker; + +use std::{borrow::Cow, sync::Arc}; + +use db::kvp::KEY_VALUE_STORE; +use gpui::{ + elements::{Flex, Label, MouseEventHandler, ParentElement}, + Action, Element, ElementBox, Entity, MouseButton, MutableAppContext, RenderContext, + Subscription, View, ViewContext, +}; +use settings::{settings_file::SettingsFile, Settings}; + +use workspace::{ + item::Item, open_new, sidebar::SidebarSide, AppState, PaneBackdrop, Welcome, Workspace, + WorkspaceId, +}; + +use crate::base_keymap_picker::ToggleBaseKeymapSelector; + +pub const FIRST_OPEN: &str = "first_open"; + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(|workspace: &mut Workspace, _: &Welcome, cx| { + let welcome_page = cx.add_view(WelcomePage::new); + workspace.add_item(Box::new(welcome_page), cx) + }); + + base_keymap_picker::init(cx); +} + +pub fn show_welcome_experience(app_state: &Arc, cx: &mut MutableAppContext) { + open_new(&app_state, cx, |workspace, cx| { + workspace.toggle_sidebar(SidebarSide::Left, cx); + let welcome_page = cx.add_view(|cx| WelcomePage::new(cx)); + workspace.add_item_to_center(Box::new(welcome_page.clone()), cx); + cx.focus(welcome_page); + cx.notify(); + }) + .detach(); + + db::write_and_log(cx, || { + KEY_VALUE_STORE.write_kvp(FIRST_OPEN.to_string(), "false".to_string()) + }); +} + +pub struct WelcomePage { + _settings_subscription: Subscription, +} + +impl Entity for WelcomePage { + type Event = (); +} + +impl View for WelcomePage { + fn ui_name() -> &'static str { + "WelcomePage" + } + + fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { + let self_handle = cx.handle(); + let settings = cx.global::(); + let theme = settings.theme.clone(); + + let width = theme.welcome.page_width; + + let (diagnostics, metrics) = { + let telemetry = settings.telemetry(); + (telemetry.diagnostics(), telemetry.metrics()) + }; + + enum Metrics {} + enum Diagnostics {} + + PaneBackdrop::new( + self_handle.id(), + Flex::column() + .with_children([ + Flex::column() + .with_children([ + theme::ui::icon(&theme.welcome.logo) + .aligned() + .contained() + .aligned() + .boxed(), + Label::new( + "Code at the speed of thought", + theme.welcome.logo_subheading.text.clone(), + ) + .aligned() + .contained() + .with_style(theme.welcome.logo_subheading.container) + .boxed(), + ]) + .contained() + .with_style(theme.welcome.heading_group) + .constrained() + .with_width(width) + .boxed(), + Flex::column() + .with_children([ + self.render_cta_button( + "Choose a theme", + theme_selector::Toggle, + width, + cx, + ), + self.render_cta_button( + "Choose a keymap", + ToggleBaseKeymapSelector, + width, + cx, + ), + self.render_cta_button( + "Install the CLI", + install_cli::Install, + width, + cx, + ), + ]) + .contained() + .with_style(theme.welcome.button_group) + .constrained() + .with_width(width) + .boxed(), + Flex::column() + .with_children([ + theme::ui::checkbox_with_label::( + Flex::column() + .with_children([ + Label::new( + "Send anonymous usage data", + theme.welcome.checkbox.label.text.clone(), + ) + .contained() + .with_style(theme.welcome.checkbox.label.container) + .boxed(), + Label::new( + "Help > View Telemetry", + theme.welcome.usage_note.text.clone(), + ) + .contained() + .with_style(theme.welcome.usage_note.container) + .boxed(), + ]) + .boxed(), + &theme.welcome.checkbox, + metrics, + cx, + |checked, cx| { + SettingsFile::update(cx, move |file| { + file.telemetry.set_metrics(checked) + }) + }, + ) + .contained() + .with_style(theme.welcome.checkbox_container) + .boxed(), + theme::ui::checkbox::( + "Send crash reports", + &theme.welcome.checkbox, + diagnostics, + cx, + |checked, cx| { + SettingsFile::update(cx, move |file| { + file.telemetry.set_diagnostics(checked) + }) + }, + ) + .contained() + .with_style(theme.welcome.checkbox_container) + .boxed(), + ]) + .contained() + .with_style(theme.welcome.checkbox_group) + .constrained() + .with_width(width) + .boxed(), + ]) + .constrained() + .with_max_width(width) + .contained() + .with_uniform_padding(10.) + .aligned() + .boxed(), + ) + .boxed() + } +} + +impl WelcomePage { + pub fn new(cx: &mut ViewContext) -> Self { + let handle = cx.weak_handle(); + + let settings_subscription = cx.observe_global::(move |cx| { + if let Some(handle) = handle.upgrade(cx) { + handle.update(cx, |_, cx| cx.notify()) + } + }); + + WelcomePage { + _settings_subscription: settings_subscription, + } + } + + fn render_cta_button( + &self, + label: L, + action: A, + width: f32, + cx: &mut RenderContext, + ) -> ElementBox + where + L: Into>, + A: 'static + Action + Clone, + { + let theme = cx.global::().theme.clone(); + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme.welcome.button.style_for(state, false); + Label::new(label, style.text.clone()) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_max_width(width) + .boxed() + }) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(action.clone()) + }) + .with_cursor_style(gpui::CursorStyle::PointingHand) + .boxed() + } + + // fn render_settings_checkbox( + // &self, + // label: &'static str, + // style: &CheckboxStyle, + // checked: bool, + // cx: &mut RenderContext, + // set_value: fn(&mut SettingsFileContent, checked: bool) -> (), + // ) -> ElementBox { + // MouseEventHandler::::new(0, cx, |state, _| { + // let indicator = if checked { + // Svg::new(style.check_icon.clone()) + // .with_color(style.check_icon_color) + // .constrained() + // } else { + // Empty::new().constrained() + // }; + + // Flex::row() + // .with_children([ + // indicator + // .with_width(style.width) + // .with_height(style.height) + // .contained() + // .with_style(if checked { + // if state.hovered() { + // style.hovered_and_checked + // } else { + // style.checked + // } + // } else { + // if state.hovered() { + // style.hovered + // } else { + // style.default + // } + // }) + // .boxed(), + // Label::new(label, style.label.text.clone()) + // .contained() + // .with_style(style.label.container) + // .boxed(), + // ]) + // .align_children_center() + // .boxed() + // }) + // .on_click(gpui::MouseButton::Left, move |_, cx| { + // SettingsFile::update(cx, move |content| set_value(content, !checked)) + // }) + // .with_cursor_style(gpui::CursorStyle::PointingHand) + // .contained() + // .with_style(style.container) + // .boxed() + // } +} + +impl Item for WelcomePage { + fn tab_content( + &self, + _detail: Option, + style: &theme::Tab, + _cx: &gpui::AppContext, + ) -> gpui::ElementBox { + Flex::row() + .with_child( + Label::new("Welcome to Zed!", style.label.clone()) + .aligned() + .contained() + .boxed(), + ) + .boxed() + } + + fn show_toolbar(&self) -> bool { + false + } + fn clone_on_split( + &self, + _workspace_id: WorkspaceId, + cx: &mut ViewContext, + ) -> Option { + Some(WelcomePage::new(cx)) + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index fc069fe6c8..2ba7a6cc40 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -27,6 +27,7 @@ context_menu = { path = "../context_menu" } drag_and_drop = { path = "../drag_and_drop" } fs = { path = "../fs" } gpui = { path = "../gpui" } +install_cli = { path = "../install_cli" } language = { path = "../language" } menu = { path = "../menu" } project = { path = "../project" } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 2bd8808281..f5ee8cad51 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -13,7 +13,7 @@ use gpui::{ use settings::{DockAnchor, Settings}; use theme::Theme; -use crate::{sidebar::SidebarSide, ItemHandle, Pane, Workspace}; +use crate::{sidebar::SidebarSide, BackgroundActions, ItemHandle, Pane, Workspace}; pub use toggle_dock_button::ToggleDockButton; #[derive(PartialEq, Clone, Deserialize)] @@ -39,20 +39,24 @@ impl_internal_actions!(dock, [MoveDock, AddDefaultItemToDock]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(Dock::focus_dock); cx.add_action(Dock::hide_dock); - cx.add_action(Dock::move_dock); + cx.add_action( + |workspace: &mut Workspace, &MoveDock(dock_anchor), cx: &mut ViewContext| { + Dock::move_dock(workspace, dock_anchor, true, cx); + }, + ); cx.add_action( |workspace: &mut Workspace, _: &AnchorDockRight, cx: &mut ViewContext| { - Dock::move_dock(workspace, &MoveDock(DockAnchor::Right), cx) + Dock::move_dock(workspace, DockAnchor::Right, true, cx); }, ); cx.add_action( |workspace: &mut Workspace, _: &AnchorDockBottom, cx: &mut ViewContext| { - Dock::move_dock(workspace, &MoveDock(DockAnchor::Bottom), cx) + Dock::move_dock(workspace, DockAnchor::Bottom, true, cx) }, ); cx.add_action( |workspace: &mut Workspace, _: &ExpandDock, cx: &mut ViewContext| { - Dock::move_dock(workspace, &MoveDock(DockAnchor::Expanded), cx) + Dock::move_dock(workspace, DockAnchor::Expanded, true, cx) }, ); cx.add_action( @@ -177,12 +181,21 @@ pub struct Dock { impl Dock { pub fn new( + workspace_id: usize, default_item_factory: DockDefaultItemFactory, + background_actions: BackgroundActions, cx: &mut ViewContext, ) -> Self { let position = DockPosition::Hidden(cx.global::().default_dock_anchor); - let pane = cx.add_view(|cx| Pane::new(Some(position.anchor()), cx)); + let pane = cx.add_view(|cx| { + Pane::new( + workspace_id, + Some(position.anchor()), + background_actions, + cx, + ) + }); pane.update(cx, |pane, cx| { pane.set_active(false, cx); }); @@ -215,6 +228,7 @@ impl Dock { pub(crate) fn set_dock_position( workspace: &mut Workspace, new_position: DockPosition, + focus: bool, cx: &mut ViewContext, ) { workspace.dock.position = new_position; @@ -235,19 +249,23 @@ impl Dock { let pane = workspace.dock.pane.clone(); if pane.read(cx).items().next().is_none() { if let Some(item_to_add) = (workspace.dock.default_item_factory)(workspace, cx) { - Pane::add_item(workspace, &pane, item_to_add, true, true, None, cx); + Pane::add_item(workspace, &pane, item_to_add, focus, focus, None, cx); } else { workspace.dock.position = workspace.dock.position.hide(); } } else { - cx.focus(pane); + if focus { + cx.focus(pane); + } } } else if let Some(last_active_center_pane) = workspace .last_active_center_pane .as_ref() .and_then(|pane| pane.upgrade(cx)) { - cx.focus(last_active_center_pane); + if focus { + cx.focus(last_active_center_pane); + } } cx.emit(crate::Event::DockAnchorChanged); workspace.serialize_workspace(cx); @@ -255,11 +273,11 @@ impl Dock { } pub fn hide(workspace: &mut Workspace, cx: &mut ViewContext) { - Self::set_dock_position(workspace, workspace.dock.position.hide(), cx); + Self::set_dock_position(workspace, workspace.dock.position.hide(), true, cx); } - pub fn show(workspace: &mut Workspace, cx: &mut ViewContext) { - Self::set_dock_position(workspace, workspace.dock.position.show(), cx); + pub fn show(workspace: &mut Workspace, focus: bool, cx: &mut ViewContext) { + Self::set_dock_position(workspace, workspace.dock.position.show(), focus, cx); } pub fn hide_on_sidebar_shown( @@ -275,19 +293,20 @@ impl Dock { } fn focus_dock(workspace: &mut Workspace, _: &FocusDock, cx: &mut ViewContext) { - Self::set_dock_position(workspace, workspace.dock.position.show(), cx); + Self::set_dock_position(workspace, workspace.dock.position.show(), true, cx); } fn hide_dock(workspace: &mut Workspace, _: &HideDock, cx: &mut ViewContext) { - Self::set_dock_position(workspace, workspace.dock.position.hide(), cx); + Self::set_dock_position(workspace, workspace.dock.position.hide(), true, cx); } - fn move_dock( + pub fn move_dock( workspace: &mut Workspace, - &MoveDock(new_anchor): &MoveDock, + new_anchor: DockAnchor, + focus: bool, cx: &mut ViewContext, ) { - Self::set_dock_position(workspace, DockPosition::Shown(new_anchor), cx); + Self::set_dock_position(workspace, DockPosition::Shown(new_anchor), focus, cx); } pub fn render( @@ -482,6 +501,7 @@ mod tests { 0, project.clone(), default_item_factory, + || &[], cx, ) }); @@ -610,7 +630,14 @@ mod tests { cx.update(|cx| init(cx)); let project = Project::test(fs, [], cx).await; let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, default_item_factory, cx) + Workspace::new( + Default::default(), + 0, + project, + default_item_factory, + || &[], + cx, + ) }); workspace.update(cx, |workspace, cx| { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index a80b9f8d83..b55c9942f8 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -151,6 +151,9 @@ pub trait Item: View { "deserialize() must be implemented if serialized_item_kind() returns Some(_)" ) } + fn show_toolbar(&self) -> bool { + true + } } pub trait ItemHandle: 'static + fmt::Debug { @@ -213,6 +216,7 @@ pub trait ItemHandle: 'static + fmt::Debug { fn breadcrumb_location(&self, cx: &AppContext) -> ToolbarItemLocation; fn breadcrumbs(&self, theme: &Theme, cx: &AppContext) -> Option>; fn serialized_item_kind(&self) -> Option<&'static str>; + fn show_toolbar(&self, cx: &AppContext) -> bool; } pub trait WeakItemHandle { @@ -591,6 +595,10 @@ impl ItemHandle for ViewHandle { fn serialized_item_kind(&self) -> Option<&'static str> { T::serialized_item_kind() } + + fn show_toolbar(&self, cx: &AppContext) -> bool { + self.read(cx).show_toolbar() + } } impl From> for AnyViewHandle { diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 141a345382..76f46f83c5 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -122,6 +122,8 @@ impl Workspace { pub mod simple_message_notification { + use std::borrow::Cow; + use gpui::{ actions, elements::{Flex, MouseEventHandler, Padding, ParentElement, Svg, Text}, @@ -153,9 +155,9 @@ pub mod simple_message_notification { } pub struct MessageNotification { - message: String, + message: Cow<'static, str>, click_action: Option>, - click_message: Option, + click_message: Option>, } pub enum MessageNotificationEvent { @@ -167,23 +169,23 @@ pub mod simple_message_notification { } impl MessageNotification { - pub fn new_message>(message: S) -> MessageNotification { + pub fn new_message>>(message: S) -> MessageNotification { Self { - message: message.as_ref().to_string(), + message: message.into(), click_action: None, click_message: None, } } - pub fn new, A: Action, S2: AsRef>( + pub fn new>, A: Action, S2: Into>>( message: S1, click_action: A, click_message: S2, ) -> Self { Self { - message: message.as_ref().to_string(), + message: message.into(), click_action: Some(Box::new(click_action) as Box), - click_message: Some(click_message.as_ref().to_string()), + click_message: Some(click_message.into()), } } @@ -210,6 +212,8 @@ pub mod simple_message_notification { let click_message = self.click_message.as_ref().map(|message| message.clone()); let message = self.message.clone(); + let has_click_action = click_action.is_some(); + MouseEventHandler::::new(0, cx, |state, cx| { Flex::column() .with_child( @@ -243,6 +247,7 @@ pub mod simple_message_notification { .on_click(MouseButton::Left, move |_, cx| { cx.dispatch_action(CancelMessageNotification) }) + .with_cursor_style(CursorStyle::PointingHand) .aligned() .constrained() .with_height( @@ -272,12 +277,19 @@ pub mod simple_message_notification { .contained() .boxed() }) - .with_cursor_style(CursorStyle::PointingHand) + // Since we're not using a proper overlay, we have to capture these extra events + .on_down(MouseButton::Left, |_, _| {}) + .on_up(MouseButton::Left, |_, _| {}) .on_click(MouseButton::Left, move |_, cx| { if let Some(click_action) = click_action.as_ref() { cx.dispatch_any_action(click_action.boxed_clone()) } }) + .with_cursor_style(if has_click_action { + CursorStyle::PointingHand + } else { + CursorStyle::Arrow + }) .boxed() } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 98fcac664c..587f180a4a 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -24,8 +24,8 @@ use gpui::{ keymap_matcher::KeymapContext, platform::{CursorStyle, NavigationDirection}, Action, AnyViewHandle, AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, EventContext, - ModelHandle, MouseButton, MutableAppContext, PromptLevel, Quad, RenderContext, Task, View, - ViewContext, ViewHandle, WeakViewHandle, + ModelHandle, MouseButton, MouseRegion, MutableAppContext, PromptLevel, Quad, RenderContext, + Task, View, ViewContext, ViewHandle, WeakViewHandle, }; use project::{Project, ProjectEntryId, ProjectPath}; use serde::Deserialize; @@ -110,6 +110,8 @@ impl_internal_actions!( const MAX_NAVIGATION_HISTORY_LEN: usize = 1024; +pub type BackgroundActions = fn() -> &'static [(&'static str, &'static dyn Action)]; + pub fn init(cx: &mut MutableAppContext) { cx.add_action(|pane: &mut Pane, action: &ActivateItem, cx| { pane.activate_item(action.0, true, true, cx); @@ -215,6 +217,8 @@ pub struct Pane { toolbar: ViewHandle, tab_bar_context_menu: ViewHandle, docked: Option, + _background_actions: BackgroundActions, + _workspace_id: usize, } pub struct ItemNavHistory { @@ -271,7 +275,12 @@ enum ItemType { } impl Pane { - pub fn new(docked: Option, cx: &mut ViewContext) -> Self { + pub fn new( + workspace_id: usize, + docked: Option, + background_actions: BackgroundActions, + cx: &mut ViewContext, + ) -> Self { let handle = cx.weak_handle(); let context_menu = cx.add_view(ContextMenu::new); Self { @@ -292,6 +301,8 @@ impl Pane { toolbar: cx.add_view(|_| Toolbar::new(handle)), tab_bar_context_menu: context_menu, docked, + _background_actions: background_actions, + _workspace_id: workspace_id, } } @@ -1415,6 +1426,14 @@ impl Pane { .flex(1., false) .boxed() } + + fn render_blank_pane(&mut self, theme: &Theme, _cx: &mut RenderContext) -> ElementBox { + let background = theme.workspace.background; + Empty::new() + .contained() + .with_background_color(background) + .boxed() + } } impl Entity for Pane { @@ -1485,11 +1504,12 @@ impl View for Pane { cx, { let toolbar = self.toolbar.clone(); + let toolbar_hidden = toolbar.read(cx).hidden(); move |_, cx| { Flex::column() - .with_child( - ChildView::new(&toolbar, cx).expanded().boxed(), - ) + .with_children((!toolbar_hidden).then(|| { + ChildView::new(&toolbar, cx).expanded().boxed() + })) .with_child( ChildView::new(active_item, cx) .flex(1., true) @@ -1507,11 +1527,8 @@ impl View for Pane { enum EmptyPane {} let theme = cx.global::().theme.clone(); - dragged_item_receiver::(0, 0, false, None, cx, |_, _| { - Empty::new() - .contained() - .with_background_color(theme.workspace.background) - .boxed() + dragged_item_receiver::(0, 0, false, None, cx, |_, cx| { + self.render_blank_pane(&theme, cx) }) .on_down(MouseButton::Left, |_, cx| { cx.focus_parent_view(); @@ -1705,6 +1722,93 @@ impl NavHistory { } } +pub struct PaneBackdrop { + child_view: usize, + child: ElementBox, +} +impl PaneBackdrop { + pub fn new(pane_item_view: usize, child: ElementBox) -> Self { + PaneBackdrop { + child, + child_view: pane_item_view, + } + } +} + +impl Element for PaneBackdrop { + type LayoutState = (); + + type PaintState = (); + + fn layout( + &mut self, + constraint: gpui::SizeConstraint, + cx: &mut gpui::LayoutContext, + ) -> (Vector2F, Self::LayoutState) { + let size = self.child.layout(constraint, cx); + (size, ()) + } + + fn paint( + &mut self, + bounds: RectF, + visible_bounds: RectF, + _: &mut Self::LayoutState, + cx: &mut gpui::PaintContext, + ) -> Self::PaintState { + let background = cx.global::().theme.editor.background; + + let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); + + cx.scene.push_quad(gpui::Quad { + bounds: RectF::new(bounds.origin(), bounds.size()), + background: Some(background), + ..Default::default() + }); + + let child_view_id = self.child_view; + cx.scene.push_mouse_region( + MouseRegion::new::(child_view_id, 0, visible_bounds).on_down( + gpui::MouseButton::Left, + move |_, cx| { + let window_id = cx.window_id; + cx.focus(window_id, Some(child_view_id)) + }, + ), + ); + + cx.paint_layer(Some(bounds), |cx| { + self.child.paint(bounds.origin(), visible_bounds, cx) + }) + } + + fn rect_for_text_range( + &self, + range_utf16: std::ops::Range, + _bounds: RectF, + _visible_bounds: RectF, + _layout: &Self::LayoutState, + _paint: &Self::PaintState, + cx: &gpui::MeasurementContext, + ) -> Option { + self.child.rect_for_text_range(range_utf16, cx) + } + + fn debug( + &self, + _bounds: RectF, + _layout: &Self::LayoutState, + _paint: &Self::PaintState, + cx: &gpui::DebugContext, + ) -> serde_json::Value { + gpui::json::json!({ + "type": "Pane Back Drop", + "view": self.child_view, + "child": self.child.debug(cx), + }) + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -1721,9 +1825,7 @@ mod tests { let fs = FakeFs::new(cx.background()); let project = Project::test(fs, None, cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); // 1. Add with a destination index @@ -1811,9 +1913,7 @@ mod tests { let fs = FakeFs::new(cx.background()); let project = Project::test(fs, None, cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); // 1. Add with a destination index @@ -1889,9 +1989,7 @@ mod tests { let fs = FakeFs::new(cx.background()); let project = Project::test(fs, None, cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); // singleton view @@ -2000,8 +2098,7 @@ mod tests { let fs = FakeFs::new(cx.background()); let project = Project::test(fs, None, cx).await; - let (_, workspace) = - cx.add_window(|cx| Workspace::new(None, 0, project, |_, _| unimplemented!(), cx)); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); add_labled_item(&workspace, &pane, "A", cx); diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index 7443f19003..df10db91a0 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -42,6 +42,7 @@ pub enum ToolbarItemLocation { pub struct Toolbar { active_pane_item: Option>, + hidden: bool, pane: WeakViewHandle, items: Vec<(Box, ToolbarItemLocation)>, } @@ -211,6 +212,7 @@ impl Toolbar { active_pane_item: None, pane, items: Default::default(), + hidden: false, } } @@ -243,6 +245,12 @@ impl Toolbar { cx: &mut ViewContext, ) { self.active_pane_item = pane_item.map(|item| item.boxed_clone()); + self.hidden = self + .active_pane_item + .as_ref() + .map(|item| !item.show_toolbar(cx)) + .unwrap_or(false); + for (toolbar_item, current_location) in self.items.iter_mut() { let new_location = toolbar_item.set_active_pane_item(pane_item, cx); if new_location != *current_location { @@ -257,6 +265,10 @@ impl Toolbar { .iter() .find_map(|(item, _)| item.to_any().downcast()) } + + pub fn hidden(&self) -> bool { + self.hidden + } } impl ToolbarItemViewHandle for ViewHandle { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b9d80e7150..609afded33 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -17,7 +17,7 @@ mod toolbar; pub use smallvec; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use call::ActiveCall; use client::{ proto::{self, PeerId}, @@ -44,7 +44,8 @@ use gpui::{ platform::{CursorStyle, WindowOptions}, AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MouseButton, MutableAppContext, PathPromptOptions, Platform, PromptLevel, RenderContext, - SizeConstraint, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowBounds, + SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, + WindowBounds, }; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem}; use language::LanguageRegistry; @@ -65,7 +66,7 @@ use crate::{ }; use lazy_static::lazy_static; use log::{error, warn}; -use notifications::NotificationHandle; +use notifications::{NotificationHandle, NotifyResultExt}; pub use pane::*; pub use pane_group::*; use persistence::{model::SerializedItem, DB}; @@ -118,7 +119,8 @@ actions!( NewTerminal, NewSearch, Feedback, - Restart + Restart, + Welcome ] ); @@ -187,21 +189,66 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { dock::init(cx); notifications::init(cx); - cx.add_global_action(open); + cx.add_global_action(|_: &Open, cx: &mut MutableAppContext| { + let mut paths = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: true, + multiple: true, + }); + + cx.spawn(|mut cx| async move { + if let Some(paths) = paths.recv().await.flatten() { + cx.update(|cx| cx.dispatch_global_action(OpenPaths { paths })); + } + }) + .detach(); + }); + cx.add_action(|_, _: &Open, cx: &mut ViewContext| { + let mut paths = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: true, + multiple: true, + }); + + let handle = cx.handle().downgrade(); + cx.spawn(|_, mut cx| async move { + if let Some(paths) = paths.recv().await.flatten() { + cx.update(|cx| { + cx.dispatch_action_at(handle.window_id(), handle.id(), OpenPaths { paths }) + }) + } + }) + .detach(); + }); cx.add_global_action({ let app_state = Arc::downgrade(&app_state); move |action: &OpenPaths, cx: &mut MutableAppContext| { if let Some(app_state) = app_state.upgrade() { - open_paths(&action.paths, &app_state, cx).detach(); + open_paths(&action.paths, &app_state, None, cx).detach(); } } }); - cx.add_global_action({ + cx.add_async_action({ let app_state = Arc::downgrade(&app_state); - move |_: &NewFile, cx: &mut MutableAppContext| { - if let Some(app_state) = app_state.upgrade() { - open_new(&app_state, cx).detach(); + move |workspace, action: &OpenPaths, cx: &mut ViewContext| { + if !workspace.project().read(cx).is_local() { + cx.propagate_action(); + return None; } + + let app_state = app_state.upgrade()?; + let window_id = cx.window_id(); + let action = action.clone(); + let close = workspace.prepare_to_close(false, cx); + + Some(cx.spawn_weak(|_, mut cx| async move { + let can_close = close.await?; + if can_close { + cx.update(|cx| open_paths(&action.paths, &app_state, Some(window_id), cx)) + .await; + } + Ok(()) + })) } }); @@ -209,7 +256,7 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { let app_state = Arc::downgrade(&app_state); move |_: &NewWindow, cx: &mut MutableAppContext| { if let Some(app_state) = app_state.upgrade() { - open_new(&app_state, cx).detach(); + open_new(&app_state, cx, |_, cx| cx.dispatch_action(NewFile)).detach(); } } }); @@ -275,6 +322,31 @@ pub fn init(app_state: Arc, cx: &mut MutableAppContext) { }, ); + cx.add_action(|_: &mut Workspace, _: &install_cli::Install, cx| { + cx.spawn(|workspace, mut cx| async move { + let err = install_cli::install_cli(&cx) + .await + .context("Failed to create CLI symlink"); + + cx.update(|cx| { + workspace.update(cx, |workspace, cx| { + if matches!(err, Err(_)) { + err.notify_err(workspace, cx); + } else { + workspace.show_notification(1, cx, |cx| { + cx.add_view(|_| { + MessageNotification::new_message( + "Successfully installed the `zed` binary", + ) + }) + }); + } + }) + }) + }) + .detach(); + }); + let client = &app_state.client; client.add_view_request_handler(Workspace::handle_follow); client.add_view_message_handler(Workspace::handle_unfollow); @@ -360,6 +432,7 @@ pub struct AppState { fn(Option, Option, &dyn Platform) -> WindowOptions<'static>, pub initialize_workspace: fn(&mut Workspace, &Arc, &mut ViewContext), pub dock_default_item_factory: DockDefaultItemFactory, + pub background_actions: BackgroundActions, } impl AppState { @@ -382,7 +455,8 @@ impl AppState { user_store, initialize_workspace: |_, _, _| {}, build_window_options: |_, _, _| Default::default(), - dock_default_item_factory: |_, _| unimplemented!(), + dock_default_item_factory: |_, _| None, + background_actions: || &[], }) } } @@ -470,6 +544,8 @@ pub struct Workspace { active_call: Option<(ModelHandle, Vec)>, leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: WorkspaceId, + background_actions: BackgroundActions, + _window_subscriptions: [Subscription; 3], _apply_leader_updates: Task>, _observe_current_user: Task<()>, } @@ -499,12 +575,9 @@ impl Workspace { workspace_id: WorkspaceId, project: ModelHandle, dock_default_factory: DockDefaultItemFactory, + background_actions: BackgroundActions, cx: &mut ViewContext, ) -> Self { - cx.observe_fullscreen(|_, _, cx| cx.notify()).detach(); - - cx.observe_window_activation(Self::on_window_activation_changed) - .detach(); cx.observe(&project, |_, _, cx| cx.notify()).detach(); cx.subscribe(&project, move |this, _, event, cx| { match event { @@ -533,7 +606,10 @@ impl Workspace { }) .detach(); - let center_pane = cx.add_view(|cx| Pane::new(None, cx)); + let weak_handle = cx.weak_handle(); + + let center_pane = + cx.add_view(|cx| Pane::new(weak_handle.id(), None, background_actions, cx)); let pane_id = center_pane.id(); cx.subscribe(¢er_pane, move |this, _, event, cx| { this.handle_pane_event(pane_id, event, cx) @@ -541,7 +617,12 @@ impl Workspace { .detach(); cx.focus(¢er_pane); cx.emit(Event::PaneAdded(center_pane.clone())); - let dock = Dock::new(dock_default_factory, cx); + let dock = Dock::new( + weak_handle.id(), + dock_default_factory, + background_actions, + cx, + ); let dock_pane = dock.pane().clone(); let fs = project.read(cx).fs().clone(); @@ -564,7 +645,6 @@ impl Workspace { } }); let handle = cx.handle(); - let weak_handle = cx.weak_handle(); // All leader updates are enqueued and then processed in a single task, so // that each asynchronous operation can be run in order. @@ -611,6 +691,28 @@ impl Workspace { active_call = Some((call, subscriptions)); } + let subscriptions = [ + cx.observe_fullscreen(|_, _, cx| cx.notify()), + cx.observe_window_activation(Self::on_window_activation_changed), + cx.observe_window_bounds(move |_, mut bounds, display, cx| { + // Transform fixed bounds to be stored in terms of the containing display + if let WindowBounds::Fixed(mut window_bounds) = bounds { + if let Some(screen) = cx.platform().screen_by_id(display) { + let screen_bounds = screen.bounds(); + window_bounds + .set_origin_x(window_bounds.origin_x() - screen_bounds.origin_x()); + window_bounds + .set_origin_y(window_bounds.origin_y() - screen_bounds.origin_y()); + bounds = WindowBounds::Fixed(window_bounds); + } + } + + cx.background() + .spawn(DB.set_window_bounds(workspace_id, bounds, display)) + .detach_and_log_err(cx); + }), + ]; + let mut this = Workspace { modal: None, weak_self: weak_handle.clone(), @@ -639,9 +741,11 @@ impl Workspace { window_edited: false, active_call, database_id: workspace_id, + background_actions, _observe_current_user, _apply_leader_updates, leader_updates_tx, + _window_subscriptions: subscriptions, }; this.project_remote_id_changed(project.read(cx).remote_id(), cx); cx.defer(|this, cx| this.update_window_title(cx)); @@ -650,6 +754,10 @@ impl Workspace { cx.defer(move |_, cx| { Self::load_from_serialized_workspace(weak_handle, serialized_workspace, cx) }); + } else { + if cx.global::().default_dock_anchor != DockAnchor::Expanded { + Dock::show(&mut this, false, cx); + } } this @@ -658,6 +766,7 @@ impl Workspace { fn new_local( abs_paths: Vec, app_state: Arc, + requesting_window_id: Option, cx: &mut MutableAppContext, ) -> Task<( ViewHandle, @@ -713,73 +822,65 @@ impl Workspace { )) }); - let (bounds, display) = if let Some(bounds) = window_bounds_override { - (Some(bounds), None) - } else { - serialized_workspace - .as_ref() - .and_then(|serialized_workspace| { - let display = serialized_workspace.display?; - let mut bounds = serialized_workspace.bounds?; - - // Stored bounds are relative to the containing display. - // So convert back to global coordinates if that screen still exists - if let WindowBounds::Fixed(mut window_bounds) = bounds { - if let Some(screen) = cx.platform().screen_by_id(display) { - let screen_bounds = screen.bounds(); - window_bounds.set_origin_x( - window_bounds.origin_x() + screen_bounds.origin_x(), - ); - window_bounds.set_origin_y( - window_bounds.origin_y() + screen_bounds.origin_y(), - ); - bounds = WindowBounds::Fixed(window_bounds); - } else { - // Screen no longer exists. Return none here. - return None; - } - } - - Some((bounds, display)) - }) - .unzip() - }; - - // Use the serialized workspace to construct the new window - let (_, workspace) = cx.add_window( - (app_state.build_window_options)(bounds, display, cx.platform().as_ref()), - |cx| { + let build_workspace = + |cx: &mut ViewContext, + serialized_workspace: Option| { let mut workspace = Workspace::new( serialized_workspace, workspace_id, project_handle, app_state.dock_default_item_factory, + app_state.background_actions, cx, ); (app_state.initialize_workspace)(&mut workspace, &app_state, cx); - cx.observe_window_bounds(move |_, mut bounds, display, cx| { - // Transform fixed bounds to be stored in terms of the containing display - if let WindowBounds::Fixed(mut window_bounds) = bounds { - if let Some(screen) = cx.platform().screen_by_id(display) { - let screen_bounds = screen.bounds(); - window_bounds.set_origin_x( - window_bounds.origin_x() - screen_bounds.origin_x(), - ); - window_bounds.set_origin_y( - window_bounds.origin_y() - screen_bounds.origin_y(), - ); - bounds = WindowBounds::Fixed(window_bounds); - } - } - - cx.background() - .spawn(DB.set_window_bounds(workspace_id, bounds, display)) - .detach_and_log_err(cx); - }) - .detach(); workspace - }, - ); + }; + + let workspace = if let Some(window_id) = requesting_window_id { + cx.update(|cx| { + cx.replace_root_view(window_id, |cx| build_workspace(cx, serialized_workspace)) + }) + } else { + let (bounds, display) = if let Some(bounds) = window_bounds_override { + (Some(bounds), None) + } else { + serialized_workspace + .as_ref() + .and_then(|serialized_workspace| { + let display = serialized_workspace.display?; + let mut bounds = serialized_workspace.bounds?; + + // Stored bounds are relative to the containing display. + // So convert back to global coordinates if that screen still exists + if let WindowBounds::Fixed(mut window_bounds) = bounds { + if let Some(screen) = cx.platform().screen_by_id(display) { + let screen_bounds = screen.bounds(); + window_bounds.set_origin_x( + window_bounds.origin_x() + screen_bounds.origin_x(), + ); + window_bounds.set_origin_y( + window_bounds.origin_y() + screen_bounds.origin_y(), + ); + bounds = WindowBounds::Fixed(window_bounds); + } else { + // Screen no longer exists. Return none here. + return None; + } + } + + Some((bounds, display)) + }) + .unzip() + }; + + // Use the serialized workspace to construct the new window + cx.add_window( + (app_state.build_window_options)(bounds, display, cx.platform().as_ref()), + |cx| build_workspace(cx, serialized_workspace), + ) + .1 + }; notify_if_database_failed(&workspace, &mut cx); @@ -875,7 +976,7 @@ impl Workspace { if self.project.read(cx).is_local() { Task::Ready(Some(callback(self, cx))) } else { - let task = Self::new_local(Vec::new(), app_state.clone(), cx); + let task = Self::new_local(Vec::new(), app_state.clone(), None, cx); cx.spawn(|_vh, mut cx| async move { let (workspace, _) = task.await; workspace.update(&mut cx, callback) @@ -1344,7 +1445,8 @@ impl Workspace { } fn add_pane(&mut self, cx: &mut ViewContext) -> ViewHandle { - let pane = cx.add_view(|cx| Pane::new(None, cx)); + let pane = + cx.add_view(|cx| Pane::new(self.weak_handle().id(), None, self.background_actions, cx)); let pane_id = pane.id(); cx.subscribe(&pane, move |this, _, event, cx| { this.handle_pane_event(pane_id, event, cx) @@ -1356,6 +1458,23 @@ impl Workspace { pane } + pub fn add_item_to_center( + &mut self, + item: Box, + cx: &mut ViewContext, + ) -> bool { + if let Some(center_pane) = self.last_active_center_pane.clone() { + if let Some(center_pane) = center_pane.upgrade(cx) { + Pane::add_item(self, ¢er_pane, item, true, true, None, cx); + true + } else { + false + } + } else { + false + } + } + pub fn add_item(&mut self, item: Box, cx: &mut ViewContext) { let active_pane = self.active_pane().clone(); Pane::add_item(self, &active_pane, item, true, true, None, cx); @@ -1513,7 +1632,7 @@ impl Workspace { self.active_item_path_changed(cx); if &pane == self.dock_pane() { - Dock::show(self, cx); + Dock::show(self, true, cx); } else { self.last_active_center_pane = Some(pane.downgrade()); if self.dock.is_anchored_at(DockAnchor::Expanded) { @@ -2526,7 +2645,12 @@ impl Workspace { // the focus the dock generates start generating alternating // focus due to the deferred execution each triggering each other cx.after_window_update(move |workspace, cx| { - Dock::set_dock_position(workspace, serialized_workspace.dock_position, cx); + Dock::set_dock_position( + workspace, + serialized_workspace.dock_position, + true, + cx, + ); }); cx.notify(); @@ -2538,6 +2662,11 @@ impl Workspace { }) .detach(); } + + #[cfg(any(test, feature = "test-support"))] + pub fn test_new(project: ModelHandle, cx: &mut ViewContext) -> Self { + Self::new(None, 0, project, |_, _| None, || &[], cx) + } } fn notify_if_database_failed(workspace: &ViewHandle, cx: &mut AsyncAppContext) { @@ -2769,20 +2898,6 @@ impl std::fmt::Debug for OpenPaths { } } -fn open(_: &Open, cx: &mut MutableAppContext) { - let mut paths = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: true, - multiple: true, - }); - cx.spawn(|mut cx| async move { - if let Some(paths) = paths.recv().await.flatten() { - cx.update(|cx| cx.dispatch_global_action(OpenPaths { paths })); - } - }) - .detach(); -} - pub struct WorkspaceCreated(WeakViewHandle); pub fn activate_workspace_for_project( @@ -2809,6 +2924,7 @@ pub async fn last_opened_workspace_paths() -> Option { pub fn open_paths( abs_paths: &[PathBuf], app_state: &Arc, + requesting_window_id: Option, cx: &mut MutableAppContext, ) -> Task<( ViewHandle, @@ -2839,7 +2955,8 @@ pub fn open_paths( .contains(&false); cx.update(|cx| { - let task = Workspace::new_local(abs_paths, app_state.clone(), cx); + let task = + Workspace::new_local(abs_paths, app_state.clone(), requesting_window_id, cx); cx.spawn(|mut cx| async move { let (workspace, items) = task.await; @@ -2858,14 +2975,18 @@ pub fn open_paths( }) } -pub fn open_new(app_state: &Arc, cx: &mut MutableAppContext) -> Task<()> { - let task = Workspace::new_local(Vec::new(), app_state.clone(), cx); +pub fn open_new( + app_state: &Arc, + cx: &mut MutableAppContext, + init: impl FnOnce(&mut Workspace, &mut ViewContext) + 'static, +) -> Task<()> { + let task = Workspace::new_local(Vec::new(), app_state.clone(), None, cx); cx.spawn(|mut cx| async move { let (workspace, opened_paths) = task.await; - workspace.update(&mut cx, |_, cx| { + workspace.update(&mut cx, |workspace, cx| { if opened_paths.is_empty() { - cx.dispatch_action(NewFile); + init(workspace, cx) } }) }) @@ -2886,17 +3007,10 @@ mod tests { use super::*; use fs::FakeFs; - use gpui::{executor::Deterministic, TestAppContext, ViewContext}; + use gpui::{executor::Deterministic, TestAppContext}; use project::{Project, ProjectEntryId}; use serde_json::json; - pub fn default_item_factory( - _workspace: &mut Workspace, - _cx: &mut ViewContext, - ) -> Option> { - unimplemented!() - } - #[gpui::test] async fn test_tab_disambiguation(cx: &mut TestAppContext) { cx.foreground().forbid_parking(); @@ -2909,7 +3023,8 @@ mod tests { Default::default(), 0, project.clone(), - default_item_factory, + |_, _| None, + || &[], cx, ) }); @@ -2981,7 +3096,8 @@ mod tests { Default::default(), 0, project.clone(), - default_item_factory, + |_, _| None, + || &[], cx, ) }); @@ -3081,7 +3197,8 @@ mod tests { Default::default(), 0, project.clone(), - default_item_factory, + |_, _| None, + || &[], cx, ) }); @@ -3120,7 +3237,7 @@ mod tests { let project = Project::test(fs, None, cx).await; let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, default_item_factory, cx) + Workspace::new(Default::default(), 0, project, |_, _| None, || &[], cx) }); let item1 = cx.add_view(&workspace, |cx| { @@ -3229,7 +3346,7 @@ mod tests { let project = Project::test(fs, [], cx).await; let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, default_item_factory, cx) + Workspace::new(Default::default(), 0, project, |_, _| None, || &[], cx) }); // Create several workspace items with single project entries, and two @@ -3338,7 +3455,7 @@ mod tests { let project = Project::test(fs, [], cx).await; let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, default_item_factory, cx) + Workspace::new(Default::default(), 0, project, |_, _| None, || &[], cx) }); let item = cx.add_view(&workspace, |cx| { @@ -3457,7 +3574,7 @@ mod tests { let project = Project::test(fs, [], cx).await; let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, default_item_factory, cx) + Workspace::new(Default::default(), 0, project, |_, _| None, || &[], cx) }); let item = cx.add_view(&workspace, |cx| { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index a7c4861e02..a1e3af98f8 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -29,6 +29,7 @@ context_menu = { path = "../context_menu" } client = { path = "../client" } clock = { path = "../clock" } diagnostics = { path = "../diagnostics" } +db = { path = "../db" } editor = { path = "../editor" } feedback = { path = "../feedback" } file_finder = { path = "../file_finder" } @@ -38,6 +39,7 @@ fsevent = { path = "../fsevent" } fuzzy = { path = "../fuzzy" } go_to_line = { path = "../go_to_line" } gpui = { path = "../gpui" } +install_cli = { path = "../install_cli" } journal = { path = "../journal" } language = { path = "../language" } language_selector = { path = "../language_selector" } @@ -59,6 +61,7 @@ theme_testbench = { path = "../theme_testbench" } util = { path = "../util" } vim = { path = "../vim" } workspace = { path = "../workspace" } +welcome = { path = "../welcome" } anyhow = "1.0.38" async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } async-tar = "0.4.2" diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 655e3968cf..8cf6879f7a 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -13,11 +13,12 @@ use client::{ http::{self, HttpClient}, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN, }; +use db::kvp::KEY_VALUE_STORE; use futures::{ channel::{mpsc, oneshot}, FutureExt, SinkExt, StreamExt, }; -use gpui::{App, AssetSource, AsyncAppContext, MutableAppContext, Task, ViewContext}; +use gpui::{Action, App, AssetSource, AsyncAppContext, MutableAppContext, Task, ViewContext}; use isahc::{config::Configurable, Request}; use language::LanguageRegistry; use log::LevelFilter; @@ -35,17 +36,19 @@ use std::{ path::PathBuf, sync::Arc, thread, time::Duration, }; use terminal_view::{get_working_directory, TerminalView}; +use welcome::{show_welcome_experience, FIRST_OPEN}; use fs::RealFs; -use settings::watched_json::{watch_keymap_file, watch_settings_file, WatchedJsonFile}; +use settings::watched_json::WatchedJsonFile; use theme::ThemeRegistry; #[cfg(debug_assertions)] use util::StaffMode; use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt}; use workspace::{ - self, item::ItemHandle, notifications::NotifyResultExt, AppState, NewFile, OpenPaths, Workspace, + self, dock::FocusDock, item::ItemHandle, notifications::NotifyResultExt, AppState, NewFile, + OpenPaths, Workspace, }; -use zed::{self, build_window_options, initialize_workspace, languages, menus}; +use zed::{self, build_window_options, initialize_workspace, languages, menus, OpenSettings}; fn main() { let http = http::client(); @@ -119,7 +122,14 @@ fn main() { fs.clone(), )); - watch_settings_file(default_settings, settings_file_content, themes.clone(), cx); + settings::watch_files( + default_settings, + settings_file_content, + themes.clone(), + keymap_file, + cx, + ); + if !stdout_is_a_pty() { upload_previous_panics(http.clone(), cx); } @@ -132,8 +142,6 @@ fn main() { languages::init(languages.clone()); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx)); - watch_keymap_file(keymap_file, cx); - cx.set_global(client.clone()); context_menu::init(cx); @@ -179,6 +187,7 @@ fn main() { build_window_options, initialize_workspace, dock_default_item_factory, + background_actions, }); auto_update::init(http, client::ZED_SERVER_URL.clone(), cx); @@ -190,6 +199,7 @@ fn main() { zed::init(&app_state, cx); collab_ui::init(app_state.clone(), cx); feedback::init(app_state.clone(), cx); + welcome::init(cx); cx.set_menus(menus::menus()); @@ -197,7 +207,7 @@ fn main() { cx.platform().activate(true); let paths = collect_path_args(); if paths.is_empty() { - cx.spawn(|cx| async move { restore_or_create_workspace(cx).await }) + cx.spawn(|cx| async move { restore_or_create_workspace(&app_state, cx).await }) .detach() } else { cx.dispatch_global_action(OpenPaths { paths }); @@ -207,11 +217,14 @@ fn main() { cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx)) .detach(); } else if let Ok(Some(paths)) = open_paths_rx.try_next() { - cx.update(|cx| workspace::open_paths(&paths, &app_state, cx)) + cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx)) .detach(); } else { - cx.spawn(|cx| async move { restore_or_create_workspace(cx).await }) - .detach() + cx.spawn({ + let app_state = app_state.clone(); + |cx| async move { restore_or_create_workspace(&app_state, cx).await } + }) + .detach() } cx.spawn(|cx| { @@ -228,8 +241,7 @@ fn main() { let app_state = app_state.clone(); async move { while let Some(paths) = open_paths_rx.next().await { - log::error!("OPEN PATHS FROM HANDLE"); - cx.update(|cx| workspace::open_paths(&paths, &app_state, cx)) + cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx)) .detach(); } } @@ -251,13 +263,15 @@ fn main() { }); } -async fn restore_or_create_workspace(mut cx: AsyncAppContext) { +async fn restore_or_create_workspace(app_state: &Arc, mut cx: AsyncAppContext) { if let Some(location) = workspace::last_opened_workspace_paths().await { cx.update(|cx| { cx.dispatch_global_action(OpenPaths { paths: location.paths().as_ref().clone(), }) }); + } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { + cx.update(|cx| show_welcome_experience(app_state, cx)); } else { cx.update(|cx| { cx.dispatch_global_action(NewFile); @@ -591,7 +605,7 @@ async fn handle_cli_connection( paths }; let (workspace, items) = cx - .update(|cx| workspace::open_paths(&paths, &app_state, cx)) + .update(|cx| workspace::open_paths(&paths, &app_state, None, cx)) .await; let mut errored = false; @@ -692,3 +706,13 @@ pub fn dock_default_item_factory( Some(Box::new(terminal_view)) } + +pub fn background_actions() -> &'static [(&'static str, &'static dyn Action)] { + &[ + ("Go to file", &file_finder::Toggle), + ("Open command palette", &command_palette::Toggle), + ("Focus the dock", &FocusDock), + ("Open recent projects", &recent_projects::OpenRecent), + ("Change your settings", &OpenSettings), + ] +} diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index bb519c7a95..82422c5c19 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -19,7 +19,7 @@ pub fn menus() -> Vec> { MenuItem::action("Select Theme", theme_selector::Toggle), ], }), - MenuItem::action("Install CLI", super::InstallCommandLineInterface), + MenuItem::action("Install CLI", install_cli::Install), MenuItem::separator(), MenuItem::action("Hide Zed", super::Hide), MenuItem::action("Hide Others", super::HideOthers), @@ -137,8 +137,9 @@ pub fn menus() -> Vec> { items: vec![ MenuItem::action("Command Palette", command_palette::Toggle), MenuItem::separator(), - MenuItem::action("View Telemetry Log", crate::OpenTelemetryLog), + MenuItem::action("View Telemetry", crate::OpenTelemetryLog), MenuItem::action("View Dependency Licenses", crate::OpenLicenses), + MenuItem::action("Show Welcome", workspace::Welcome), MenuItem::separator(), MenuItem::action( "Copy System Specs Into Clipboard", diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 79a6f67f62..20dbcdb7ef 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2,7 +2,7 @@ pub mod languages; pub mod menus; #[cfg(any(test, feature = "test-support"))] pub mod test; -use anyhow::{anyhow, Context, Result}; +use anyhow::Context; use assets::Assets; use breadcrumbs::Breadcrumbs; pub use client; @@ -20,7 +20,7 @@ use gpui::{ geometry::vector::vec2f, impl_actions, platform::{WindowBounds, WindowOptions}, - AssetSource, AsyncAppContext, Platform, PromptLevel, TitlebarOptions, ViewContext, WindowKind, + AssetSource, Platform, PromptLevel, TitlebarOptions, ViewContext, WindowKind, }; use language::Rope; pub use lsp; @@ -65,7 +65,6 @@ actions!( IncreaseBufferFontSize, DecreaseBufferFontSize, ResetBufferFontSize, - InstallCommandLineInterface, ResetDatabase, ] ); @@ -140,9 +139,13 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { cx.refresh_windows(); }); }); - cx.add_global_action(move |_: &InstallCommandLineInterface, cx| { - cx.spawn(|cx| async move { install_cli(&cx).await.context("error creating CLI symlink") }) - .detach_and_log_err(cx); + cx.add_global_action(move |_: &install_cli::Install, cx| { + cx.spawn(|cx| async move { + install_cli::install_cli(&cx) + .await + .context("error creating CLI symlink") + }) + .detach_and_log_err(cx); }); cx.add_action({ let app_state = app_state.clone(); @@ -255,7 +258,6 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { workspace.toggle_sidebar_item_focus(SidebarSide::Left, 0, cx); }, ); - activity_indicator::init(cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); settings::KeymapFileContent::load_defaults(cx); @@ -482,54 +484,6 @@ fn about(_: &mut Workspace, _: &About, cx: &mut gpui::ViewContext) { ); } -async fn install_cli(cx: &AsyncAppContext) -> Result<()> { - let cli_path = cx.platform().path_for_auxiliary_executable("cli")?; - let link_path = Path::new("/usr/local/bin/zed"); - let bin_dir_path = link_path.parent().unwrap(); - - // Don't re-create symlink if it points to the same CLI binary. - if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) { - return Ok(()); - } - - // If the symlink is not there or is outdated, first try replacing it - // without escalating. - smol::fs::remove_file(link_path).await.log_err(); - if smol::fs::unix::symlink(&cli_path, link_path) - .await - .log_err() - .is_some() - { - return Ok(()); - } - - // The symlink could not be created, so use osascript with admin privileges - // to create it. - let status = smol::process::Command::new("osascript") - .args([ - "-e", - &format!( - "do shell script \" \ - mkdir -p \'{}\' && \ - ln -sf \'{}\' \'{}\' \ - \" with administrator privileges", - bin_dir_path.to_string_lossy(), - cli_path.to_string_lossy(), - link_path.to_string_lossy(), - ), - ]) - .stdout(smol::process::Stdio::inherit()) - .stderr(smol::process::Stdio::inherit()) - .output() - .await? - .status; - if status.success() { - Ok(()) - } else { - Err(anyhow!("error running osascript")) - } -} - fn open_config_file( path: &'static Path, app_state: Arc, @@ -758,6 +712,10 @@ mod tests { "ca": null, "cb": null, }, + "d": { + "da": null, + "db": null, + }, }), ) .await; @@ -766,13 +724,14 @@ mod tests { open_paths( &[PathBuf::from("/root/a"), PathBuf::from("/root/b")], &app_state, + None, cx, ) }) .await; assert_eq!(cx.window_ids().len(), 1); - cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, cx)) + cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) .await; assert_eq!(cx.window_ids().len(), 1); let workspace_1 = cx.root_view::(cx.window_ids()[0]).unwrap(); @@ -786,11 +745,37 @@ mod tests { open_paths( &[PathBuf::from("/root/b"), PathBuf::from("/root/c")], &app_state, + None, cx, ) }) .await; assert_eq!(cx.window_ids().len(), 2); + + // Replace existing windows + let window_id = cx.window_ids()[0]; + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/c"), PathBuf::from("/root/d")], + &app_state, + Some(window_id), + cx, + ) + }) + .await; + assert_eq!(cx.window_ids().len(), 2); + let workspace_1 = cx.root_view::(window_id).unwrap(); + workspace_1.read_with(cx, |workspace, cx| { + assert_eq!( + workspace + .worktrees(cx) + .map(|w| w.read(cx).abs_path()) + .collect::>(), + &[Path::new("/root/c").into(), Path::new("/root/d").into()] + ); + assert!(workspace.left_sidebar().read(cx).is_open()); + assert!(workspace.active_pane().is_focused(cx)); + }); } #[gpui::test] @@ -802,7 +787,7 @@ mod tests { .insert_tree("/root", json!({"a": "hey"})) .await; - cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, cx)) + cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) .await; assert_eq!(cx.window_ids().len(), 1); @@ -840,7 +825,7 @@ mod tests { assert!(!cx.is_window_edited(workspace.window_id())); // Opening the buffer again doesn't impact the window's edited state. - cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, cx)) + cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, None, cx)) .await; let editor = workspace.read_with(cx, |workspace, cx| { workspace @@ -870,7 +855,8 @@ mod tests { #[gpui::test] async fn test_new_empty_workspace(cx: &mut TestAppContext) { let app_state = init(cx); - cx.update(|cx| open_new(&app_state, cx)).await; + cx.update(|cx| open_new(&app_state, cx, |_, cx| cx.dispatch_action(NewFile))) + .await; let window_id = *cx.window_ids().first().unwrap(); let workspace = cx.root_view::(window_id).unwrap(); @@ -915,9 +901,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let entries = cx.read(|cx| workspace.file_project_paths(cx)); let file1 = entries[0].clone(); @@ -1036,9 +1020,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/dir1".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); // Open a file within an existing worktree. cx.update(|cx| { @@ -1197,9 +1179,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); // Open a file within an existing worktree. cx.update(|cx| { @@ -1241,9 +1221,7 @@ mod tests { let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; project.update(cx, |project, _| project.languages().add(rust_lang())); - let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap()); // Create a new untitled buffer @@ -1332,9 +1310,7 @@ mod tests { let project = Project::test(app_state.fs.clone(), [], cx).await; project.update(cx, |project, _| project.languages().add(rust_lang())); - let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); // Create a new untitled buffer cx.dispatch_action(window_id, NewFile); @@ -1387,9 +1363,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (window_id, workspace) = cx.add_window(|cx| { - Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx) - }); + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let entries = cx.read(|cx| workspace.file_project_paths(cx)); let file1 = entries[0].clone(); @@ -1463,15 +1437,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); let entries = cx.read(|cx| workspace.file_project_paths(cx)); let file1 = entries[0].clone(); @@ -1735,15 +1701,7 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (_, workspace) = cx.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); let entries = cx.read(|cx| workspace.file_project_paths(cx)); diff --git a/script/generate-licenses b/script/generate-licenses index 8a41f55c02..14c9d4c79f 100755 --- a/script/generate-licenses +++ b/script/generate-licenses @@ -10,7 +10,7 @@ echo -e "# ###### THEME LICENSES ######\n" >> $OUTPUT_FILE echo "Generating theme licenses" cd styles -npm ci +npm --silent ci npm run --silent build-licenses >> $OUTPUT_FILE cd .. diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index dc57468df6..423ce37d48 100644 --- a/styles/src/styleTree/app.ts +++ b/styles/src/styleTree/app.ts @@ -20,6 +20,7 @@ import contactList from "./contactList" import incomingCallNotification from "./incomingCallNotification" import { ColorScheme } from "../themes/common/colorScheme" import feedback from "./feedback" +import welcome from "./welcome" export default function app(colorScheme: ColorScheme): Object { return { @@ -33,6 +34,7 @@ export default function app(colorScheme: ColorScheme): Object { incomingCallNotification: incomingCallNotification(colorScheme), picker: picker(colorScheme), workspace: workspace(colorScheme), + welcome: welcome(colorScheme), contextMenu: contextMenu(colorScheme), editor: editor(colorScheme), projectDiagnostics: projectDiagnostics(colorScheme), diff --git a/styles/src/styleTree/components.ts b/styles/src/styleTree/components.ts index edbced8323..3f69df981e 100644 --- a/styles/src/styleTree/components.ts +++ b/styles/src/styleTree/components.ts @@ -93,7 +93,7 @@ interface Text { underline?: boolean } -interface TextProperties { +export interface TextProperties { size?: keyof typeof fontSizes weight?: FontWeight underline?: boolean diff --git a/styles/src/styleTree/contextMenu.ts b/styles/src/styleTree/contextMenu.ts index 30f44a6314..b4a21deba4 100644 --- a/styles/src/styleTree/contextMenu.ts +++ b/styles/src/styleTree/contextMenu.ts @@ -26,14 +26,19 @@ export default function contextMenu(colorScheme: ColorScheme) { hover: { background: background(layer, "hovered"), label: text(layer, "sans", "hovered", { size: "sm" }), + keystroke: { + ...text(layer, "sans", "hovered", { + size: "sm", + weight: "bold", + }), + padding: { left: 3, right: 3 }, + }, }, active: { background: background(layer, "active"), - label: text(layer, "sans", "active", { size: "sm" }), }, activeHover: { background: background(layer, "active"), - label: text(layer, "sans", "active", { size: "sm" }), }, }, separator: { diff --git a/styles/src/styleTree/projectPanel.ts b/styles/src/styleTree/projectPanel.ts index 90e0c82f5b..80cb884c48 100644 --- a/styles/src/styleTree/projectPanel.ts +++ b/styles/src/styleTree/projectPanel.ts @@ -29,6 +29,28 @@ export default function projectPanel(colorScheme: ColorScheme) { } return { + openProjectButton: { + background: background(layer), + border: border(layer, "active"), + cornerRadius: 4, + margin: { + top: 16, + left: 16, + right: 16, + }, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + ...text(layer, "sans", "default", { size: "sm" }), + hover: { + ...text(layer, "sans", "default", { size: "sm" }), + background: background(layer, "hovered"), + border: border(layer, "active"), + }, + }, background: background(layer), padding: { left: 12, right: 12, top: 6, bottom: 6 }, indentWidth: 8, diff --git a/styles/src/styleTree/welcome.ts b/styles/src/styleTree/welcome.ts new file mode 100644 index 0000000000..e1bd5c82bb --- /dev/null +++ b/styles/src/styleTree/welcome.ts @@ -0,0 +1,139 @@ + +import { ColorScheme } from "../themes/common/colorScheme"; +import { withOpacity } from "../utils/color"; +import { border, background, foreground, text, TextProperties } from "./components"; + + +export default function welcome(colorScheme: ColorScheme) { + let layer = colorScheme.highest; + + let checkboxBase = { + cornerRadius: 4, + padding: { + left: 3, + right: 3, + top: 3, + bottom: 3, + }, + // shadow: colorScheme.popoverShadow, + border: border(layer), + margin: { + right: 8, + top: 5, + bottom: 5 + }, + }; + + let interactive_text_size: TextProperties = { size: "sm" } + + return { + pageWidth: 320, + logo: { + color: foreground(layer, "default"), + icon: "icons/logo_96.svg", + dimensions: { + width: 64, + height: 64, + } + }, + logoSubheading: { + ...text(layer, "sans", "variant", { size: "md" }), + margin: { + top: 10, + bottom: 7, + }, + }, + buttonGroup: { + margin: { + top: 8, + bottom: 16 + }, + }, + headingGroup: { + margin: { + top: 8, + bottom: 12 + }, + }, + checkboxGroup: { + border: border(layer, "variant"), + background: withOpacity(background(layer, "hovered"), 0.25), + cornerRadius: 4, + padding: { + left: 12, + top: 2, + bottom: 2 + }, + }, + button: { + background: background(layer), + border: border(layer, "active"), + cornerRadius: 4, + margin: { + top: 4, + bottom: 4 + }, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + ...text(layer, "sans", "default", interactive_text_size), + hover: { + ...text(layer, "sans", "default", interactive_text_size), + background: background(layer, "hovered"), + border: border(layer, "active"), + }, + }, + usageNote: { + ...text(layer, "sans", "variant", { size: "2xs" }), + padding: { + top: -4, + + } + }, + checkboxContainer: { + margin: { + top: 4, + }, + padding: { + bottom: 8, + } + }, + checkbox: { + label: { + ...text(layer, "sans", interactive_text_size), + // Also supports margin, container, border, etc. + }, + icon: { + color: foreground(layer, "on"), + icon: "icons/check_12.svg", + dimensions: { + width: 12, + height: 12, + } + }, + default: { + ...checkboxBase, + background: background(layer, "default"), + border: border(layer, "active") + }, + checked: { + ...checkboxBase, + background: background(layer, "hovered"), + border: border(layer, "active") + }, + hovered: { + ...checkboxBase, + background: background(layer, "hovered"), + border: border(layer, "active") + }, + hoveredAndChecked: { + ...checkboxBase, + background: background(layer, "hovered"), + border: border(layer, "active") + } + } + } +} diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts index f9f49e3c7d..bebe87ce55 100644 --- a/styles/src/styleTree/workspace.ts +++ b/styles/src/styleTree/workspace.ts @@ -40,7 +40,49 @@ export default function workspace(colorScheme: ColorScheme) { const followerAvatarOuterWidth = followerAvatarWidth + 4 return { - background: background(layer), + background: background(colorScheme.lowest), + blankPane: { + logoContainer: { + width: 256, + height: 256, + }, + logo: { + color: withOpacity("#000000", colorScheme.isLight ? 0.6 : 0.8), + icon: "icons/logo_96.svg", + dimensions: { + width: 256, + height: 256, + }, + }, + logoShadow: { + color: withOpacity(colorScheme.isLight ? "#FFFFFF" : colorScheme.lowest.base.default.background, colorScheme.isLight ? 1 : 0.6), + icon: "icons/logo_96.svg", + dimensions: { + width: 256, + height: 256, + }, + }, + keyboardHints: { + margin: { + top: 96, + }, + cornerRadius: 4, + }, + keyboardHint: { + ...text(layer, "sans", "variant", { size: "sm" }), + padding: { + top: 3, + left: 8, + right: 8, + bottom: 3 + }, + cornerRadius: 8, + hover: { + ...text(layer, "sans", "active", { size: "sm" }), + } + }, + keyboardHintWidth: 320, + }, joiningProjectAvatar: { cornerRadius: 40, width: 80, @@ -248,7 +290,7 @@ export default function workspace(colorScheme: ColorScheme) { }, dock: { initialSizeRight: 640, - initialSizeBottom: 480, + initialSizeBottom: 304, wash_color: withOpacity(background(colorScheme.highest), 0.5), panel: { border: border(colorScheme.middle),