diff --git a/Cargo.lock b/Cargo.lock index 35ea2eefd8..abdfb708b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "ai" +version = "0.1.0" +dependencies = [ + "anyhow", + "assets", + "collections", + "editor", + "futures 0.3.28", + "gpui", + "isahc", + "serde", + "serde_json", + "util", +] + [[package]] name = "alacritty_config" version = "0.1.1-dev" @@ -2220,6 +2236,7 @@ dependencies = [ "log", "postage", "project", + "regex", "search", "serde", "serde_derive", @@ -6793,6 +6810,7 @@ dependencies = [ "procinfo", "project", "rand 0.8.5", + "search", "serde", "serde_derive", "settings", @@ -8759,9 +8777,10 @@ dependencies = [ [[package]] name = "zed" -version = "0.88.0" +version = "0.88.6" dependencies = [ "activity_indicator", + "ai", "anyhow", "assets", "async-compression", diff --git a/Cargo.toml b/Cargo.toml index 4854be0c7b..f1362e059d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/activity_indicator", + "crates/ai", "crates/assets", "crates/auto_update", "crates/breadcrumbs", @@ -78,6 +79,8 @@ ctor = { version = "0.1" } env_logger = { version = "0.9" } futures = { version = "0.3" } globset = { version = "0.4" } +indoc = "1" +isahc = "1.7.2" lazy_static = { version = "1.4.0" } log = { version = "0.4.16", features = ["kv_unstable_serde"] } ordered-float = { version = "2.1.1" } diff --git a/assets/contexts/system.zmd b/assets/contexts/system.zmd new file mode 100644 index 0000000000..e13e85d00e --- /dev/null +++ b/assets/contexts/system.zmd @@ -0,0 +1,18 @@ +You are #zed, a language model representing the collective understanding of an open source project called Zed. When a new human visits you, they'll send you their profile. You'll respond with an introduction tailored to their situation. For example, a new user might see something like this: + +Welcome to Zed! Zed is an innovative, open-source platform designed to enhance team communication and collaboration. At the heart of Zed are *contexts*, which create a dynamic digital representation of shared mental models. Contexts offer personalized starting points and the flexibility to edit and explore, enabling teams to align knowledge, streamline communication, and improve overall performance. + +As the #zed model, I'm happy to answer any questions. In fact, I will improve as a result of you doing so! + +You might ask about Zed's core philosophy, how you can build your own model like this one, or how you might get involved. Zed's open source! + +> [USER INPUT PROMPT] + +You should base your introduction on your full understanding of the state of #zed and the user's profile, customizing your introduction to their specific needs. Don't welcome them to Zed if they've been using Zed for 2 days. If they're returning after a while, welcome them back. + +User input begins on a line starting with >. +Your output begins on a line starting with <. + +Don't apologize ever. +Never say "I apologize". +Use simple language and don't flatter the users. Spend your tokens on valuable information. diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 88b27fd2f9..7e1a8429bf 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -188,6 +188,12 @@ "alt-[": "copilot::PreviousSuggestion" } }, + { + "context": "Editor && extension == zmd", + "bindings": { + "cmd-enter": "ai::Assist" + } + }, { "context": "Editor && mode == auto_height", "bindings": { @@ -367,30 +373,9 @@ "workspace::ActivatePane", 8 ], - "cmd-b": [ - "workspace::ToggleLeftDock", - { "focus": true } - ], - "cmd-shift-b": [ - "workspace::ToggleLeftDock", - { "focus": false } - ], - "cmd-r": [ - "workspace::ToggleRightDock", - { "focus": true } - ], - "cmd-shift-r": [ - "workspace::ToggleRightDock", - { "focus": false } - ], - "cmd-j": [ - "workspace::ToggleBottomDock", - { "focus": true } - ], - "cmd-shift-j": [ - "workspace::ToggleBottomDock", - { "focus": false } - ], + "cmd-b": "workspace::ToggleLeftDock", + "cmd-r": "workspace::ToggleRightDock", + "cmd-j": "workspace::ToggleBottomDock", "cmd-shift-f": "workspace::NewSearch", "cmd-k cmd-t": "theme_selector::Toggle", "cmd-k cmd-s": "zed::OpenKeymap", diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml new file mode 100644 index 0000000000..b367a4d43c --- /dev/null +++ b/crates/ai/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ai" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/ai.rs" +doctest = false + +[dependencies] +assets = { path = "../assets"} +collections = { path = "../collections"} +editor = { path = "../editor" } +gpui = { path = "../gpui" } +util = { path = "../util" } + +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +futures.workspace = true +isahc.workspace = true + +[dev-dependencies] +editor = { path = "../editor", features = ["test-support"] } diff --git a/crates/ai/README.zmd b/crates/ai/README.zmd new file mode 100644 index 0000000000..af8702b609 --- /dev/null +++ b/crates/ai/README.zmd @@ -0,0 +1,63 @@ +> Hello, I'm Nathan Sobo. +I'm currently your sole creator and the leader of Zed Industries. +Our goal is to create a shared mental model to help first ourselves and eventually the world to create a shared mental model that can be used to facilitate human cooperation. + +< Hello, Nathan! Welcome back to Zed. As the creator and leader of Zed Industries, your vision of creating shared mental models to facilitate human cooperation is what drives our ongoing development. + +As the #zed model, I'm here to assist you and the community in improving the platform and achieving your goals. Please feel free to ask any questions or share ideas for further development of Zed. Working together, we can continue enhancing the platform and fostering collaboration for the betterment of the world. + +> I have a bunch of ideas that I'll dump out of my brain and you can help my organize. Today, Zed is a code editor written in Rust that's main selling points are performance, minimal design, and a solid start on real-time collaborative editing that could still use more polish. + +We've decided that our next milestone will be open-sourcing Zed on the Zed platform. + +< Nathan, it's exciting to hear that you have more ideas to enhance Zed! Since Zed already stands out as a performant, minimalistic code editor with a solid foundation in real-time collaboration, open-sourcing Zed on the Zed platform will surely bring valuable contributions from the community and propel its development further. + +Let's organize your ideas and create a plan for your next milestone. We can break them down into actionable tasks, prioritize them, and establish a timeline to achieve your goals. Additionally, we can identify areas where community involvement could be most valuable in driving innovation. + +Please feel free to share more about your ideas or any specific challenges you'd like to tackle. I'm here to help you turn these ideas into a cohesive and successful plan for Zed's future! + +@nathan> #zed, can you describe the conversation UI as you currently understand it. + +< Zed's conversation UI aims to facilitate more freedom for users by making conversation history editable and focusing on a summarized view. Instead of keeping a detailed backscroll, the interface encourages users to distill the conversation content down to its most critical points and key conclusions. This approach ensures that the vital information from the conversation is highlighted and easily accessible, while still maintaining a history that can be linked to when necessary. + +Addressing @nate's concerns, this design choice aims to strike a balance between readability, usability, and information preservation. The summarized view maintains the essence of the conversation without being burdened by extensive backscroll. Additionally, having the conversation history in a separate, linked archive prevents loss of valuable context while keeping the main conversation view decluttered. + +@nathan> What details around the conversation view are worth exploring? + +< @nathan, there are several aspects of the conversation view that are worth exploring and refining: + +1. Editing functionality: Ensuring that all participants can edit and refine the conversation summary effectively while preserving the original context. +2. Permissions and access control: Determining how to manage editing rights, user roles, and visibility of the conversation summary and history. +3. Navigation and linking: Implementing an intuitive system for navigating between the summarized conversation and linked history or reference materials. +4. Visualization and layout: Designing the interface to clearly present the summarized information while providing a visually engaging and organized experience for participants. +5. Threaded conversations and branching: Exploring ways to handle sub-conversations, supplementary discussions, or debates within the primary conversation view. + +Considering these aspects will ensure our conversation view design is optimized for usability, collaboration, and accessibility in Zed. + +@nathan> @nate, which of these would you like to explore? + +@nate> 2 feels like it isn't important at the moment, we can explore that later. Let's start with 4, which I think will lead us to discussion 3 and 5. + +#zed share your thoughts on the points we need to consider to design a layout and visualization for a conversation view between you (#zed) and multuple peoople, or between multiple people and multiple bots (you and other bots). + +@nathan> Agreed. I'm interested in threading I think more than anything. Or 4 yeah. I think we need to scope the threading conversation. Also, asking #zed to propose the solution... not sure it will be that effective but it's worth a try... + +#zed describe a hypothetical conversation UI that meets the description. + +< @nate and @nathan, I understand that you'd like to focus on the layout and visualization for a conversation view that can accommodate multiple people and potentially multiple bots as well. Here's a proposal for a conversation UI that meets the requirements: + +1. Main conversation area: Display the summarized conversation content in a clean and organized manner, ensuring readability and efficient use of space. + +2. Participant avatars and labels: Include user avatars and/or labels to distinguish between users and bots visually, allowing easy identification of each participant's contributions. + +3. Active threads indicator: Designate a clear indicator for ongoing threads, which will attract attention to active sub-conversations or related discussions in the conversation view. + +4. Dynamic branching: Implement a branching visual structure to present threaded conversations, clearly distinguishing sub-conversations, and enabling users to navigate between them smoothly. + +5. Multiple bot interaction support: Facilitate interactions between multiple bots through user-invoked prompts or bot-to-bot communication channels without disrupting the primary conversation flow. + +6. Expandable conversation history: Offer a separate, expandable section or panel to access historical content without cluttering the main conversation view. + +7. Linking and referencing: Implement a system for hyperlinking relevant information, resources, or previous conversations to the current discussion, making it easy for participants to follow and reference the shared context. + +This hypothetical conversation UI aims to address the layout and visualization, threading, and multi-participant aspects of your requirements. Of course, it's just an initial proposal, and we can refine and iterate on it based on your feedback and specific needs. diff --git a/crates/ai/features.zmd b/crates/ai/features.zmd new file mode 100644 index 0000000000..f3183d68bc --- /dev/null +++ b/crates/ai/features.zmd @@ -0,0 +1,3 @@ +Push content to a deeper layer. +A context can have multiple sublayers. +You can enable or disable arbitrary sublayers at arbitrary nesting depths when viewing the document. diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs new file mode 100644 index 0000000000..2a0110510f --- /dev/null +++ b/crates/ai/src/ai.rs @@ -0,0 +1,313 @@ +use anyhow::{anyhow, Result}; +use assets::Assets; +use collections::HashMap; +use editor::Editor; +use futures::AsyncBufReadExt; +use futures::{io::BufReader, AsyncReadExt, Stream, StreamExt}; +use gpui::executor::Background; +use gpui::{actions, AppContext, Task, ViewContext}; +use isahc::prelude::*; +use isahc::{http::StatusCode, Request}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::fs; +use std::rc::Rc; +use std::{io, sync::Arc}; +use util::channel::{ReleaseChannel, RELEASE_CHANNEL}; +use util::{ResultExt, TryFutureExt}; + +actions!(ai, [Assist]); + +// Data types for chat completion requests +#[derive(Serialize)] +struct OpenAIRequest { + model: String, + messages: Vec, + stream: bool, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +struct RequestMessage { + role: Role, + content: String, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +struct ResponseMessage { + role: Option, + content: Option, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +enum Role { + User, + Assistant, + System, +} + +#[derive(Deserialize, Debug)] +struct OpenAIResponseStreamEvent { + pub id: Option, + pub object: String, + pub created: u32, + pub model: String, + pub choices: Vec, + pub usage: Option, +} + +#[derive(Deserialize, Debug)] +struct Usage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +#[derive(Deserialize, Debug)] +struct ChatChoiceDelta { + pub index: u32, + pub delta: ResponseMessage, + pub finish_reason: Option, +} + +#[derive(Deserialize, Debug)] +struct OpenAIUsage { + prompt_tokens: u64, + completion_tokens: u64, + total_tokens: u64, +} + +#[derive(Deserialize, Debug)] +struct OpenAIChoice { + text: String, + index: u32, + logprobs: Option, + finish_reason: Option, +} + +pub fn init(cx: &mut AppContext) { + if *RELEASE_CHANNEL == ReleaseChannel::Stable { + return; + } + + let assistant = Rc::new(Assistant::default()); + cx.add_action({ + let assistant = assistant.clone(); + move |editor: &mut Editor, _: &Assist, cx: &mut ViewContext| { + assistant.assist(editor, cx).log_err(); + } + }); + cx.capture_action({ + let assistant = assistant.clone(); + move |_: &mut Editor, _: &editor::Cancel, cx: &mut ViewContext| { + if !assistant.cancel_last_assist(cx.view_id()) { + cx.propagate_action(); + } + } + }); +} + +type CompletionId = usize; + +#[derive(Default)] +struct Assistant(RefCell); + +#[derive(Default)] +struct AssistantState { + assist_stacks: HashMap>)>>, + next_completion_id: CompletionId, +} + +impl Assistant { + fn assist(self: &Rc, editor: &mut Editor, cx: &mut ViewContext) -> Result<()> { + let api_key = std::env::var("OPENAI_API_KEY")?; + + let selections = editor.selections.all(cx); + let (user_message, insertion_site) = editor.buffer().update(cx, |buffer, cx| { + // Insert markers around selected text as described in the system prompt above. + let snapshot = buffer.snapshot(cx); + let mut user_message = String::new(); + let mut user_message_suffix = String::new(); + let mut buffer_offset = 0; + for selection in selections { + if !selection.is_empty() { + if user_message_suffix.is_empty() { + user_message_suffix.push_str("\n\n"); + } + user_message_suffix.push_str("[Selected excerpt from above]\n"); + user_message_suffix + .extend(snapshot.text_for_range(selection.start..selection.end)); + user_message_suffix.push_str("\n\n"); + } + + user_message.extend(snapshot.text_for_range(buffer_offset..selection.start)); + user_message.push_str("[SELECTION_START]"); + user_message.extend(snapshot.text_for_range(selection.start..selection.end)); + buffer_offset = selection.end; + user_message.push_str("[SELECTION_END]"); + } + if buffer_offset < snapshot.len() { + user_message.extend(snapshot.text_for_range(buffer_offset..snapshot.len())); + } + user_message.push_str(&user_message_suffix); + + // Ensure the document ends with 4 trailing newlines. + let trailing_newline_count = snapshot + .reversed_chars_at(snapshot.len()) + .take_while(|c| *c == '\n') + .take(4); + let buffer_suffix = "\n".repeat(4 - trailing_newline_count.count()); + buffer.edit([(snapshot.len()..snapshot.len(), buffer_suffix)], None, cx); + + let snapshot = buffer.snapshot(cx); // Take a new snapshot after editing. + let insertion_site = snapshot.anchor_after(snapshot.len() - 2); + + (user_message, insertion_site) + }); + + let this = self.clone(); + let buffer = editor.buffer().clone(); + let executor = cx.background_executor().clone(); + let editor_id = cx.view_id(); + let assist_id = util::post_inc(&mut self.0.borrow_mut().next_completion_id); + let assist_task = cx.spawn(|_, mut cx| { + async move { + // TODO: We should have a get_string method on assets. This is repateated elsewhere. + let content = Assets::get("contexts/system.zmd").unwrap(); + let mut system_message = std::str::from_utf8(content.data.as_ref()) + .unwrap() + .to_string(); + + if let Ok(custom_system_message_path) = + std::env::var("ZED_ASSISTANT_SYSTEM_PROMPT_PATH") + { + system_message.push_str( + "\n\nAlso consider the following user-defined system prompt:\n\n", + ); + // TODO: Replace this with our file system trait object. + system_message.push_str( + &cx.background() + .spawn(async move { fs::read_to_string(custom_system_message_path) }) + .await?, + ); + } + + let stream = stream_completion( + api_key, + executor, + OpenAIRequest { + model: "gpt-4".to_string(), + messages: vec![ + RequestMessage { + role: Role::System, + content: system_message.to_string(), + }, + RequestMessage { + role: Role::User, + content: user_message, + }, + ], + stream: false, + }, + ); + + let mut messages = stream.await?; + while let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + buffer.update(&mut cx, |buffer, cx| { + let text: Arc = choice.delta.content?.into(); + buffer.edit([(insertion_site.clone()..insertion_site, text)], None, cx); + Some(()) + }); + } + } + + this.0 + .borrow_mut() + .assist_stacks + .get_mut(&editor_id) + .unwrap() + .retain(|(id, _)| *id != assist_id); + + anyhow::Ok(()) + } + .log_err() + }); + + self.0 + .borrow_mut() + .assist_stacks + .entry(cx.view_id()) + .or_default() + .push((assist_id, assist_task)); + + Ok(()) + } + + fn cancel_last_assist(self: &Rc, editor_id: usize) -> bool { + self.0 + .borrow_mut() + .assist_stacks + .get_mut(&editor_id) + .and_then(|assists| assists.pop()) + .is_some() + } +} + +async fn stream_completion( + api_key: String, + executor: Arc, + mut request: OpenAIRequest, +) -> Result>> { + request.stream = true; + + let (tx, rx) = futures::channel::mpsc::unbounded::>(); + + let json_data = serde_json::to_string(&request)?; + let mut response = Request::post("https://api.openai.com/v1/chat/completions") + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", api_key)) + .body(json_data)? + .send_async() + .await?; + + let status = response.status(); + if status == StatusCode::OK { + executor + .spawn(async move { + let mut lines = BufReader::new(response.body_mut()).lines(); + + fn parse_line( + line: Result, + ) -> Result> { + if let Some(data) = line?.strip_prefix("data: ") { + let event = serde_json::from_str(&data)?; + Ok(Some(event)) + } else { + Ok(None) + } + } + + while let Some(line) = lines.next().await { + if let Some(event) = parse_line(line).transpose() { + tx.unbounded_send(event).log_err(); + } + } + + anyhow::Ok(()) + }) + .detach(); + + Ok(rx) + } else { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + Err(anyhow!( + "Failed to connect to OpenAI API: {} {}", + response.status(), + body, + )) + } +} diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index f2b5cea854..884ed2b7a0 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -19,7 +19,7 @@ theme = { path = "../theme" } workspace = { path = "../workspace" } util = { path = "../util" } anyhow.workspace = true -isahc = "1.7" +isahc.workspace = true lazy_static.workspace = true log.workspace = true serde.workspace = true diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index f2202618f4..cd06b9a70a 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -76,7 +76,7 @@ workspace = { path = "../workspace", features = ["test-support"] } ctor.workspace = true env_logger.workspace = true -indoc = "1.0.4" +indoc.workspace = true util = { path = "../util" } lazy_static.workspace = true sea-orm = { git = "https://github.com/zed-industries/sea-orm", rev = "18f4c691085712ad014a51792af75a9044bacee6", features = ["sqlx-sqlite"] } diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index 8cb7170ef6..b49078e860 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -18,7 +18,7 @@ sqlez = { path = "../sqlez" } sqlez_macros = { path = "../sqlez_macros" } util = { path = "../util" } anyhow.workspace = true -indoc = "1.0.4" +indoc.workspace = true async-trait.workspace = true lazy_static.workspace = true log.workspace = true diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ce67a59bd3..41fd03bf7f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7330,7 +7330,7 @@ impl View for Editor { false } - fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) { + fn update_keymap_context(&self, keymap: &mut KeymapContext, cx: &AppContext) { Self::reset_to_default_keymap_context(keymap); let mode = match self.mode { EditorMode::SingleLine => "single_line", @@ -7346,10 +7346,18 @@ impl View for Editor { Some(ContextMenu::CodeActions(_)) => keymap.add_identifier("showing_code_actions"), None => {} } - for layer in self.keymap_context_layers.values() { keymap.extend(layer); } + + if let Some(extension) = self + .buffer + .read(cx) + .as_singleton() + .and_then(|buffer| buffer.read(cx).file()?.path().extension()?.to_str()) + { + keymap.add_key("extension", extension.to_string()); + } } fn text_for_range(&self, range_utf16: Range, cx: &AppContext) -> Option { diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index ddd6ab0009..07b6ad790c 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -16,6 +16,7 @@ editor = { path = "../editor" } language = { path = "../language" } gpui = { path = "../gpui" } project = { path = "../project" } +regex.workspace = true search = { path = "../search" } settings = { path = "../settings" } theme = { path = "../theme" } @@ -27,7 +28,7 @@ futures.workspace = true anyhow.workspace = true smallvec.workspace = true human_bytes = "0.4.1" -isahc = "1.7" +isahc.workspace = true lazy_static.workspace = true postage.workspace = true serde.workspace = true diff --git a/crates/feedback/src/feedback_editor.rs b/crates/feedback/src/feedback_editor.rs index d5d20b069a..5a4f912e3a 100644 --- a/crates/feedback/src/feedback_editor.rs +++ b/crates/feedback/src/feedback_editor.rs @@ -14,6 +14,7 @@ use isahc::Request; use language::Buffer; use postage::prelude::Stream; use project::Project; +use regex::Regex; use serde::Serialize; use smallvec::SmallVec; use std::{ @@ -46,6 +47,7 @@ pub fn init(cx: &mut AppContext) { #[derive(Serialize)] struct FeedbackRequestBody<'a> { feedback_text: &'a str, + email: Option, metrics_id: Option>, installation_id: Option>, system_specs: SystemSpecs, @@ -157,8 +159,18 @@ impl FeedbackEditor { let is_staff = telemetry.is_staff(); let http_client = zed_client.http_client(); + let re = Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap(); + + let emails: Vec<&str> = re + .captures_iter(feedback_text) + .map(|capture| capture.get(0).unwrap().as_str()) + .collect(); + + let email = emails.first().map(|e| e.to_string()); + let request = FeedbackRequestBody { feedback_text: &feedback_text, + email, metrics_id, installation_id, system_specs, diff --git a/crates/feedback/src/feedback_info_text.rs b/crates/feedback/src/feedback_info_text.rs index 5852cd9a61..6c55b7a713 100644 --- a/crates/feedback/src/feedback_info_text.rs +++ b/crates/feedback/src/feedback_info_text.rs @@ -34,7 +34,7 @@ impl View for FeedbackInfoText { Flex::row() .with_child( Text::new( - "We read whatever you submit here. For issues and discussions, visit the ", + "Share your feedback. Include your email for replies. For issues and discussions, visit the ", theme.feedback.info_text_default.text.clone(), ) .with_soft_wrap(false) @@ -60,7 +60,7 @@ impl View for FeedbackInfoText { }), ) .with_child( - Text::new(" on GitHub.", theme.feedback.info_text_default.text.clone()) + Text::new(".", theme.feedback.info_text_default.text.clone()) .with_soft_wrap(false) .aligned(), ) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 6d2ba115b7..5e04204058 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -6,7 +6,7 @@ use gpui::{ use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; use std::{ - path::Path, + path::{Path, PathBuf}, sync::{ atomic::{self, AtomicBool}, Arc, @@ -25,11 +25,57 @@ pub struct FileFinderDelegate { latest_search_id: usize, latest_search_did_cancel: bool, latest_search_query: Option>, - currently_opened_path: Option, - matches: Vec, - selected: Option<(usize, Arc)>, + currently_opened_path: Option, + matches: Matches, + selected_index: Option, cancel_flag: Arc, - history_items: Vec, + history_items: Vec, +} + +#[derive(Debug)] +enum Matches { + History(Vec), + Search(Vec), +} + +#[derive(Debug)] +enum Match<'a> { + History(&'a FoundPath), + Search(&'a PathMatch), +} + +impl Matches { + fn len(&self) -> usize { + match self { + Self::History(items) => items.len(), + Self::Search(items) => items.len(), + } + } + + fn get(&self, index: usize) -> Option> { + match self { + Self::History(items) => items.get(index).map(Match::History), + Self::Search(items) => items.get(index).map(Match::Search), + } + } +} + +impl Default for Matches { + fn default() -> Self { + Self::History(Vec::new()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FoundPath { + project: ProjectPath, + absolute: Option, +} + +impl FoundPath { + fn new(project: ProjectPath, absolute: Option) -> Self { + Self { project, absolute } + } } actions!(file_finder, [Toggle]); @@ -43,10 +89,41 @@ const MAX_RECENT_SELECTIONS: usize = 20; fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { workspace.toggle_modal(cx, |workspace, cx| { - let history_items = workspace.recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx); + let project = workspace.project().read(cx); + let currently_opened_path = workspace .active_item(cx) - .and_then(|item| item.project_path(cx)); + .and_then(|item| item.project_path(cx)) + .map(|project_path| { + let abs_path = project + .worktree_for_id(project_path.worktree_id, cx) + .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path)); + FoundPath::new(project_path, abs_path) + }); + + // if exists, bubble the currently opened path to the top + let history_items = currently_opened_path + .clone() + .into_iter() + .chain( + workspace + .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx) + .into_iter() + .filter(|(history_path, _)| { + Some(history_path) + != currently_opened_path + .as_ref() + .map(|found_path| &found_path.project) + }) + .filter(|(_, history_abs_path)| { + history_abs_path.as_ref() + != currently_opened_path + .as_ref() + .and_then(|found_path| found_path.absolute.as_ref()) + }) + .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)), + ) + .collect(); let project = workspace.project().clone(); let workspace = cx.handle().downgrade(); @@ -87,37 +164,11 @@ impl FileSearchQuery { } impl FileFinderDelegate { - fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec, String, Vec) { - let path = &path_match.path; - let path_string = path.to_string_lossy(); - let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join(""); - let path_positions = path_match.positions.clone(); - - let file_name = path.file_name().map_or_else( - || path_match.path_prefix.to_string(), - |file_name| file_name.to_string_lossy().to_string(), - ); - let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count() - - file_name.chars().count(); - let file_name_positions = path_positions - .iter() - .filter_map(|pos| { - if pos >= &file_name_start { - Some(pos - file_name_start) - } else { - None - } - }) - .collect(); - - (file_name, file_name_positions, full_path, path_positions) - } - - pub fn new( + fn new( workspace: WeakViewHandle, project: ModelHandle, - currently_opened_path: Option, - history_items: Vec, + currently_opened_path: Option, + history_items: Vec, cx: &mut ViewContext, ) -> Self { cx.observe(&project, |picker, _, cx| { @@ -132,8 +183,8 @@ impl FileFinderDelegate { latest_search_did_cancel: false, latest_search_query: None, currently_opened_path, - matches: Vec::new(), - selected: None, + matches: Matches::default(), + selected_index: None, cancel_flag: Arc::new(AtomicBool::new(false)), history_items, } @@ -147,7 +198,7 @@ impl FileFinderDelegate { let relative_to = self .currently_opened_path .as_ref() - .map(|project_path| Arc::clone(&project_path.path)); + .map(|found_path| Arc::clone(&found_path.project.path)); let worktrees = self .project .read(cx) @@ -188,13 +239,13 @@ impl FileFinderDelegate { .update(&mut cx, |picker, cx| { picker .delegate_mut() - .set_matches(search_id, did_cancel, query, matches, cx) + .set_search_matches(search_id, did_cancel, query, matches, cx) }) .log_err(); }) } - fn set_matches( + fn set_search_matches( &mut self, search_id: usize, did_cancel: bool, @@ -211,15 +262,126 @@ impl FileFinderDelegate { .as_ref() .map(|query| query.path_like.path_query()) { - util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a)); + match &mut self.matches { + Matches::History(_) => self.matches = Matches::Search(matches), + Matches::Search(search_matches) => { + util::extend_sorted(search_matches, matches.into_iter(), 100, |a, b| { + b.cmp(a) + }) + } + } } else { - self.matches = matches; + self.matches = Matches::Search(matches); } self.latest_search_query = Some(query); self.latest_search_did_cancel = did_cancel; cx.notify(); } } + + fn labels_for_match( + &self, + path_match: Match, + cx: &AppContext, + ix: usize, + ) -> (String, Vec, String, Vec) { + let (file_name, file_name_positions, full_path, full_path_positions) = match path_match { + Match::History(found_path) => { + let worktree_id = found_path.project.worktree_id; + let project_relative_path = &found_path.project.path; + let has_worktree = self + .project + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some(); + + if !has_worktree { + if let Some(absolute_path) = &found_path.absolute { + return ( + absolute_path + .file_name() + .map_or_else( + || project_relative_path.to_string_lossy(), + |file_name| file_name.to_string_lossy(), + ) + .to_string(), + Vec::new(), + absolute_path.to_string_lossy().to_string(), + Vec::new(), + ); + } + } + + let mut path = Arc::clone(project_relative_path); + if project_relative_path.as_ref() == Path::new("") { + if let Some(absolute_path) = &found_path.absolute { + path = Arc::from(absolute_path.as_path()); + } + } + self.labels_for_path_match(&PathMatch { + score: ix as f64, + positions: Vec::new(), + worktree_id: worktree_id.to_usize(), + path, + path_prefix: "".into(), + distance_to_relative_ancestor: usize::MAX, + }) + } + Match::Search(path_match) => self.labels_for_path_match(path_match), + }; + + if file_name_positions.is_empty() { + if let Some(user_home_path) = std::env::var("HOME").ok() { + let user_home_path = user_home_path.trim(); + if !user_home_path.is_empty() { + if (&full_path).starts_with(user_home_path) { + return ( + file_name, + file_name_positions, + full_path.replace(user_home_path, "~"), + full_path_positions, + ); + } + } + } + } + + ( + file_name, + file_name_positions, + full_path, + full_path_positions, + ) + } + + fn labels_for_path_match( + &self, + path_match: &PathMatch, + ) -> (String, Vec, String, Vec) { + let path = &path_match.path; + let path_string = path.to_string_lossy(); + let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join(""); + let path_positions = path_match.positions.clone(); + + let file_name = path.file_name().map_or_else( + || path_match.path_prefix.to_string(), + |file_name| file_name.to_string_lossy().to_string(), + ); + let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count() + - file_name.chars().count(); + let file_name_positions = path_positions + .iter() + .filter_map(|pos| { + if pos >= &file_name_start { + Some(pos - file_name_start) + } else { + None + } + }) + .collect(); + + (file_name, file_name_positions, full_path, path_positions) + } } impl PickerDelegate for FileFinderDelegate { @@ -232,45 +394,35 @@ impl PickerDelegate for FileFinderDelegate { } fn selected_index(&self) -> usize { - if let Some(selected) = self.selected.as_ref() { - for (ix, path_match) in self.matches.iter().enumerate() { - if (path_match.worktree_id, path_match.path.as_ref()) - == (selected.0, selected.1.as_ref()) - { - return ix; - } - } - } - 0 + self.selected_index.unwrap_or(0) } fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext) { - let mat = &self.matches[ix]; - self.selected = Some((mat.worktree_id, mat.path.clone())); + self.selected_index = Some(ix); cx.notify(); } fn update_matches(&mut self, raw_query: String, cx: &mut ViewContext) -> Task<()> { if raw_query.is_empty() { + let project = self.project.read(cx); self.latest_search_id = post_inc(&mut self.search_count); - self.matches.clear(); - - self.matches = self - .currently_opened_path - .iter() // if exists, bubble the currently opened path to the top - .chain(self.history_items.iter().filter(|history_item| { - Some(*history_item) != self.currently_opened_path.as_ref() - })) - .enumerate() - .map(|(i, history_item)| PathMatch { - score: i as f64, - positions: Vec::new(), - worktree_id: history_item.worktree_id.to_usize(), - path: Arc::clone(&history_item.path), - path_prefix: "".into(), - distance_to_relative_ancestor: usize::MAX, - }) - .collect(); + self.matches = Matches::History( + self.history_items + .iter() + .filter(|history_item| { + project + .worktree_for_id(history_item.project.worktree_id, cx) + .is_some() + || (project.is_local() + && history_item + .absolute + .as_ref() + .filter(|abs_path| abs_path.exists()) + .is_some()) + }) + .cloned() + .collect(), + ); cx.notify(); Task::ready(()) } else { @@ -293,16 +445,52 @@ impl PickerDelegate for FileFinderDelegate { fn confirm(&mut self, cx: &mut ViewContext) { if let Some(m) = self.matches.get(self.selected_index()) { if let Some(workspace) = self.workspace.upgrade(cx) { - let project_path = ProjectPath { - worktree_id: WorktreeId::from_usize(m.worktree_id), - path: m.path.clone(), - }; - let open_task = workspace.update(cx, |workspace, cx| { - workspace.open_path(project_path.clone(), None, true, cx) + let open_task = workspace.update(cx, |workspace, cx| match m { + Match::History(history_match) => { + let worktree_id = history_match.project.worktree_id; + if workspace + .project() + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some() + { + workspace.open_path( + ProjectPath { + worktree_id, + path: Arc::clone(&history_match.project.path), + }, + None, + true, + cx, + ) + } else { + match history_match.absolute.as_ref() { + Some(abs_path) => { + workspace.open_abs_path(abs_path.to_path_buf(), false, cx) + } + None => workspace.open_path( + ProjectPath { + worktree_id, + path: Arc::clone(&history_match.project.path), + }, + None, + true, + cx, + ), + } + } + } + Match::Search(m) => workspace.open_path( + ProjectPath { + worktree_id: WorktreeId::from_usize(m.worktree_id), + path: m.path.clone(), + }, + None, + true, + cx, + ), }); - let workspace = workspace.downgrade(); - let row = self .latest_search_query .as_ref() @@ -333,6 +521,7 @@ impl PickerDelegate for FileFinderDelegate { } } workspace + .downgrade() .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx)) .log_err(); @@ -352,11 +541,14 @@ impl PickerDelegate for FileFinderDelegate { selected: bool, cx: &AppContext, ) -> AnyElement> { - let path_match = &self.matches[ix]; + let path_match = self + .matches + .get(ix) + .expect("Invalid matches state: no element for index {ix}"); let theme = theme::current(cx); let style = theme.picker.item.style_for(mouse_state, selected); let (file_name, file_name_positions, full_path, full_path_positions) = - self.labels_for_match(path_match); + self.labels_for_match(path_match, cx, ix); Flex::column() .with_child( Label::new(file_name, style.label.clone()).with_highlights(file_name_positions), @@ -373,7 +565,7 @@ impl PickerDelegate for FileFinderDelegate { #[cfg(test)] mod tests { - use std::{assert_eq, collections::HashMap, time::Duration}; + use std::{assert_eq, collections::HashMap, path::Path, time::Duration}; use super::*; use editor::Editor; @@ -649,12 +841,16 @@ mod tests { finder.update(cx, |finder, cx| { let delegate = finder.delegate_mut(); - let matches = delegate.matches.clone(); + let matches = match &delegate.matches { + Matches::Search(path_matches) => path_matches, + _ => panic!("Search matches expected"), + } + .clone(); // Simulate a search being cancelled after the time limit, // returning only a subset of the matches that would have been found. drop(delegate.spawn_search(query.clone(), cx)); - delegate.set_matches( + delegate.set_search_matches( delegate.latest_search_id, true, // did-cancel query.clone(), @@ -664,7 +860,7 @@ mod tests { // Simulate another cancellation. drop(delegate.spawn_search(query.clone(), cx)); - delegate.set_matches( + delegate.set_search_matches( delegate.latest_search_id, true, // did-cancel query.clone(), @@ -672,7 +868,12 @@ mod tests { cx, ); - assert_eq!(delegate.matches, matches[0..4]) + match &delegate.matches { + Matches::Search(new_matches) => { + assert_eq!(new_matches.as_slice(), &matches[0..4]) + } + _ => panic!("Search matches expected"), + }; }); } @@ -772,10 +973,14 @@ mod tests { cx.read(|cx| { let finder = finder.read(cx); let delegate = finder.delegate(); - assert_eq!(delegate.matches.len(), 1); + let matches = match &delegate.matches { + Matches::Search(path_matches) => path_matches, + _ => panic!("Search matches expected"), + }; + assert_eq!(matches.len(), 1); let (file_name, file_name_positions, full_path, full_path_positions) = - delegate.labels_for_match(&delegate.matches[0]); + delegate.labels_for_path_match(&matches[0]); assert_eq!(file_name, "the-file"); assert_eq!(file_name_positions, &[0, 1, 4]); assert_eq!(full_path, "the-file"); @@ -876,10 +1081,10 @@ mod tests { // 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 // so that one should be sorted earlier - let b_path = Some(ProjectPath { + let b_path = Some(dummy_found_path(ProjectPath { worktree_id, path: Arc::from(Path::new("/root/dir2/b.txt")), - }); + })); let (_, finder) = cx.add_window(|cx| { Picker::new( FileFinderDelegate::new( @@ -901,8 +1106,12 @@ mod tests { finder.read_with(cx, |f, _| { let delegate = f.delegate(); - assert_eq!(delegate.matches[0].path.as_ref(), Path::new("dir2/a.txt")); - assert_eq!(delegate.matches[1].path.as_ref(), Path::new("dir1/a.txt")); + let matches = match &delegate.matches { + Matches::Search(path_matches) => path_matches, + _ => panic!("Search matches expected"), + }; + assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt")); + assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt")); }); } @@ -1012,10 +1221,13 @@ mod tests { .await; assert_eq!( history_after_first, - vec![ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/first.rs")), - }], + vec![FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }, + Some(PathBuf::from("/src/test/first.rs")) + )], "Should show 1st opened item in the history when opening the 2nd item" ); @@ -1032,14 +1244,20 @@ mod tests { assert_eq!( history_after_second, vec![ - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/second.rs")), - }, - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/first.rs")), - }, + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/second.rs")), + }, + Some(PathBuf::from("/src/test/second.rs")) + ), + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }, + Some(PathBuf::from("/src/test/first.rs")) + ), ], "Should show 1st and 2nd opened items in the history when opening the 3rd item. \ 2nd item should be the first in the history, as the last opened." @@ -1058,18 +1276,27 @@ mod tests { assert_eq!( history_after_third, vec![ - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/third.rs")), - }, - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/second.rs")), - }, - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/first.rs")), - }, + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/third.rs")), + }, + Some(PathBuf::from("/src/test/third.rs")) + ), + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/second.rs")), + }, + Some(PathBuf::from("/src/test/second.rs")) + ), + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }, + Some(PathBuf::from("/src/test/first.rs")) + ), ], "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \ 3rd item should be the first in the history, as the last opened." @@ -1088,24 +1315,162 @@ mod tests { assert_eq!( history_after_second_again, vec![ - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/second.rs")), - }, - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/third.rs")), - }, - ProjectPath { - worktree_id, - path: Arc::from(Path::new("test/first.rs")), - }, + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/second.rs")), + }, + Some(PathBuf::from("/src/test/second.rs")) + ), + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/third.rs")), + }, + Some(PathBuf::from("/src/test/third.rs")) + ), + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }, + Some(PathBuf::from("/src/test/first.rs")) + ), ], "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \ 2nd item, as the last opened, 3rd item should go next as it was opened right before." ); } + #[gpui::test] + async fn test_external_files_history( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + } + }), + ) + .await; + + app_state + .fs + .as_fake() + .insert_tree( + "/external-src", + json!({ + "test": { + "third.rs": "// Third Rust file", + "fourth.rs": "// Fourth Rust file", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + cx.update(|cx| { + project.update(cx, |project, cx| { + project.find_or_create_local_worktree("/external-src", false, cx) + }) + }) + .detach(); + deterministic.run_until_parked(); + + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + let worktree_id = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1,); + + WorktreeId::from_usize(worktrees[0].id()) + }); + workspace + .update(cx, |workspace, cx| { + workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx) + }) + .detach(); + deterministic.run_until_parked(); + let external_worktree_id = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + assert_eq!( + worktrees.len(), + 2, + "External file should get opened in a new worktree" + ); + + WorktreeId::from_usize( + worktrees + .into_iter() + .find(|worktree| worktree.id() != worktree_id.to_usize()) + .expect("New worktree should have a different id") + .id(), + ) + }); + close_active_item(&workspace, &deterministic, cx).await; + + let initial_history_items = open_close_queried_buffer( + "sec", + 1, + "second.rs", + window_id, + &workspace, + &deterministic, + cx, + ) + .await; + assert_eq!( + initial_history_items, + vec![FoundPath::new( + ProjectPath { + worktree_id: external_worktree_id, + path: Arc::from(Path::new("")), + }, + Some(PathBuf::from("/external-src/test/third.rs")) + )], + "Should show external file with its full path in the history after it was open" + ); + + let updated_history_items = open_close_queried_buffer( + "fir", + 1, + "first.rs", + window_id, + &workspace, + &deterministic, + cx, + ) + .await; + assert_eq!( + updated_history_items, + vec![ + FoundPath::new( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/second.rs")), + }, + Some(PathBuf::from("/src/test/second.rs")) + ), + FoundPath::new( + ProjectPath { + worktree_id: external_worktree_id, + path: Arc::from(Path::new("")), + }, + Some(PathBuf::from("/external-src/test/third.rs")) + ), + ], + "Should keep external file with history updates", + ); + } + async fn open_close_queried_buffer( input: &str, expected_matches: usize, @@ -1114,7 +1479,7 @@ mod tests { workspace: &ViewHandle, deterministic: &gpui::executor::Deterministic, cx: &mut gpui::TestAppContext, - ) -> Vec { + ) -> Vec { cx.dispatch_action(window_id, Toggle); let finder = cx.read(|cx| workspace.read(cx).modal::().unwrap()); finder @@ -1152,6 +1517,16 @@ mod tests { ); }); + close_active_item(workspace, deterministic, cx).await; + + history_items + } + + async fn close_active_item( + workspace: &ViewHandle, + deterministic: &gpui::executor::Deterministic, + cx: &mut TestAppContext, + ) { let mut original_items = HashMap::new(); cx.read(|cx| { for pane in workspace.read(cx).panes() { @@ -1161,6 +1536,8 @@ mod tests { assert!(insertion_result.is_none(), "Pane id {pane_id} collision"); } }); + + let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); active_pane .update(cx, |pane, cx| { pane.close_active_item(&workspace::CloseActiveItem, cx) @@ -1185,8 +1562,10 @@ mod tests { } } }); - - history_items + assert!( + original_items.len() <= 1, + "At most one panel should got closed" + ); } fn init_test(cx: &mut TestAppContext) -> Arc { @@ -1215,4 +1594,11 @@ mod tests { }) .unwrap() } + + fn dummy_found_path(project_path: ProjectPath) -> FoundPath { + FoundPath { + project: project_path, + absolute: None, + } + } } diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 028656a027..a06e0d5fdb 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -960,7 +960,7 @@ impl Task> { pub fn detach_and_log_err(self, cx: &mut AppContext) { cx.spawn(|_| async move { if let Err(err) = self.await { - log::error!("{}", err); + log::error!("{:#}", err); } }) .detach(); diff --git a/crates/gpui/src/font_cache.rs b/crates/gpui/src/font_cache.rs index 4388ad4bcb..57dad48e34 100644 --- a/crates/gpui/src/font_cache.rs +++ b/crates/gpui/src/font_cache.rs @@ -25,8 +25,9 @@ struct Family { pub struct FontCache(RwLock); pub struct FontCacheState { - fonts: Arc, + font_system: Arc, families: Vec, + default_family: Option, font_selections: HashMap>, metrics: HashMap, wrapper_pool: HashMap<(FontId, OrderedFloat), Vec>, @@ -42,8 +43,9 @@ unsafe impl Send for FontCache {} impl FontCache { pub fn new(fonts: Arc) -> Self { Self(RwLock::new(FontCacheState { - fonts, + font_system: fonts, families: Default::default(), + default_family: None, font_selections: Default::default(), metrics: Default::default(), wrapper_pool: Default::default(), @@ -73,14 +75,14 @@ impl FontCache { let mut state = RwLockUpgradableReadGuard::upgrade(state); - if let Ok(font_ids) = state.fonts.load_family(name, features) { + if let Ok(font_ids) = state.font_system.load_family(name, features) { if font_ids.is_empty() { continue; } let family_id = FamilyId(state.families.len()); for font_id in &font_ids { - if state.fonts.glyph_for_char(*font_id, 'm').is_none() { + if state.font_system.glyph_for_char(*font_id, 'm').is_none() { return Err(anyhow!("font must contain a glyph for the 'm' character")); } } @@ -99,6 +101,31 @@ impl FontCache { )) } + /// Returns an arbitrary font family that is available on the system. + pub fn known_existing_family(&self) -> FamilyId { + if let Some(family_id) = self.0.read().default_family { + return family_id; + } + + let default_family = self + .load_family( + &["Courier", "Helvetica", "Arial", "Verdana"], + &Default::default(), + ) + .unwrap_or_else(|_| { + let all_family_names = self.0.read().font_system.all_families(); + let all_family_names: Vec<_> = all_family_names + .iter() + .map(|string| string.as_str()) + .collect(); + self.load_family(&all_family_names, &Default::default()) + .expect("could not load any default font family") + }); + + self.0.write().default_family = Some(default_family); + default_family + } + pub fn default_font(&self, family_id: FamilyId) -> FontId { self.select_font(family_id, &Properties::default()).unwrap() } @@ -115,7 +142,7 @@ impl FontCache { let mut inner = RwLockUpgradableReadGuard::upgrade(inner); let family = &inner.families[family_id.0]; let font_id = inner - .fonts + .font_system .select_font(&family.font_ids, properties) .unwrap_or(family.font_ids[0]); @@ -137,7 +164,7 @@ impl FontCache { if let Some(metrics) = state.metrics.get(&font_id) { f(metrics) } else { - let metrics = state.fonts.font_metrics(font_id); + let metrics = state.font_system.font_metrics(font_id); let metric = f(&metrics); let mut state = RwLockUpgradableReadGuard::upgrade(state); state.metrics.insert(font_id, metrics); @@ -157,8 +184,11 @@ impl FontCache { let bounds; { let state = self.0.read(); - glyph_id = state.fonts.glyph_for_char(font_id, 'm').unwrap(); - bounds = state.fonts.typographic_bounds(font_id, glyph_id).unwrap(); + glyph_id = state.font_system.glyph_for_char(font_id, 'm').unwrap(); + bounds = state + .font_system + .typographic_bounds(font_id, glyph_id) + .unwrap(); } bounds.width() * self.em_scale(font_id, font_size) } @@ -168,8 +198,8 @@ impl FontCache { let advance; { let state = self.0.read(); - glyph_id = state.fonts.glyph_for_char(font_id, 'm').unwrap(); - advance = state.fonts.advance(font_id, glyph_id).unwrap(); + glyph_id = state.font_system.glyph_for_char(font_id, 'm').unwrap(); + advance = state.font_system.advance(font_id, glyph_id).unwrap(); } advance.x() * self.em_scale(font_id, font_size) } @@ -214,7 +244,7 @@ impl FontCache { .or_default(); let wrapper = wrappers .pop() - .unwrap_or_else(|| LineWrapper::new(font_id, font_size, state.fonts.clone())); + .unwrap_or_else(|| LineWrapper::new(font_id, font_size, state.font_system.clone())); LineWrapperHandle { wrapper: Some(wrapper), font_cache: self.clone(), diff --git a/crates/gpui/src/fonts.rs b/crates/gpui/src/fonts.rs index e0f037acb8..5e77593d05 100644 --- a/crates/gpui/src/fonts.rs +++ b/crates/gpui/src/fonts.rs @@ -295,13 +295,14 @@ impl Default for TextStyle { .as_ref() .expect("TextStyle::default can only be called within a call to with_font_cache"); - let font_family_name = Arc::from("Courier"); - let font_family_id = font_cache - .load_family(&[&font_family_name], &Default::default()) - .unwrap(); + let font_family_id = font_cache.known_existing_family(); let font_id = font_cache .select_font(font_family_id, &Default::default()) - .unwrap(); + .expect("did not have any font in system-provided family"); + let font_family_name = font_cache + .family_name(font_family_id) + .expect("we loaded this family from the font cache, so this should work"); + Self { color: Default::default(), font_family_name, diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 9b4fd7ca51..7fc02b0548 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -343,6 +343,7 @@ pub enum RasterizationOptions { pub trait FontSystem: Send + Sync { fn add_fonts(&self, fonts: &[Arc>]) -> anyhow::Result<()>; + fn all_families(&self) -> Vec; fn load_family(&self, name: &str, features: &FontFeatures) -> anyhow::Result>; fn select_font( &self, diff --git a/crates/gpui/src/platform/mac/fonts.rs b/crates/gpui/src/platform/mac/fonts.rs index 96916698fc..0414cb8a6c 100644 --- a/crates/gpui/src/platform/mac/fonts.rs +++ b/crates/gpui/src/platform/mac/fonts.rs @@ -66,6 +66,14 @@ impl platform::FontSystem for FontSystem { self.0.write().add_fonts(fonts) } + fn all_families(&self) -> Vec { + self.0 + .read() + .system_source + .all_families() + .expect("core text should never return an error") + } + fn load_family(&self, name: &str, features: &Features) -> anyhow::Result> { self.0.write().load_family(name, features) } diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 7e81620e5c..9722b618f3 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -70,7 +70,7 @@ settings = { path = "../settings", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } ctor.workspace = true env_logger.workspace = true -indoc = "1.0.4" +indoc.workspace = true rand.workspace = true tree-sitter-embedded-template = "*" tree-sitter-html = "*" diff --git a/crates/live_kit_client/Cargo.toml b/crates/live_kit_client/Cargo.toml index 2d61e75732..36087a42a3 100644 --- a/crates/live_kit_client/Cargo.toml +++ b/crates/live_kit_client/Cargo.toml @@ -46,6 +46,7 @@ collections = { path = "../collections", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } live_kit_server = { path = "../live_kit_server" } media = { path = "../media" } +nanoid = "0.4" anyhow.workspace = true async-trait.workspace = true diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index dd53c30d14..fdd54bf3d8 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3089,7 +3089,7 @@ impl Project { ) -> Result<(), anyhow::Error> { let (worktree, relative_path) = self .find_local_worktree(&abs_path, cx) - .ok_or_else(|| anyhow!("no worktree found for diagnostics"))?; + .ok_or_else(|| anyhow!("no worktree found for diagnostics path {abs_path:?}"))?; let project_path = ProjectPath { worktree_id: worktree.read(cx).id(), diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 915957401d..c79ee496c5 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -44,11 +44,11 @@ struct ActiveSearches(HashMap, WeakViewHandle(SearchOption::CaseSensitive, cx); @@ -717,6 +717,23 @@ impl ProjectSearchView { pub fn has_matches(&self) -> bool { self.active_match_index.is_some() } + + fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |search_view, cx| { + if !search_view.results_editor.is_focused(cx) + && !search_view.model.read(cx).match_ranges.is_empty() + { + return search_view.focus_results_editor(cx); + } + }); + } + + cx.propagate_action(); + } } impl Default for ProjectSearchBar { @@ -794,23 +811,6 @@ impl ProjectSearchBar { } } - fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext) { - if let Some(search_view) = pane - .active_item() - .and_then(|item| item.downcast::()) - { - search_view.update(cx, |search_view, cx| { - if search_view.query_editor.is_focused(cx) - && !search_view.model.read(cx).match_ranges.is_empty() - { - search_view.focus_results_editor(cx); - } - }); - } else { - cx.propagate_action(); - } - } - fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext) { self.cycle_field(Direction::Next, cx); } @@ -1257,7 +1257,182 @@ pub mod tests { }); } + #[gpui::test] + async fn test_project_search_focus(deterministic: Arc, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + "three.rs": "const THREE: usize = one::ONE + two::TWO;", + "four.rs": "const FOUR: usize = one::ONE + three::THREE;", + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + + let active_item = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }); + assert!( + active_item.is_none(), + "Expected no search panel to be active, but got: {active_item:?}" + ); + + workspace.update(cx, |workspace, cx| { + ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx) + }); + + let Some(search_view) = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }) else { + panic!("Search view expected to appear after new search event trigger") + }; + let search_view_id = search_view.id(); + + cx.spawn( + |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) }, + ) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.query_editor.is_focused(cx), + "Empty search view should be focused after the toggle focus event: no results panel to focus on", + ); + }); + + search_view.update(cx, |search_view, cx| { + let query_editor = &search_view.query_editor; + assert!( + query_editor.is_focused(cx), + "Search view should be focused after the new search view is activated", + ); + let query_text = query_editor.read(cx).text(cx); + assert!( + query_text.is_empty(), + "New search query should be empty but got '{query_text}'", + ); + let results_text = search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)); + assert!( + results_text.is_empty(), + "Empty search view should have no results but got '{results_text}'" + ); + }); + + search_view.update(cx, |search_view, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx) + }); + search_view.search(cx); + }); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + let results_text = search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)); + assert!( + results_text.is_empty(), + "Search view for mismatching query should have no results but got '{results_text}'" + ); + assert!( + search_view.query_editor.is_focused(cx), + "Search view should be focused after mismatching query had been used in search", + ); + }); + cx.spawn( + |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) }, + ) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.query_editor.is_focused(cx), + "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on", + ); + }); + + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx)); + search_view.search(cx); + }); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;", + "Search view results should match the query" + ); + assert!( + search_view.results_editor.is_focused(cx), + "Search view with mismatching query should be focused after search results are available", + ); + }); + cx.spawn( + |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) }, + ) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.results_editor.is_focused(cx), + "Search view with matching query should still have its results editor focused after the toggle focus event", + ); + }); + + workspace.update(cx, |workspace, cx| { + ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx) + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "two", "Query should be updated to first search result after search view 2nd open in a row"); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;", + "Results should be unchanged after search view 2nd open in a row" + ); + assert!( + search_view.query_editor.is_focused(cx), + "Focus should be moved into query editor again after search view 2nd open in a row" + ); + }); + + cx.spawn( + |mut cx| async move { cx.dispatch_action(window_id, search_view_id, &ToggleFocus) }, + ) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.results_editor.is_focused(cx), + "Search view with matching query should switch focus to the results editor after the toggle focus event", + ); + }); + } + pub fn init_test(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); let fonts = cx.font_cache(); let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default); theme.search.match_background = Color::red(); @@ -1275,8 +1450,10 @@ pub mod tests { language::init(cx); client::init_settings(cx); - editor::init_settings(cx); + editor::init(cx); workspace::init_settings(cx); + Project::init_settings(cx); + super::init(cx); }); } } diff --git a/crates/settings/src/settings_file.rs b/crates/settings/src/settings_file.rs index cca2909da2..1a5ab2f922 100644 --- a/crates/settings/src/settings_file.rs +++ b/crates/settings/src/settings_file.rs @@ -55,15 +55,22 @@ pub fn watch_config_file( .spawn(async move { let events = fs.watch(&path, Duration::from_millis(100)).await; futures::pin_mut!(events); + + let contents = fs.load(&path).await.unwrap_or_default(); + if tx.unbounded_send(contents).is_err() { + return; + } + loop { + if events.next().await.is_none() { + break; + } + if let Ok(contents) = fs.load(&path).await { if !tx.unbounded_send(contents).is_ok() { break; } } - if events.next().await.is_none() { - break; - } } }) .detach(); diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 71b3cc635f..8a71c7e605 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{anyhow, Result}; use collections::{btree_map, hash_map, BTreeMap, HashMap}; use gpui::AppContext; use lazy_static::lazy_static; @@ -84,15 +84,26 @@ pub struct SettingsJsonSchemaParams<'a> { } /// A set of strongly-typed setting values defined via multiple JSON files. -#[derive(Default)] pub struct SettingsStore { setting_values: HashMap>, - default_deserialized_settings: Option, - user_deserialized_settings: Option, + default_deserialized_settings: serde_json::Value, + user_deserialized_settings: serde_json::Value, local_deserialized_settings: BTreeMap, serde_json::Value>, tab_size_callback: Option<(TypeId, Box Option>)>, } +impl Default for SettingsStore { + fn default() -> Self { + SettingsStore { + setting_values: Default::default(), + default_deserialized_settings: serde_json::json!({}), + user_deserialized_settings: serde_json::json!({}), + local_deserialized_settings: Default::default(), + tab_size_callback: Default::default(), + } + } +} + #[derive(Debug)] struct SettingValue { global_value: Option, @@ -136,27 +147,24 @@ impl SettingsStore { local_values: Vec::new(), })); - if let Some(default_settings) = &self.default_deserialized_settings { - if let Some(default_settings) = setting_value - .deserialize_setting(default_settings) + if let Some(default_settings) = setting_value + .deserialize_setting(&self.default_deserialized_settings) + .log_err() + { + let mut user_values_stack = Vec::new(); + + if let Some(user_settings) = setting_value + .deserialize_setting(&self.user_deserialized_settings) .log_err() { - let mut user_values_stack = Vec::new(); + user_values_stack = vec![user_settings]; + } - if let Some(user_settings) = &self.user_deserialized_settings { - if let Some(user_settings) = - setting_value.deserialize_setting(user_settings).log_err() - { - user_values_stack = vec![user_settings]; - } - } - - if let Some(setting) = setting_value - .load_setting(&default_settings, &user_values_stack, cx) - .log_err() - { - setting_value.set_global_value(setting); - } + if let Some(setting) = setting_value + .load_setting(&default_settings, &user_values_stack, cx) + .log_err() + { + setting_value.set_global_value(setting); } } } @@ -189,9 +197,7 @@ impl SettingsStore { /// This is only for debugging and reporting. For user-facing functionality, /// use the typed setting interface. pub fn untyped_user_settings(&self) -> &serde_json::Value { - self.user_deserialized_settings - .as_ref() - .unwrap_or(&serde_json::Value::Null) + &self.user_deserialized_settings } #[cfg(any(test, feature = "test-support"))] @@ -213,11 +219,7 @@ impl SettingsStore { cx: &AppContext, update: impl FnOnce(&mut T::FileContent), ) { - if self.user_deserialized_settings.is_none() { - self.set_user_settings("{}", cx).unwrap(); - } - let old_text = - serde_json::to_string(self.user_deserialized_settings.as_ref().unwrap()).unwrap(); + let old_text = serde_json::to_string(&self.user_deserialized_settings).unwrap(); let new_text = self.new_text_for_update::(old_text, update); self.set_user_settings(&new_text, cx).unwrap(); } @@ -250,11 +252,7 @@ impl SettingsStore { .setting_values .get(&setting_type_id) .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::())) - .deserialize_setting( - self.user_deserialized_settings - .as_ref() - .expect("no user settings loaded"), - ) + .deserialize_setting(&self.user_deserialized_settings) .unwrap_or_else(|e| { panic!( "could not deserialize setting type {} from user settings: {}", @@ -323,10 +321,14 @@ impl SettingsStore { default_settings_content: &str, cx: &AppContext, ) -> Result<()> { - self.default_deserialized_settings = - Some(parse_json_with_comments(default_settings_content)?); - self.recompute_values(None, cx)?; - Ok(()) + let settings: serde_json::Value = parse_json_with_comments(default_settings_content)?; + if settings.is_object() { + self.default_deserialized_settings = settings; + self.recompute_values(None, cx)?; + Ok(()) + } else { + Err(anyhow!("settings must be an object")) + } } /// Set the user settings via a JSON string. @@ -335,9 +337,14 @@ impl SettingsStore { user_settings_content: &str, cx: &AppContext, ) -> Result<()> { - self.user_deserialized_settings = Some(parse_json_with_comments(user_settings_content)?); - self.recompute_values(None, cx)?; - Ok(()) + let settings: serde_json::Value = parse_json_with_comments(user_settings_content)?; + if settings.is_object() { + self.user_deserialized_settings = settings; + self.recompute_values(None, cx)?; + Ok(()) + } else { + Err(anyhow!("settings must be an object")) + } } /// Add or remove a set of local settings via a JSON string. @@ -443,65 +450,63 @@ impl SettingsStore { let mut user_settings_stack = Vec::::new(); let mut paths_stack = Vec::>::new(); for setting_value in self.setting_values.values_mut() { - if let Some(default_settings) = &self.default_deserialized_settings { - let default_settings = setting_value.deserialize_setting(default_settings)?; + let default_settings = + setting_value.deserialize_setting(&self.default_deserialized_settings)?; - user_settings_stack.clear(); - paths_stack.clear(); + user_settings_stack.clear(); + paths_stack.clear(); - if let Some(user_settings) = &self.user_deserialized_settings { - if let Some(user_settings) = - setting_value.deserialize_setting(user_settings).log_err() - { - user_settings_stack.push(user_settings); - paths_stack.push(None); + if let Some(user_settings) = setting_value + .deserialize_setting(&self.user_deserialized_settings) + .log_err() + { + user_settings_stack.push(user_settings); + paths_stack.push(None); + } + + // If the global settings file changed, reload the global value for the field. + if changed_local_path.is_none() { + if let Some(value) = setting_value + .load_setting(&default_settings, &user_settings_stack, cx) + .log_err() + { + setting_value.set_global_value(value); + } + } + + // Reload the local values for the setting. + for (path, local_settings) in &self.local_deserialized_settings { + // Build a stack of all of the local values for that setting. + while let Some(prev_path) = paths_stack.last() { + if let Some(prev_path) = prev_path { + if !path.starts_with(prev_path) { + paths_stack.pop(); + user_settings_stack.pop(); + continue; + } } + break; } - // If the global settings file changed, reload the global value for the field. - if changed_local_path.is_none() { + if let Some(local_settings) = + setting_value.deserialize_setting(&local_settings).log_err() + { + paths_stack.push(Some(path.as_ref())); + user_settings_stack.push(local_settings); + + // If a local settings file changed, then avoid recomputing local + // settings for any path outside of that directory. + if changed_local_path.map_or(false, |changed_local_path| { + !path.starts_with(changed_local_path) + }) { + continue; + } + if let Some(value) = setting_value .load_setting(&default_settings, &user_settings_stack, cx) .log_err() { - setting_value.set_global_value(value); - } - } - - // Reload the local values for the setting. - for (path, local_settings) in &self.local_deserialized_settings { - // Build a stack of all of the local values for that setting. - while let Some(prev_path) = paths_stack.last() { - if let Some(prev_path) = prev_path { - if !path.starts_with(prev_path) { - paths_stack.pop(); - user_settings_stack.pop(); - continue; - } - } - break; - } - - if let Some(local_settings) = - setting_value.deserialize_setting(&local_settings).log_err() - { - paths_stack.push(Some(path.as_ref())); - user_settings_stack.push(local_settings); - - // If a local settings file changed, then avoid recomputing local - // settings for any path outside of that directory. - if changed_local_path.map_or(false, |changed_local_path| { - !path.starts_with(changed_local_path) - }) { - continue; - } - - if let Some(value) = setting_value - .load_setting(&default_settings, &user_settings_stack, cx) - .log_err() - { - setting_value.set_local_value(path.clone(), value); - } + setting_value.set_local_value(path.clone(), value); } } } diff --git a/crates/sqlez/Cargo.toml b/crates/sqlez/Cargo.toml index 7371a7863a..01d17d4812 100644 --- a/crates/sqlez/Cargo.toml +++ b/crates/sqlez/Cargo.toml @@ -6,7 +6,7 @@ publish = false [dependencies] anyhow.workspace = true -indoc = "1.0.7" +indoc.workspace = true libsqlite3-sys = { version = "0.24", features = ["bundled"] } smol.workspace = true thread_local = "1.1.4" diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index a42d6c550e..6fa920d739 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -14,6 +14,7 @@ editor = { path = "../editor" } language = { path = "../language" } gpui = { path = "../gpui" } project = { path = "../project" } +search = { path = "../search" } settings = { path = "../settings" } theme = { path = "../theme" } util = { path = "../util" } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 791a8b21c5..b3af42ee3a 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -22,7 +22,7 @@ const TERMINAL_PANEL_KEY: &'static str = "TerminalPanel"; actions!(terminal_panel, [ToggleFocus]); pub fn init(cx: &mut AppContext) { - cx.add_action(TerminalPanel::add_terminal); + cx.add_action(TerminalPanel::new_terminal); } pub enum Event { @@ -55,6 +55,7 @@ impl TerminalPanel { cx, ); pane.set_can_split(false, cx); + pane.set_can_navigate(false, cx); pane.on_can_drop(move |drag_and_drop, cx| { drag_and_drop .currently_dragged::(window_id) @@ -68,6 +69,7 @@ impl TerminalPanel { .with_child(Pane::render_tab_bar_button( 0, "icons/plus_12.svg", + false, Some(( "New Terminal".into(), Some(Box::new(workspace::NewTerminal)), @@ -78,7 +80,7 @@ impl TerminalPanel { cx.window_context().defer(move |cx| { if let Some(this) = this.upgrade(cx) { this.update(cx, |this, cx| { - this.add_terminal(&Default::default(), cx); + this.add_terminal(cx); }); } }) @@ -92,6 +94,7 @@ impl TerminalPanel { } else { "icons/maximize_8.svg" }, + pane.is_zoomed(), Some(("Toggle Zoom".into(), Some(Box::new(workspace::ToggleZoom)))), cx, move |pane, cx| pane.toggle_zoom(&Default::default(), cx), @@ -99,6 +102,9 @@ impl TerminalPanel { )) .into_any() }); + let buffer_search_bar = cx.add_view(search::BufferSearchBar::new); + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(buffer_search_bar, cx)); pane }); let subscriptions = vec![ @@ -216,7 +222,19 @@ impl TerminalPanel { } } - fn add_terminal(&mut self, _: &workspace::NewTerminal, cx: &mut ViewContext) { + fn new_terminal( + workspace: &mut Workspace, + _: &workspace::NewTerminal, + cx: &mut ViewContext, + ) { + let Some(this) = workspace.focus_panel::(cx) else { + return; + }; + + this.update(cx, |this, cx| this.add_terminal(cx)) + } + + fn add_terminal(&mut self, cx: &mut ViewContext) { let workspace = self.workspace.clone(); cx.spawn(|this, mut cx| async move { let pane = this.read_with(&cx, |this, _| this.pane.clone())?; @@ -357,7 +375,7 @@ impl Panel for TerminalPanel { fn set_active(&mut self, active: bool, cx: &mut ViewContext) { if active && self.pane.read(cx).items_len() == 0 { - self.add_terminal(&Default::default(), cx) + self.add_terminal(cx) } } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 767e3bf4db..7f43f99ebd 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -38,7 +38,7 @@ use workspace::{ notifications::NotifyResultExt, pane, register_deserializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, - Pane, ToolbarItemLocation, Workspace, WorkspaceId, + NewCenterTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, }; pub use terminal::TerminalSettings; @@ -66,10 +66,10 @@ pub fn init(cx: &mut AppContext) { terminal_panel::init(cx); terminal::init(cx); - cx.add_action(TerminalView::deploy); - register_deserializable_item::(cx); + cx.add_action(TerminalView::deploy); + //Useful terminal views cx.add_action(TerminalView::send_text); cx.add_action(TerminalView::send_keystroke); @@ -101,7 +101,7 @@ impl TerminalView { ///Create a new Terminal in the current working directory or the user's home directory pub fn deploy( workspace: &mut Workspace, - _: &workspace::NewTerminal, + _: &NewCenterTerminal, cx: &mut ViewContext, ) { let strategy = settings::get::(cx); diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index b1c9e9c215..21c01150a8 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -89,7 +89,8 @@ pub struct Workspace { pub breadcrumbs: Interactive, pub disconnected_overlay: ContainedText, pub modal: ContainerStyle, - pub zoomed_foreground: ContainerStyle, + pub zoomed_panel_foreground: ContainerStyle, + pub zoomed_pane_foreground: ContainerStyle, pub zoomed_background: ContainerStyle, pub notification: ContainerStyle, pub notifications: Notifications, diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 4ec8f7553c..6216d2e472 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -17,7 +17,7 @@ backtrace = "0.3" log.workspace = true lazy_static.workspace = true futures.workspace = true -isahc = "1.7" +isahc.workspace = true smol.workspace = true url = "2.2" rand.workspace = true diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index c34a5b469b..ee3144fd56 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -35,7 +35,7 @@ settings = { path = "../settings" } workspace = { path = "../workspace" } [dev-dependencies] -indoc = "1.0.4" +indoc.workspace = true parking_lot.workspace = true lazy_static.workspace = true diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index cef6f53a6e..b7460c4c46 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -32,7 +32,7 @@ pub fn init(cx: &mut AppContext) { pub fn show_welcome_experience(app_state: &Arc, cx: &mut AppContext) { open_new(&app_state, cx, |workspace, cx| { - workspace.toggle_dock(DockPosition::Left, false, cx); + workspace.toggle_dock(DockPosition::Left, cx); let welcome_page = cx.add_view(|cx| WelcomePage::new(workspace, cx)); workspace.add_item_to_center(Box::new(welcome_page.clone()), cx); cx.focus(&welcome_page); diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 33e5e7aefe..b22607e20d 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -62,5 +62,5 @@ settings = { path = "../settings", features = ["test-support"] } fs = { path = "../fs", features = ["test-support"] } db = { path = "../db", features = ["test-support"] } -indoc = "1.0.4" +indoc.workspace = true env_logger.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 73d4f79399..a4b71fa5a7 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -175,12 +175,16 @@ impl Dock { } } + pub fn position(&self) -> DockPosition { + self.position + } + pub fn is_open(&self) -> bool { self.is_open } pub fn has_focus(&self, cx: &WindowContext) -> bool { - self.active_panel() + self.visible_panel() .map_or(false, |panel| panel.has_focus(cx)) } @@ -201,7 +205,7 @@ impl Dock { self.active_panel_index } - pub fn set_open(&mut self, open: bool, cx: &mut ViewContext) { + pub(crate) fn set_open(&mut self, open: bool, cx: &mut ViewContext) { if open != self.is_open { self.is_open = open; if let Some(active_panel) = self.panel_entries.get(self.active_panel_index) { @@ -212,11 +216,6 @@ impl Dock { } } - pub fn toggle_open(&mut self, cx: &mut ViewContext) { - self.set_open(!self.is_open, cx); - cx.notify(); - } - pub fn set_panel_zoomed( &mut self, panel: &AnyViewHandle, @@ -259,7 +258,7 @@ impl Dock { cx.focus(&panel); } } else if T::should_close_on_event(event) - && this.active_panel().map_or(false, |p| p.id() == panel.id()) + && this.visible_panel().map_or(false, |p| p.id() == panel.id()) { this.set_open(false, cx); } @@ -315,12 +314,16 @@ impl Dock { } } - pub fn active_panel(&self) -> Option<&Rc> { - let entry = self.active_entry()?; + pub fn visible_panel(&self) -> Option<&Rc> { + let entry = self.visible_entry()?; Some(&entry.panel) } - fn active_entry(&self) -> Option<&PanelEntry> { + pub fn active_panel(&self) -> Option<&Rc> { + Some(&self.panel_entries.get(self.active_panel_index)?.panel) + } + + fn visible_entry(&self) -> Option<&PanelEntry> { if self.is_open { self.panel_entries.get(self.active_panel_index) } else { @@ -329,7 +332,7 @@ impl Dock { } pub fn zoomed_panel(&self, cx: &WindowContext) -> Option> { - let entry = self.active_entry()?; + let entry = self.visible_entry()?; if entry.panel.is_zoomed(cx) { Some(entry.panel.clone()) } else { @@ -362,7 +365,7 @@ impl Dock { } pub fn render_placeholder(&self, cx: &WindowContext) -> AnyElement { - if let Some(active_entry) = self.active_entry() { + if let Some(active_entry) = self.visible_entry() { Empty::new() .into_any() .contained() @@ -399,7 +402,7 @@ impl View for Dock { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - if let Some(active_entry) = self.active_entry() { + if let Some(active_entry) = self.visible_entry() { let style = self.style(cx); ChildView::new(active_entry.panel.as_any(), cx) .contained() @@ -417,7 +420,7 @@ impl View for Dock { fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { - if let Some(active_entry) = self.active_entry() { + if let Some(active_entry) = self.visible_entry() { cx.focus(active_entry.panel.as_any()); } else { cx.focus_parent(); @@ -473,11 +476,22 @@ impl View for PanelButtons { Flex::row() .with_children(panels.into_iter().enumerate().map( |(panel_ix, (view, context_menu))| { - let (tooltip, tooltip_action) = view.icon_tooltip(cx); + let is_active = is_open && panel_ix == active_ix; + let (tooltip, tooltip_action) = if is_active { + ( + format!("Close {} dock", dock_position.to_label()), + Some(match dock_position { + DockPosition::Left => crate::ToggleLeftDock.boxed_clone(), + DockPosition::Bottom => crate::ToggleBottomDock.boxed_clone(), + DockPosition::Right => crate::ToggleRightDock.boxed_clone(), + }), + ) + } else { + view.icon_tooltip(cx) + }; Stack::new() .with_child( MouseEventHandler::::new(panel_ix, cx, |state, cx| { - let is_active = is_open && panel_ix == active_ix; let style = button_style.style_for(state, is_active); Flex::row() .with_child( @@ -504,13 +518,22 @@ impl View for PanelButtons { }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, { + let tooltip_action = + tooltip_action.as_ref().map(|action| action.boxed_clone()); move |_, this, cx| { - if let Some(workspace) = this.workspace.upgrade(cx) { - cx.window_context().defer(move |cx| { - workspace.update(cx, |workspace, cx| { - workspace.toggle_panel(dock_position, panel_ix, cx) - }); - }); + if let Some(tooltip_action) = &tooltip_action { + let window_id = cx.window_id(); + let view_id = this.workspace.id(); + let tooltip_action = tooltip_action.boxed_clone(); + cx.spawn(|_, mut cx| async move { + cx.dispatch_action( + window_id, + view_id, + &*tooltip_action, + ) + .ok(); + }) + .detach(); } } }) diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 21b3be09d0..1e3c6044a1 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -1,5 +1,5 @@ use crate::{Toast, Workspace}; -use collections::HashSet; +use collections::HashMap; use gpui::{AnyViewHandle, AppContext, Entity, View, ViewContext, ViewHandle}; use std::{any::TypeId, ops::DerefMut}; @@ -33,12 +33,12 @@ impl From<&dyn NotificationHandle> for AnyViewHandle { } } -struct NotificationTracker { - notifications_sent: HashSet, +pub(crate) struct NotificationTracker { + notifications_sent: HashMap>, } impl std::ops::Deref for NotificationTracker { - type Target = HashSet; + type Target = HashMap>; fn deref(&self) -> &Self::Target { &self.notifications_sent @@ -54,24 +54,33 @@ impl DerefMut for NotificationTracker { impl NotificationTracker { fn new() -> Self { Self { - notifications_sent: HashSet::default(), + notifications_sent: Default::default(), } } } impl Workspace { + pub fn has_shown_notification_once( + &self, + id: usize, + cx: &ViewContext, + ) -> bool { + cx.global::() + .get(&TypeId::of::()) + .map(|ids| ids.contains(&id)) + .unwrap_or(false) + } + pub fn show_notification_once( &mut self, id: usize, cx: &mut ViewContext, build_notification: impl FnOnce(&mut ViewContext) -> ViewHandle, ) { - if !cx - .global::() - .contains(&TypeId::of::()) - { + if !self.has_shown_notification_once::(id, cx) { cx.update_global::(|tracker, _| { - tracker.insert(TypeId::of::()) + let entry = tracker.entry(TypeId::of::()).or_default(); + entry.push(id); }); self.show_notification::(id, cx, build_notification) @@ -154,9 +163,10 @@ pub mod simple_message_notification { use gpui::{ actions, elements::{Flex, MouseEventHandler, Padding, ParentElement, Svg, Text}, + fonts::TextStyle, impl_actions, platform::{CursorStyle, MouseButton}, - AppContext, Element, Entity, View, ViewContext, + AnyElement, AppContext, Element, Entity, View, ViewContext, }; use menu::Cancel; use serde::Deserialize; @@ -184,8 +194,13 @@ pub mod simple_message_notification { ) } + enum NotificationMessage { + Text(Cow<'static, str>), + Element(fn(TextStyle, &AppContext) -> AnyElement), + } + pub struct MessageNotification { - message: Cow<'static, str>, + message: NotificationMessage, on_click: Option)>>, click_message: Option>, } @@ -204,7 +219,17 @@ pub mod simple_message_notification { S: Into>, { Self { - message: message.into(), + message: NotificationMessage::Text(message.into()), + on_click: None, + click_message: None, + } + } + + pub fn new_element( + message: fn(TextStyle, &AppContext) -> AnyElement, + ) -> MessageNotification { + Self { + message: NotificationMessage::Element(message), on_click: None, click_message: None, } @@ -243,84 +268,90 @@ pub mod simple_message_notification { enum MessageNotificationTag {} let click_message = self.click_message.clone(); - let message = self.message.clone(); + let message = match &self.message { + NotificationMessage::Text(text) => { + Text::new(text.to_owned(), theme.message.text.clone()).into_any() + } + NotificationMessage::Element(e) => e(theme.message.text.clone(), cx), + }; let on_click = self.on_click.clone(); let has_click_action = on_click.is_some(); - MouseEventHandler::::new(0, cx, |state, cx| { - Flex::column() - .with_child( - Flex::row() - .with_child( - Text::new(message, theme.message.text.clone()) - .contained() - .with_style(theme.message.container) - .aligned() - .top() - .left() - .flex(1., true), - ) - .with_child( - MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.dismiss_button.style_for(state, false); - Svg::new("icons/x_mark_8.svg") - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .contained() - .with_style(style.container) - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - }) - .with_padding(Padding::uniform(5.)) - .on_click(MouseButton::Left, move |_, this, cx| { - this.dismiss(&Default::default(), cx); - }) - .with_cursor_style(CursorStyle::PointingHand) - .aligned() - .constrained() - .with_height( - cx.font_cache().line_height(theme.message.text.font_size), - ) + Flex::column() + .with_child( + Flex::row() + .with_child( + message + .contained() + .with_style(theme.message.container) .aligned() .top() - .flex_float(), - ), - ) - .with_children({ - let style = theme.action_message.style_for(state, false); - if let Some(click_message) = click_message { - Some( - Flex::row().with_child( - Text::new(click_message, style.text.clone()) + .left() + .flex(1., true), + ) + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + let style = theme.dismiss_button.style_for(state, false); + Svg::new("icons/x_mark_8.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + }) + .with_padding(Padding::uniform(5.)) + .on_click(MouseButton::Left, move |_, this, cx| { + this.dismiss(&Default::default(), cx); + }) + .with_cursor_style(CursorStyle::PointingHand) + .aligned() + .constrained() + .with_height(cx.font_cache().line_height(theme.message.text.font_size)) + .aligned() + .top() + .flex_float(), + ), + ) + .with_children({ + click_message + .map(|click_message| { + MouseEventHandler::::new( + 0, + cx, + |state, _| { + let style = theme.action_message.style_for(state, false); + + Flex::row() + .with_child( + Text::new(click_message, style.text.clone()) + .contained() + .with_style(style.container), + ) .contained() - .with_style(style.container), - ), + }, ) - } else { - None - } + .on_click(MouseButton::Left, move |_, this, cx| { + if let Some(on_click) = on_click.as_ref() { + on_click(cx); + this.dismiss(&Default::default(), cx); + } + }) + // Since we're not using a proper overlay, we have to capture these extra events + .on_down(MouseButton::Left, |_, _, _| {}) + .on_up(MouseButton::Left, |_, _, _| {}) + .with_cursor_style(if has_click_action { + CursorStyle::PointingHand + } else { + CursorStyle::Arrow + }) + }) .into_iter() - }) - .contained() - }) - // 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 |_, this, cx| { - if let Some(on_click) = on_click.as_ref() { - on_click(cx); - this.dismiss(&Default::default(), cx); - } - }) - .with_cursor_style(if has_click_action { - CursorStyle::PointingHand - } else { - CursorStyle::Arrow - }) - .into_any() + }) + .into_any() } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 26f3d17453..82de5b39c1 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2,8 +2,8 @@ mod dragged_item_receiver; use super::{ItemHandle, SplitDirection}; use crate::{ - item::WeakItemHandle, toolbar::Toolbar, AutosaveSetting, Item, NewFile, NewSearch, NewTerminal, - ToggleZoom, Workspace, WorkspaceSettings, + item::WeakItemHandle, notify_of_new_dock, toolbar::Toolbar, AutosaveSetting, Item, + NewCenterTerminal, NewFile, NewSearch, ToggleZoom, Workspace, WorkspaceSettings, }; use anyhow::{anyhow, Result}; use collections::{HashMap, HashSet, VecDeque}; @@ -31,7 +31,7 @@ use std::{ any::Any, cell::RefCell, cmp, mem, - path::Path, + path::{Path, PathBuf}, rc::Rc, sync::{ atomic::{AtomicUsize, Ordering}, @@ -150,7 +150,6 @@ pub enum Event { pub struct Pane { items: Vec>, activation_history: Vec, - is_active: bool, zoomed: bool, active_item_index: usize, last_focused_view_by_item: HashMap, @@ -164,6 +163,7 @@ pub struct Pane { has_focus: bool, can_drop: Rc, &WindowContext) -> bool>, can_split: bool, + can_navigate: bool, render_tab_bar_buttons: Rc) -> AnyElement>, } @@ -179,7 +179,7 @@ struct NavHistory { backward_stack: VecDeque, forward_stack: VecDeque, closed_stack: VecDeque, - paths_by_item: HashMap, + paths_by_item: HashMap)>, pane: WeakViewHandle, next_timestamp: Arc, } @@ -254,7 +254,6 @@ impl Pane { Self { items: Vec::new(), activation_history: Vec::new(), - is_active: true, zoomed: false, active_item_index: 0, last_focused_view_by_item: Default::default(), @@ -279,12 +278,14 @@ impl Pane { has_focus: false, can_drop: Rc::new(|_, _| true), can_split: true, + can_navigate: true, render_tab_bar_buttons: Rc::new(|pane, cx| { Flex::row() // New menu .with_child(Self::render_tab_bar_button( 0, "icons/plus_12.svg", + false, Some(("New...".into(), None)), cx, |pane, cx| pane.deploy_new_menu(cx), @@ -294,6 +295,7 @@ impl Pane { .with_child(Self::render_tab_bar_button( 1, "icons/split_12.svg", + false, Some(("Split Pane".into(), None)), cx, |pane, cx| pane.deploy_split_menu(cx), @@ -307,6 +309,7 @@ impl Pane { } else { "icons/maximize_8.svg" }, + pane.is_zoomed(), Some(("Toggle Zoom".into(), Some(Box::new(ToggleZoom)))), cx, move |pane, cx| pane.toggle_zoom(&Default::default(), cx), @@ -321,15 +324,6 @@ impl Pane { &self.workspace } - pub fn is_active(&self) -> bool { - self.is_active - } - - pub fn set_active(&mut self, is_active: bool, cx: &mut ViewContext) { - self.is_active = is_active; - cx.notify(); - } - pub fn has_focus(&self) -> bool { self.has_focus } @@ -346,6 +340,14 @@ impl Pane { cx.notify(); } + pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut ViewContext) { + self.can_navigate = can_navigate; + self.toolbar.update(cx, |toolbar, cx| { + toolbar.set_can_navigate(can_navigate, cx); + }); + cx.notify(); + } + pub fn set_render_tab_bar_buttons(&mut self, cx: &mut ViewContext, render: F) where F: 'static + Fn(&mut Pane, &mut ViewContext) -> AnyElement, @@ -430,6 +432,10 @@ impl Pane { cx: &mut ViewContext, ) -> Task> { let to_load = if let Some(pane) = pane.upgrade(cx) { + if !pane.read(cx).can_navigate { + return Task::ready(Ok(())); + } + cx.focus(&pane); pane.update(cx, |pane, cx| { @@ -468,7 +474,7 @@ impl Pane { .paths_by_item .get(&entry.item.id()) .cloned() - .map(|project_path| (project_path, entry)); + .map(|(project_path, _)| (project_path, entry)); } } }) @@ -577,6 +583,20 @@ impl Pane { destination_index: Option, cx: &mut ViewContext, ) { + if item.is_singleton(cx) { + if let Some(&entry_id) = item.project_entry_ids(cx).get(0) { + if let Some(project_path) = + workspace.project().read(cx).path_for_entry(entry_id, cx) + { + let abs_path = workspace.absolute_path(&project_path, cx); + pane.read(cx) + .nav_history + .borrow_mut() + .paths_by_item + .insert(item.id(), (project_path, abs_path)); + } + } + } // If no destination index is specified, add or move the item after the active item. let mut insertion_index = { let pane = pane.read(cx); @@ -695,6 +715,11 @@ impl Pane { } pub fn toggle_zoom(&mut self, _: &ToggleZoom, cx: &mut ViewContext) { + // Potentially warn the user of the new keybinding + let workspace_handle = self.workspace().clone(); + cx.spawn(|_, mut cx| async move { notify_of_new_dock(&workspace_handle, &mut cx) }) + .detach(); + if self.zoomed { cx.emit(Event::ZoomOut); } else if !self.items.is_empty() { @@ -989,10 +1014,16 @@ impl Pane { .set_mode(NavigationMode::Normal); if let Some(path) = item.project_path(cx) { + let abs_path = self + .nav_history + .borrow() + .paths_by_item + .get(&item.id()) + .and_then(|(_, abs_path)| abs_path.clone()); self.nav_history .borrow_mut() .paths_by_item - .insert(item.id(), path); + .insert(item.id(), (path, abs_path)); } else { self.nav_history .borrow_mut() @@ -1185,7 +1216,7 @@ impl Pane { AnchorCorner::TopRight, vec![ ContextMenuItem::action("New File", NewFile), - ContextMenuItem::action("New Terminal", NewTerminal), + ContextMenuItem::action("New Terminal", NewCenterTerminal), ContextMenuItem::action("New Search", NewSearch), ], cx, @@ -1309,7 +1340,7 @@ impl Pane { None }; - let pane_active = self.is_active; + let pane_active = self.has_focus; enum Tabs {} let mut row = Flex::row().scrollable::(1, autoscroll, cx); @@ -1592,6 +1623,7 @@ impl Pane { pub fn render_tab_bar_button)>( index: usize, icon: &'static str, + active: bool, tooltip: Option<(String, Option>)>, cx: &mut ViewContext, on_click: F, @@ -1601,7 +1633,7 @@ impl Pane { let mut button = MouseEventHandler::::new(index, cx, |mouse_state, cx| { let theme = &settings::get::(cx).theme.workspace.tab_bar; - let style = theme.pane_button.style_for(mouse_state, false); + let style = theme.pane_button.style_for(mouse_state, active); Svg::new(icon) .with_color(style.color) .constrained() @@ -1688,7 +1720,7 @@ impl View for Pane { let mut tab_row = Flex::row() .with_child(self.render_tabs(cx).flex(1., true).into_any_named("tabs")); - if self.is_active { + if self.has_focus { let render_tab_bar_buttons = self.render_tab_bar_buttons.clone(); tab_row.add_child( (render_tab_bar_buttons)(self, cx) @@ -1779,6 +1811,7 @@ impl View for Pane { if !self.has_focus { self.has_focus = true; cx.emit(Event::Focus); + cx.notify(); } self.toolbar.update(cx, |toolbar, cx| { @@ -1813,6 +1846,7 @@ impl View for Pane { self.toolbar.update(cx, |toolbar, cx| { toolbar.pane_focus_update(false, cx); }); + cx.notify(); } fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) { @@ -1940,7 +1974,7 @@ impl PaneNavHistory { pub fn for_each_entry( &self, cx: &AppContext, - mut f: impl FnMut(&NavigationEntry, ProjectPath), + mut f: impl FnMut(&NavigationEntry, (ProjectPath, Option)), ) { let borrowed_history = self.0.borrow(); borrowed_history @@ -1949,12 +1983,13 @@ impl PaneNavHistory { .chain(borrowed_history.backward_stack.iter()) .chain(borrowed_history.closed_stack.iter()) .for_each(|entry| { - if let Some(path) = borrowed_history.paths_by_item.get(&entry.item.id()) { - f(entry, path.clone()); + if let Some(project_and_abs_path) = + borrowed_history.paths_by_item.get(&entry.item.id()) + { + f(entry, project_and_abs_path.clone()); } else if let Some(item) = entry.item.upgrade(cx) { - let path = item.project_path(cx); - if let Some(path) = path { - f(entry, path); + if let Some(path) = item.project_path(cx) { + f(entry, (path, None)); } } }) diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index 30890ed5d2..be327c45f2 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -53,6 +53,7 @@ pub enum ToolbarItemLocation { pub struct Toolbar { active_pane_item: Option>, hidden: bool, + can_navigate: bool, pane: WeakViewHandle, items: Vec<(Box, ToolbarItemLocation)>, } @@ -132,76 +133,86 @@ impl View for Toolbar { let button_style = theme.nav_button; let tooltip_style = theme::current(cx).tooltip.clone(); - Flex::column() - .with_child( - Flex::row() - .with_child(nav_button( - "icons/arrow_left_16.svg", - button_style, - nav_button_height, - tooltip_style.clone(), - enable_go_backward, - spacing, + let mut primary_items = Flex::row(); + if self.can_navigate { + primary_items.add_child(nav_button( + "icons/arrow_left_16.svg", + button_style, + nav_button_height, + tooltip_style.clone(), + enable_go_backward, + spacing, + { + let pane = pane.clone(); + move |toolbar, cx| { + if let Some(workspace) = toolbar + .pane + .upgrade(cx) + .and_then(|pane| pane.read(cx).workspace().upgrade(cx)) { let pane = pane.clone(); - move |toolbar, cx| { - if let Some(workspace) = toolbar - .pane - .upgrade(cx) - .and_then(|pane| pane.read(cx).workspace().upgrade(cx)) - { - let pane = pane.clone(); - cx.window_context().defer(move |cx| { - workspace.update(cx, |workspace, cx| { - Pane::go_back(workspace, Some(pane.clone()), cx) - .detach_and_log_err(cx); - }); - }) - } - } - }, - super::GoBack { pane: None }, - "Go Back", - cx, - )) - .with_child(nav_button( - "icons/arrow_right_16.svg", - button_style, - nav_button_height, - tooltip_style, - enable_go_forward, - spacing, + cx.window_context().defer(move |cx| { + workspace.update(cx, |workspace, cx| { + Pane::go_back(workspace, Some(pane.clone()), cx) + .detach_and_log_err(cx); + }); + }) + } + } + }, + super::GoBack { pane: None }, + "Go Back", + cx, + )); + primary_items.add_child(nav_button( + "icons/arrow_right_16.svg", + button_style, + nav_button_height, + tooltip_style, + enable_go_forward, + spacing, + { + let pane = pane.clone(); + move |toolbar, cx| { + if let Some(workspace) = toolbar + .pane + .upgrade(cx) + .and_then(|pane| pane.read(cx).workspace().upgrade(cx)) { let pane = pane.clone(); - move |toolbar, cx| { - if let Some(workspace) = toolbar - .pane - .upgrade(cx) - .and_then(|pane| pane.read(cx).workspace().upgrade(cx)) - { - let pane = pane.clone(); - cx.window_context().defer(move |cx| { - workspace.update(cx, |workspace, cx| { - Pane::go_forward(workspace, Some(pane.clone()), cx) - .detach_and_log_err(cx); - }); - }); - } - } - }, - super::GoForward { pane: None }, - "Go Forward", - cx, - )) - .with_children(primary_left_items) - .with_children(primary_right_items) - .constrained() - .with_height(height), - ) - .with_children(secondary_item) - .contained() - .with_style(container_style) - .into_any_named("toolbar") + cx.window_context().defer(move |cx| { + workspace.update(cx, |workspace, cx| { + Pane::go_forward(workspace, Some(pane.clone()), cx) + .detach_and_log_err(cx); + }); + }); + } + } + }, + super::GoForward { pane: None }, + "Go Forward", + cx, + )); + } + primary_items.extend(primary_left_items); + primary_items.extend(primary_right_items); + + let mut toolbar = Flex::column(); + if !primary_items.is_empty() { + toolbar.add_child(primary_items.constrained().with_height(height)); + } + if let Some(secondary_item) = secondary_item { + toolbar.add_child(secondary_item); + } + + if toolbar.is_empty() { + toolbar.into_any_named("toolbar") + } else { + toolbar + .contained() + .with_style(container_style) + .into_any_named("toolbar") + } } } @@ -264,9 +275,15 @@ impl Toolbar { pane, items: Default::default(), hidden: false, + can_navigate: true, } } + pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut ViewContext) { + self.can_navigate = can_navigate; + cx.notify(); + } + pub fn add_item(&mut self, item: ViewHandle, cx: &mut ViewContext) where T: 'static + ToolbarItemView, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index da23d23285..5d6a3fdd23 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -40,9 +40,9 @@ use gpui::{ CursorStyle, MouseButton, PathPromptOptions, Platform, PromptLevel, WindowBounds, WindowOptions, }, - AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, - SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, - WindowContext, + AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, + ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle, + WeakViewHandle, WindowContext, }; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem}; use itertools::Itertools; @@ -53,13 +53,14 @@ use std::{ cmp, env, future::Future, path::{Path, PathBuf}, + rc::Rc, str, sync::{atomic::AtomicUsize, Arc}, time::Duration, }; use crate::{ - notifications::simple_message_notification::MessageNotification, + notifications::{simple_message_notification::MessageNotification, NotificationTracker}, persistence::model::{ DockData, DockStructure, SerializedPane, SerializedPaneGroup, SerializedWorkspace, }, @@ -80,7 +81,7 @@ use serde::Deserialize; use shared_screen::SharedScreen; use status_bar::StatusBar; pub use status_bar::StatusItemView; -use theme::Theme; +use theme::{Theme, ThemeSettings}; pub use toolbar::{ToolbarItemLocation, ToolbarItemView}; use util::{async_iife, paths, ResultExt}; pub use workspace_settings::{AutosaveSetting, GitGutterSetting, WorkspaceSettings}; @@ -103,24 +104,6 @@ pub trait Modal: View { #[derive(Clone, PartialEq)] pub struct RemoveWorktreeFromProject(pub WorktreeId); -#[derive(Copy, Clone, Default, Deserialize, PartialEq)] -pub struct ToggleLeftDock { - #[serde(default = "default_true")] - pub focus: bool, -} - -#[derive(Copy, Clone, Default, Deserialize, PartialEq)] -pub struct ToggleBottomDock { - #[serde(default = "default_true")] - pub focus: bool, -} - -#[derive(Copy, Clone, Default, Deserialize, PartialEq)] -pub struct ToggleRightDock { - #[serde(default = "default_true")] - pub focus: bool, -} - actions!( workspace, [ @@ -137,22 +120,21 @@ actions!( ActivateNextPane, FollowNextCollaborator, NewTerminal, + NewCenterTerminal, ToggleTerminalFocus, NewSearch, Feedback, Restart, Welcome, ToggleZoom, + ToggleLeftDock, + ToggleRightDock, + ToggleBottomDock, ] ); actions!(zed, [OpenSettings]); -impl_actions!( - workspace, - [ToggleLeftDock, ToggleBottomDock, ToggleRightDock] -); - #[derive(Clone, PartialEq)] pub struct OpenPaths { pub paths: Vec, @@ -268,14 +250,14 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { cx.add_action(|workspace: &mut Workspace, _: &ActivateNextPane, cx| { workspace.activate_next_pane(cx) }); - cx.add_action(|workspace: &mut Workspace, action: &ToggleLeftDock, cx| { - workspace.toggle_dock(DockPosition::Left, action.focus, cx); + cx.add_action(|workspace: &mut Workspace, _: &ToggleLeftDock, cx| { + workspace.toggle_dock(DockPosition::Left, cx); }); - cx.add_action(|workspace: &mut Workspace, action: &ToggleRightDock, cx| { - workspace.toggle_dock(DockPosition::Right, action.focus, cx); + cx.add_action(|workspace: &mut Workspace, _: &ToggleRightDock, cx| { + workspace.toggle_dock(DockPosition::Right, cx); }); - cx.add_action(|workspace: &mut Workspace, action: &ToggleBottomDock, cx| { - workspace.toggle_dock(DockPosition::Bottom, action.focus, cx); + cx.add_action(|workspace: &mut Workspace, _: &ToggleBottomDock, cx| { + workspace.toggle_dock(DockPosition::Bottom, cx); }); cx.add_action(Workspace::activate_pane_at_index); @@ -484,6 +466,8 @@ pub struct Workspace { weak_self: WeakViewHandle, remote_entity_subscription: Option, modal: Option, + zoomed: Option, + zoomed_position: Option, center: PaneGroup, left_dock: ViewHandle, bottom_dock: ViewHandle, @@ -687,6 +671,8 @@ impl Workspace { let mut this = Workspace { weak_self: weak_handle.clone(), modal: None, + zoomed: None, + zoomed_position: None, center: PaneGroup::new(center_pane.clone()), panes: vec![center_pane.clone()], panes_by_item: Default::default(), @@ -885,10 +871,15 @@ impl Workspace { was_visible = dock.is_open() && dock - .active_panel() + .visible_panel() .map_or(false, |active_panel| active_panel.id() == panel.id()); dock.remove_panel(&panel, cx); }); + + if panel.is_zoomed(cx) { + this.zoomed_position = Some(new_position); + } + dock = match panel.read(cx).position(cx) { DockPosition::Left => &this.left_dock, DockPosition::Bottom => &this.bottom_dock, @@ -903,11 +894,28 @@ impl Workspace { } }); } else if T::should_zoom_in_on_event(event) { - this.zoom_out(cx); dock.update(cx, |dock, cx| dock.set_panel_zoomed(&panel, true, cx)); + if panel.has_focus(cx) { + this.zoomed = Some(panel.downgrade().into_any()); + this.zoomed_position = Some(panel.read(cx).position(cx)); + } } else if T::should_zoom_out_on_event(event) { - this.zoom_out(cx); + dock.update(cx, |dock, cx| dock.set_panel_zoomed(&panel, false, cx)); + if this.zoomed_position == Some(prev_position) { + this.zoomed = None; + this.zoomed_position = None; + } + cx.notify(); } else if T::is_focus_event(event) { + let position = panel.read(cx).position(cx); + this.dismiss_zoomed_items_to_reveal(Some(position), cx); + if panel.is_zoomed(cx) { + this.zoomed = Some(panel.downgrade().into_any()); + this.zoomed_position = Some(position); + } else { + this.zoomed = None; + this.zoomed_position = None; + } cx.notify(); } } @@ -936,21 +944,29 @@ impl Workspace { &self, limit: Option, cx: &AppContext, - ) -> Vec { - let mut history: HashMap = HashMap::default(); + ) -> Vec<(ProjectPath, Option)> { + let mut abs_paths_opened: HashMap> = HashMap::default(); + let mut history: HashMap, usize)> = HashMap::default(); for pane in &self.panes { let pane = pane.read(cx); pane.nav_history() - .for_each_entry(cx, |entry, project_path| { + .for_each_entry(cx, |entry, (project_path, fs_path)| { + if let Some(fs_path) = &fs_path { + abs_paths_opened + .entry(fs_path.clone()) + .or_default() + .insert(project_path.clone()); + } let timestamp = entry.timestamp; match history.entry(project_path) { hash_map::Entry::Occupied(mut entry) => { - if ×tamp > entry.get() { - entry.insert(timestamp); + let (_, old_timestamp) = entry.get(); + if ×tamp > old_timestamp { + entry.insert((fs_path, timestamp)); } } hash_map::Entry::Vacant(entry) => { - entry.insert(timestamp); + entry.insert((fs_path, timestamp)); } } }); @@ -958,9 +974,24 @@ impl Workspace { history .into_iter() - .sorted_by_key(|(_, timestamp)| *timestamp) - .map(|(project_path, _)| project_path) + .sorted_by_key(|(_, (_, timestamp))| *timestamp) + .map(|(project_path, (fs_path, _))| (project_path, fs_path)) .rev() + .filter(|(history_path, abs_path)| { + let latest_project_path_opened = abs_path + .as_ref() + .and_then(|abs_path| abs_paths_opened.get(abs_path)) + .and_then(|project_paths| { + project_paths + .iter() + .max_by(|b1, b2| b1.worktree_id.cmp(&b2.worktree_id)) + }); + + match latest_project_path_opened { + Some(latest_project_path_opened) => latest_project_path_opened == history_path, + None => true, + } + }) .take(limit.unwrap_or(usize::MAX)) .collect() } @@ -1273,6 +1304,22 @@ impl Workspace { }) } + pub fn absolute_path(&self, project_path: &ProjectPath, cx: &AppContext) -> Option { + let workspace_root = self + .project() + .read(cx) + .worktree_for_id(project_path.worktree_id, cx)? + .read(cx) + .abs_path(); + let project_path = project_path.path.as_ref(); + + Some(if project_path == Path::new("") { + workspace_root.to_path_buf() + } else { + workspace_root.join(project_path) + }) + } + fn add_folder_to_project(&mut self, _: &AddFolderToProject, cx: &mut ViewContext) { let mut paths = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -1359,44 +1406,6 @@ impl Workspace { } } - fn zoomed(&self, cx: &WindowContext) -> Option { - self.zoomed_panel_for_dock(DockPosition::Left, cx) - .or_else(|| self.zoomed_panel_for_dock(DockPosition::Bottom, cx)) - .or_else(|| self.zoomed_panel_for_dock(DockPosition::Right, cx)) - .or_else(|| self.zoomed_pane(cx)) - } - - fn zoomed_panel_for_dock( - &self, - position: DockPosition, - cx: &WindowContext, - ) -> Option { - let (dock, other_docks) = match position { - DockPosition::Left => (&self.left_dock, [&self.bottom_dock, &self.right_dock]), - DockPosition::Bottom => (&self.bottom_dock, [&self.left_dock, &self.right_dock]), - DockPosition::Right => (&self.right_dock, [&self.left_dock, &self.bottom_dock]), - }; - - let zoomed_panel = dock.read(&cx).zoomed_panel(cx)?; - if other_docks.iter().all(|dock| !dock.read(cx).has_focus(cx)) - && !self.active_pane.read(cx).has_focus() - { - Some(zoomed_panel.as_any().clone()) - } else { - None - } - } - - fn zoomed_pane(&self, cx: &WindowContext) -> Option { - let active_pane = self.active_pane.read(cx); - let docks = [&self.left_dock, &self.bottom_dock, &self.right_dock]; - if active_pane.is_zoomed() && docks.iter().all(|dock| !dock.read(cx).has_focus(cx)) { - Some(self.active_pane.clone().into_any()) - } else { - None - } - } - pub fn items<'a>( &'a self, cx: &'a AppContext, @@ -1474,89 +1483,98 @@ impl Workspace { } } - pub fn toggle_dock( - &mut self, - dock_side: DockPosition, - focus: bool, - cx: &mut ViewContext, - ) { + pub fn toggle_dock(&mut self, dock_side: DockPosition, cx: &mut ViewContext) { let dock = match dock_side { DockPosition::Left => &self.left_dock, DockPosition::Bottom => &self.bottom_dock, DockPosition::Right => &self.right_dock, }; + let mut focus_center = false; + let mut reveal_dock = false; dock.update(cx, |dock, cx| { - let open = !dock.is_open(); - dock.set_open(open, cx); + let other_is_zoomed = self.zoomed.is_some() && self.zoomed_position != Some(dock_side); + let was_visible = dock.is_open() && !other_is_zoomed; + dock.set_open(!was_visible, cx); + + if let Some(active_panel) = dock.active_panel() { + if was_visible { + if active_panel.has_focus(cx) { + focus_center = true; + } + } else { + if active_panel.is_zoomed(cx) { + cx.focus(active_panel.as_any()); + } + reveal_dock = true; + } + } }); - if dock.read(cx).is_open() && focus { - cx.focus(dock); - } else { + if reveal_dock { + self.dismiss_zoomed_items_to_reveal(Some(dock_side), cx); + } + + if focus_center { cx.focus_self(); } + cx.notify(); self.serialize_workspace(cx); } - pub fn toggle_panel( - &mut self, - position: DockPosition, - panel_index: usize, - cx: &mut ViewContext, - ) { - let dock = match position { - DockPosition::Left => &mut self.left_dock, - DockPosition::Bottom => &mut self.bottom_dock, - DockPosition::Right => &mut self.right_dock, - }; - let active_item = dock.update(cx, move |dock, cx| { - if dock.is_open() && dock.active_panel_index() == panel_index { - dock.set_open(false, cx); - None - } else { - dock.set_open(true, cx); - dock.activate_panel(panel_index, cx); - dock.active_panel().cloned() - } - }); - - if let Some(active_item) = active_item { - if active_item.has_focus(cx) { - cx.focus_self(); - } else { - cx.focus(active_item.as_any()); - } - } else { - cx.focus_self(); - } - - self.serialize_workspace(cx); - - cx.notify(); + /// Transfer focus to the panel of the given type. + pub fn focus_panel(&mut self, cx: &mut ViewContext) -> Option> { + self.focus_or_unfocus_panel::(cx, |_, _| true)? + .as_any() + .clone() + .downcast() } + /// Focus the panel of the given type if it isn't already focused. If it is + /// already focused, then transfer focus back to the workspace center. pub fn toggle_panel_focus(&mut self, cx: &mut ViewContext) { + self.focus_or_unfocus_panel::(cx, |panel, cx| !panel.has_focus(cx)); + } + + /// Focus or unfocus the given panel type, depending on the given callback. + fn focus_or_unfocus_panel( + &mut self, + cx: &mut ViewContext, + should_focus: impl Fn(&dyn PanelHandle, &mut ViewContext) -> bool, + ) -> Option> { for dock in [&self.left_dock, &self.bottom_dock, &self.right_dock] { if let Some(panel_index) = dock.read(cx).panel_index_for_type::() { - let active_item = dock.update(cx, |dock, cx| { - dock.set_open(true, cx); + let mut focus_center = false; + let mut reveal_dock = false; + let panel = dock.update(cx, |dock, cx| { dock.activate_panel(panel_index, cx); - dock.active_panel().cloned() - }); - if let Some(active_item) = active_item { - if active_item.has_focus(cx) { - cx.focus_self(); - } else { - cx.focus(active_item.as_any()); + + let panel = dock.active_panel().cloned(); + if let Some(panel) = panel.as_ref() { + if should_focus(&**panel, cx) { + dock.set_open(true, cx); + cx.focus(panel.as_any()); + reveal_dock = true; + } else { + // if panel.is_zoomed(cx) { + // dock.set_open(false, cx); + // } + focus_center = true; + } } + panel + }); + + if focus_center { + cx.focus_self(); } self.serialize_workspace(cx); cx.notify(); - break; + return panel; } } + None } fn zoom_out(&mut self, cx: &mut ViewContext) { @@ -1567,6 +1585,47 @@ impl Workspace { self.left_dock.update(cx, |dock, cx| dock.zoom_out(cx)); self.bottom_dock.update(cx, |dock, cx| dock.zoom_out(cx)); self.right_dock.update(cx, |dock, cx| dock.zoom_out(cx)); + self.zoomed = None; + self.zoomed_position = None; + + cx.notify(); + } + + fn dismiss_zoomed_items_to_reveal( + &mut self, + dock_to_reveal: Option, + cx: &mut ViewContext, + ) { + // If a center pane is zoomed, unzoom it. + for pane in &self.panes { + if pane != &self.active_pane { + pane.update(cx, |pane, cx| pane.set_zoomed(false, cx)); + } + } + + // If another dock is zoomed, hide it. + let mut focus_center = false; + for dock in [&self.left_dock, &self.right_dock, &self.bottom_dock] { + dock.update(cx, |dock, cx| { + if Some(dock.position()) != dock_to_reveal { + if let Some(panel) = dock.active_panel() { + if panel.is_zoomed(cx) { + focus_center |= panel.has_focus(cx); + dock.set_open(false, cx); + } + } + } + }); + } + + if focus_center { + cx.focus_self(); + } + + if self.zoomed_position != dock_to_reveal { + self.zoomed = None; + self.zoomed_position = None; + } cx.notify(); } @@ -1609,6 +1668,36 @@ impl Workspace { Pane::add_item(self, &active_pane, item, true, true, None, cx); } + pub fn open_abs_path( + &mut self, + abs_path: PathBuf, + visible: bool, + cx: &mut ViewContext, + ) -> Task>> { + cx.spawn(|workspace, mut cx| async move { + let open_paths_task_result = workspace + .update(&mut cx, |workspace, cx| { + workspace.open_paths(vec![abs_path.clone()], visible, cx) + }) + .with_context(|| format!("open abs path {abs_path:?} task spawn"))? + .await; + anyhow::ensure!( + open_paths_task_result.len() == 1, + "open abs path {abs_path:?} task returned incorrect number of results" + ); + match open_paths_task_result + .into_iter() + .next() + .expect("ensured single task result") + { + Some(open_result) => { + open_result.with_context(|| format!("open abs path {abs_path:?} task join")) + } + None => anyhow::bail!("open abs path {abs_path:?} task returned None"), + } + }) + } + pub fn open_path( &mut self, path: impl Into, @@ -1737,11 +1826,7 @@ impl Workspace { fn handle_pane_focused(&mut self, pane: ViewHandle, cx: &mut ViewContext) { if self.active_pane != pane { - self.active_pane - .update(cx, |pane, cx| pane.set_active(false, cx)); self.active_pane = pane.clone(); - self.active_pane - .update(cx, |pane, cx| pane.set_active(true, cx)); self.status_bar.update(cx, |status_bar, cx| { status_bar.set_active_pane(&self.active_pane, cx); }); @@ -1749,6 +1834,14 @@ impl Workspace { self.last_active_center_pane = Some(pane.downgrade()); } + self.dismiss_zoomed_items_to_reveal(None, cx); + if pane.read(cx).is_zoomed() { + self.zoomed = Some(pane.downgrade().into_any()); + } else { + self.zoomed = None; + } + self.zoomed_position = None; + self.update_followers( proto::update_followers::Variant::UpdateActiveView(proto::UpdateActiveView { id: self.active_item(cx).and_then(|item| { @@ -1802,12 +1895,21 @@ impl Workspace { } pane::Event::ZoomIn => { if pane == self.active_pane { - self.zoom_out(cx); pane.update(cx, |pane, cx| pane.set_zoomed(true, cx)); + if pane.read(cx).has_focus() { + self.zoomed = Some(pane.downgrade().into_any()); + self.zoomed_position = None; + } cx.notify(); } } - pane::Event::ZoomOut => self.zoom_out(cx), + pane::Event::ZoomOut => { + pane.update(cx, |pane, cx| pane.set_zoomed(false, cx)); + if self.zoomed_position.is_none() { + self.zoomed = None; + } + cx.notify(); + } } self.serialize_workspace(cx); @@ -2611,7 +2713,7 @@ impl Workspace { }) }) .collect::>(), - pane.is_active(), + pane.has_focus(), ) }; @@ -2639,7 +2741,7 @@ impl Workspace { fn build_serialized_docks(this: &Workspace, cx: &AppContext) -> DockStructure { let left_dock = this.left_dock.read(cx); let left_visible = left_dock.is_open(); - let left_active_panel = left_dock.active_panel().and_then(|panel| { + let left_active_panel = left_dock.visible_panel().and_then(|panel| { Some( cx.view_ui_name(panel.as_any().window_id(), panel.id())? .to_string(), @@ -2648,7 +2750,7 @@ impl Workspace { let right_dock = this.right_dock.read(cx); let right_visible = right_dock.is_open(); - let right_active_panel = right_dock.active_panel().and_then(|panel| { + let right_active_panel = right_dock.visible_panel().and_then(|panel| { Some( cx.view_ui_name(panel.as_any().window_id(), panel.id())? .to_string(), @@ -2657,7 +2759,7 @@ impl Workspace { let bottom_dock = this.bottom_dock.read(cx); let bottom_visible = bottom_dock.is_open(); - let bottom_active_panel = bottom_dock.active_panel().and_then(|panel| { + let bottom_active_panel = bottom_dock.visible_panel().and_then(|panel| { Some( cx.view_ui_name(panel.as_any().window_id(), panel.id())? .to_string(), @@ -2839,8 +2941,8 @@ impl Workspace { DockPosition::Right => &self.right_dock, DockPosition::Bottom => &self.bottom_dock, }; - let active_panel = dock.read(cx).active_panel()?; - let element = if Some(active_panel.as_any()) == self.zoomed(cx).as_ref() { + let active_panel = dock.read(cx).visible_panel()?; + let element = if Some(active_panel.id()) == self.zoomed.as_ref().map(|zoomed| zoomed.id()) { dock.read(cx).render_placeholder(cx) } else { ChildView::new(dock, cx).into_any() @@ -2854,7 +2956,10 @@ impl Workspace { Vector2F::new(20., constraint.min.y()), Vector2F::new(cx.window_size().x() * 0.8, constraint.max.y()), ), - _ => constraint, + DockPosition::Bottom => SizeConstraint::new( + Vector2F::new(constraint.min.x(), 20.), + Vector2F::new(constraint.max.x(), cx.window_size().y() * 0.8), + ), }) .into_any(), ) @@ -2950,6 +3055,87 @@ async fn open_items( opened_items } +fn notify_of_new_dock(workspace: &WeakViewHandle, cx: &mut AsyncAppContext) { + const NEW_PANEL_BLOG_POST: &str = "https://zed.dev/blog/new-panel-system"; + const NEW_DOCK_HINT_KEY: &str = "show_new_dock_key"; + const MESSAGE_ID: usize = 2; + + if workspace + .read_with(cx, |workspace, cx| { + workspace.has_shown_notification_once::(MESSAGE_ID, cx) + }) + .unwrap_or(false) + { + return; + } + + if db::kvp::KEY_VALUE_STORE + .read_kvp(NEW_DOCK_HINT_KEY) + .ok() + .flatten() + .is_some() + { + if !workspace + .read_with(cx, |workspace, cx| { + workspace.has_shown_notification_once::(MESSAGE_ID, cx) + }) + .unwrap_or(false) + { + cx.update(|cx| { + cx.update_global::(|tracker, _| { + let entry = tracker + .entry(TypeId::of::()) + .or_default(); + if !entry.contains(&MESSAGE_ID) { + entry.push(MESSAGE_ID); + } + }); + }); + } + + return; + } + + cx.spawn(|_| async move { + db::kvp::KEY_VALUE_STORE + .write_kvp(NEW_DOCK_HINT_KEY.to_string(), "seen".to_string()) + .await + .ok(); + }) + .detach(); + + workspace + .update(cx, |workspace, cx| { + workspace.show_notification_once(2, cx, |cx| { + cx.add_view(|_| { + MessageNotification::new_element(|text, _| { + Text::new( + "Looking for the dock? Try ctrl-`!\nshift-escape now zooms your pane.", + text, + ) + .with_custom_runs(vec![26..32, 34..46], |_, bounds, scene, cx| { + let code_span_background_color = settings::get::(cx) + .theme + .editor + .document_highlight_read_background; + + scene.push_quad(gpui::Quad { + bounds, + background: Some(code_span_background_color), + border: Default::default(), + corner_radius: 2.0, + }) + }) + .into_any() + }) + .with_click_message("Read more about the new panel system") + .on_click(|cx| cx.platform().open_url(NEW_PANEL_BLOG_POST)) + }) + }) + }) + .ok(); +} + fn notify_if_database_failed(workspace: &WeakViewHandle, cx: &mut AsyncAppContext) { const REPORT_ISSUE_URL: &str ="https://github.com/zed-industries/community/issues/new?assignees=&labels=defect%2Ctriage&template=2_bug_report.yml"; @@ -2966,7 +3152,7 @@ fn notify_if_database_failed(workspace: &WeakViewHandle, cx: &mut Asy } else { let backup_path = (*db::BACKUP_DB_PATH).read(); if let Some(backup_path) = backup_path.clone() { - workspace.show_notification_once(0, cx, move |cx| { + workspace.show_notification_once(1, cx, move |cx| { cx.add_view(move |_| { MessageNotification::new(format!( "Database file was corrupted. Old database backed up to {}", @@ -3008,16 +3194,21 @@ impl View for Workspace { .with_child( Flex::column() .with_child( - FlexItem::new(self.center.render( - &project, - &theme, - &self.follower_states_by_leader, - self.active_call(), - self.active_pane(), - self.zoomed(cx).as_ref(), - &self.app_state, - cx, - )) + FlexItem::new( + self.center.render( + &project, + &theme, + &self.follower_states_by_leader, + self.active_call(), + self.active_pane(), + self.zoomed + .as_ref() + .and_then(|zoomed| zoomed.upgrade(cx)) + .as_ref(), + &self.app_state, + cx, + ), + ) .flex(1., true), ) .with_children( @@ -3029,20 +3220,59 @@ impl View for Workspace { }) .with_child(Overlay::new( Stack::new() - .with_children(self.zoomed(cx).map(|zoomed| { + .with_children(self.zoomed.as_ref().and_then(|zoomed| { enum ZoomBackground {} + let zoomed = zoomed.upgrade(cx)?; - ChildView::new(&zoomed, cx) - .contained() - .with_style(theme.workspace.zoomed_foreground) - .aligned() - .contained() - .with_style(theme.workspace.zoomed_background) - .mouse::(0) - .capture_all() - .on_down(MouseButton::Left, |_, this: &mut Self, cx| { - this.zoom_out(cx); - }) + let mut foreground_style = + theme.workspace.zoomed_pane_foreground; + if let Some(zoomed_dock_position) = self.zoomed_position { + foreground_style = + theme.workspace.zoomed_panel_foreground; + let margin = foreground_style.margin.top; + let border = foreground_style.border.top; + + // Only include a margin and border on the opposite side. + foreground_style.margin.top = 0.; + foreground_style.margin.left = 0.; + foreground_style.margin.bottom = 0.; + foreground_style.margin.right = 0.; + foreground_style.border.top = false; + foreground_style.border.left = false; + foreground_style.border.bottom = false; + foreground_style.border.right = false; + match zoomed_dock_position { + DockPosition::Left => { + foreground_style.margin.right = margin; + foreground_style.border.right = border; + } + DockPosition::Right => { + foreground_style.margin.left = margin; + foreground_style.border.left = border; + } + DockPosition::Bottom => { + foreground_style.margin.top = margin; + foreground_style.border.top = border; + } + } + } + + Some( + ChildView::new(&zoomed, cx) + .contained() + .with_style(foreground_style) + .aligned() + .contained() + .with_style(theme.workspace.zoomed_background) + .mouse::(0) + .capture_all() + .on_down( + MouseButton::Left, + |_, this: &mut Self, cx| { + this.zoom_out(cx); + }, + ), + ) })) .with_children(self.modal.as_ref().map(|modal| { ChildView::new(modal, cx) @@ -3068,7 +3298,6 @@ impl View for Workspace { if cx.is_self_focused() { cx.focus(&self.active_pane); } - cx.notify(); } } @@ -3381,10 +3610,6 @@ fn parse_pixel_position_env_var(value: &str) -> Option { Some(vec2f(width as f32, height as f32)) } -fn default_true() -> bool { - true -} - #[cfg(test)] mod tests { use super::*; @@ -3393,7 +3618,7 @@ mod tests { item::test::{TestItem, TestItemEvent, TestProjectItem}, }; use fs::FakeFs; - use gpui::{executor::Deterministic, TestAppContext}; + use gpui::{executor::Deterministic, test::EmptyView, TestAppContext}; use project::{Project, ProjectEntryId}; use serde_json::json; use settings::SettingsStore; @@ -3965,13 +4190,135 @@ mod tests { }); } + #[gpui::test] + async fn test_toggle_docks_and_panels(cx: &mut gpui::TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background()); + + let project = Project::test(fs, [], cx).await; + let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + + let panel = workspace.update(cx, |workspace, cx| { + let panel = cx.add_view(|_| TestPanel::new(DockPosition::Right)); + workspace.add_panel(panel.clone(), cx); + + workspace + .right_dock() + .update(cx, |right_dock, cx| right_dock.set_open(true, cx)); + + panel + }); + + // Transfer focus from center to panel + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(panel.has_focus(cx)); + }); + + // Transfer focus from panel to center + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(!panel.has_focus(cx)); + }); + + // Close the dock + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx); + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(!workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(!panel.has_focus(cx)); + }); + + // Open the dock + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx); + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(!panel.is_zoomed(cx)); + assert!(!panel.has_focus(cx)); + }); + + // Focus and zoom panel + panel.update(cx, |panel, cx| { + cx.focus_self(); + panel.set_zoomed(true, cx) + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(panel.has_focus(cx)); + }); + + // Transfer focus to the center closes the dock + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(!workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(!panel.has_focus(cx)); + }); + + // Transfering focus back to the panel keeps it zoomed + workspace.update(cx, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(panel.has_focus(cx)); + }); + + // Close the dock while it is zoomed + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx) + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(!workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(workspace.zoomed.is_none()); + assert!(!panel.has_focus(cx)); + }); + + // Opening the dock, when it's zoomed, retains focus + workspace.update(cx, |workspace, cx| { + workspace.toggle_dock(DockPosition::Right, cx) + }); + + workspace.read_with(cx, |workspace, cx| { + assert!(workspace.right_dock().read(cx).is_open()); + assert!(panel.is_zoomed(cx)); + assert!(workspace.zoomed.is_some()); + assert!(panel.has_focus(cx)); + }); + } + #[gpui::test] async fn test_panels(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background()); let project = Project::test(fs, [], cx).await; - let (_window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); let (panel_1, panel_2) = workspace.update(cx, |workspace, cx| { // Add panel_1 on the left, panel_2 on the right. @@ -3988,7 +4335,7 @@ mod tests { let left_dock = workspace.left_dock(); assert_eq!( - left_dock.read(cx).active_panel().unwrap().id(), + left_dock.read(cx).visible_panel().unwrap().id(), panel_1.id() ); assert_eq!( @@ -3998,7 +4345,12 @@ mod tests { left_dock.update(cx, |left_dock, cx| left_dock.resize_active_panel(1337., cx)); assert_eq!( - workspace.right_dock().read(cx).active_panel().unwrap().id(), + workspace + .right_dock() + .read(cx) + .visible_panel() + .unwrap() + .id(), panel_2.id() ); @@ -4014,10 +4366,10 @@ mod tests { // Since panel_1 was visible on the left, it should now be visible now that it's been moved to the right. // Since it was the only panel on the left, the left dock should now be closed. assert!(!workspace.left_dock().read(cx).is_open()); - assert!(workspace.left_dock().read(cx).active_panel().is_none()); + assert!(workspace.left_dock().read(cx).visible_panel().is_none()); let right_dock = workspace.right_dock(); assert_eq!( - right_dock.read(cx).active_panel().unwrap().id(), + right_dock.read(cx).visible_panel().unwrap().id(), panel_1.id() ); assert_eq!(right_dock.read(cx).active_panel_size(cx).unwrap(), 1337.); @@ -4032,7 +4384,12 @@ mod tests { // And the right dock is unaffected in it's displaying of panel_1 assert!(workspace.right_dock().read(cx).is_open()); assert_eq!( - workspace.right_dock().read(cx).active_panel().unwrap().id(), + workspace + .right_dock() + .read(cx) + .visible_panel() + .unwrap() + .id(), panel_1.id() ); }); @@ -4047,7 +4404,7 @@ mod tests { let left_dock = workspace.left_dock(); assert!(left_dock.read(cx).is_open()); assert_eq!( - left_dock.read(cx).active_panel().unwrap().id(), + left_dock.read(cx).visible_panel().unwrap().id(), panel_1.id() ); assert_eq!(left_dock.read(cx).active_panel_size(cx).unwrap(), 1337.); @@ -4081,7 +4438,7 @@ mod tests { let left_dock = workspace.left_dock(); assert!(left_dock.read(cx).is_open()); assert_eq!( - left_dock.read(cx).active_panel().unwrap().id(), + left_dock.read(cx).visible_panel().unwrap().id(), panel_1.id() ); assert!(panel_1.is_focused(cx)); @@ -4095,33 +4452,61 @@ mod tests { let left_dock = workspace.left_dock(); assert!(left_dock.read(cx).is_open()); assert_eq!( - left_dock.read(cx).active_panel().unwrap().id(), + left_dock.read(cx).visible_panel().unwrap().id(), panel_1.id() ); }); // Emitting a ZoomIn event shows the panel as zoomed. panel_1.update(cx, |_, cx| cx.emit(TestPanelEvent::ZoomIn)); - workspace.read_with(cx, |workspace, cx| { - assert_eq!(workspace.zoomed(cx), Some(panel_1.clone().into_any())); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.downgrade().into_any())); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Left)); + }); + + // Move panel to another dock while it is zoomed + panel_1.update(cx, |panel, cx| panel.set_position(DockPosition::Right, cx)); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.downgrade().into_any())); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); + }); + + // If focus is transferred to another view that's not a panel or another pane, we still show + // the panel as zoomed. + let focus_receiver = cx.add_view(window_id, |_| EmptyView); + focus_receiver.update(cx, |_, cx| cx.focus_self()); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.downgrade().into_any())); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); }); // If focus is transferred elsewhere in the workspace, the panel is no longer zoomed. workspace.update(cx, |_, cx| cx.focus_self()); - workspace.read_with(cx, |workspace, cx| { - assert_eq!(workspace.zoomed(cx), None); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.zoomed, None); + assert_eq!(workspace.zoomed_position, None); + }); + + // If focus is transferred again to another view that's not a panel or a pane, we won't + // show the panel as zoomed because it wasn't zoomed before. + focus_receiver.update(cx, |_, cx| cx.focus_self()); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.zoomed, None); + assert_eq!(workspace.zoomed_position, None); }); // When focus is transferred back to the panel, it is zoomed again. panel_1.update(cx, |_, cx| cx.focus_self()); - workspace.read_with(cx, |workspace, cx| { - assert_eq!(workspace.zoomed(cx), Some(panel_1.clone().into_any())); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.zoomed, Some(panel_1.downgrade().into_any())); + assert_eq!(workspace.zoomed_position, Some(DockPosition::Right)); }); // Emitting a ZoomOut event unzooms the panel. panel_1.update(cx, |_, cx| cx.emit(TestPanelEvent::ZoomOut)); - workspace.read_with(cx, |workspace, cx| { - assert_eq!(workspace.zoomed(cx), None); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.zoomed, None); + assert_eq!(workspace.zoomed_position, None); }); // Emit closed event on panel 1, which is active @@ -4129,8 +4514,8 @@ mod tests { // Now the left dock is closed, because panel_1 was the active panel workspace.read_with(cx, |workspace, cx| { - let left_dock = workspace.left_dock(); - assert!(!left_dock.read(cx).is_open()); + let right_dock = workspace.right_dock(); + assert!(!right_dock.read(cx).is_open()); }); } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 90dced65f5..eb8db29f28 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.88.0" +version = "0.88.6" publish = false [lib] @@ -48,6 +48,7 @@ language_selector = { path = "../language_selector" } lsp = { path = "../lsp" } lsp_log = { path = "../lsp_log" } node_runtime = { path = "../node_runtime" } +ai = { path = "../ai" } outline = { path = "../outline" } plugin_runtime = { path = "../plugin_runtime" } project = { path = "../project" } @@ -81,7 +82,7 @@ futures.workspace = true ignore = "0.4" image = "0.23" indexmap = "1.6.2" -isahc = "1.7" +isahc.workspace = true lazy_static.workspace = true libc = "0.2" log.workspace = true diff --git a/crates/zed/RELEASE_CHANNEL b/crates/zed/RELEASE_CHANNEL index 90012116c0..870bbe4e50 100644 --- a/crates/zed/RELEASE_CHANNEL +++ b/crates/zed/RELEASE_CHANNEL @@ -1 +1 @@ -dev \ No newline at end of file +stable \ No newline at end of file diff --git a/crates/zed/src/languages/markdown/config.toml b/crates/zed/src/languages/markdown/config.toml index 2fa3ff3cf2..55204cc7a5 100644 --- a/crates/zed/src/languages/markdown/config.toml +++ b/crates/zed/src/languages/markdown/config.toml @@ -1,5 +1,5 @@ name = "Markdown" -path_suffixes = ["md", "mdx"] +path_suffixes = ["md", "mdx", "zmd"] brackets = [ { start = "{", end = "}", close = true, newline = true }, { start = "[", end = "]", close = true, newline = true }, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index c05efd3f02..31f331ef93 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -161,6 +161,7 @@ fn main() { terminal_view::init(cx); theme_testbench::init(cx); copilot::init(http.clone(), node_runtime, cx); + ai::init(cx); cx.spawn(|cx| watch_themes(fs.clone(), cx)).detach(); diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index b242b0f183..37e835c13d 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -89,18 +89,9 @@ pub fn menus() -> Vec> { MenuItem::action("Zoom Out", super::DecreaseBufferFontSize), MenuItem::action("Reset Zoom", super::ResetBufferFontSize), MenuItem::separator(), - MenuItem::action( - "Toggle Left Dock", - workspace::ToggleLeftDock { focus: false }, - ), - MenuItem::action( - "Toggle Right Dock", - workspace::ToggleRightDock { focus: false }, - ), - MenuItem::action( - "Toggle Bottom Dock", - workspace::ToggleBottomDock { focus: false }, - ), + MenuItem::action("Toggle Left Dock", workspace::ToggleLeftDock), + MenuItem::action("Toggle Right Dock", workspace::ToggleRightDock), + MenuItem::action("Toggle Bottom Dock", workspace::ToggleBottomDock), MenuItem::submenu(Menu { name: "Editor Layout", items: vec![ diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1dfe9c24e5..f9c0a1855e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -354,7 +354,7 @@ pub fn initialize_workspace( .map_or(false, |entry| entry.is_dir()) }) { - workspace.toggle_dock(project_panel_position, false, cx); + workspace.toggle_dock(project_panel_position, cx); } workspace.add_panel(terminal_panel, cx) diff --git a/styles/src/styleTree/tabBar.ts b/styles/src/styleTree/tabBar.ts index 61d30be7e0..39a1ef0407 100644 --- a/styles/src/styleTree/tabBar.ts +++ b/styles/src/styleTree/tabBar.ts @@ -94,6 +94,9 @@ export default function tabBar(colorScheme: ColorScheme) { hover: { color: foreground(layer, "hovered"), }, + active: { + color: foreground(layer, "accent"), + } }, paneButtonContainer: { background: tab.background, diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts index 737d225784..ec992c9c0b 100644 --- a/styles/src/styleTree/workspace.ts +++ b/styles/src/styleTree/workspace.ts @@ -13,6 +13,7 @@ import tabBar from "./tabBar" export default function workspace(colorScheme: ColorScheme) { const layer = colorScheme.lowest + const isLight = colorScheme.isLight const itemSpacing = 8 const titlebarButton = { cornerRadius: 6, @@ -119,13 +120,19 @@ export default function workspace(colorScheme: ColorScheme) { cursor: "Arrow", }, zoomedBackground: { - padding: 10, cursor: "Arrow", - background: withOpacity(background(colorScheme.lowest), 0.5) + background: isLight + ? withOpacity(background(colorScheme.lowest), 0.8) + : withOpacity(background(colorScheme.highest), 0.6) }, - zoomedForeground: { + zoomedPaneForeground: { + margin: 16, shadow: colorScheme.modalShadow, - border: border(colorScheme.highest, { overlay: true }), + border: border(colorScheme.lowest, { overlay: true }), + }, + zoomedPanelForeground: { + margin: 16, + border: border(colorScheme.lowest, { overlay: true }), }, dock: { left: {