diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1c16b2d4d..a906c8b82d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: rustup set profile minimal rustup update stable rustup target add wasm32-wasi + cargo install cargo-nextest - name: Install Node uses: actions/setup-node@v2 @@ -70,7 +71,7 @@ jobs: run: cargo check --workspace - name: Run tests - run: cargo test --workspace --no-fail-fast + run: cargo nextest run --workspace --no-fail-fast - name: Build collab run: cargo build -p collab diff --git a/.gitignore b/.gitignore index 12711bd349..dbffa0f829 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ DerivedData/ .swiftpm/config/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.swiftpm **/*.db diff --git a/Cargo.lock b/Cargo.lock index 3f6ebf306f..3e28404fc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,7 @@ dependencies = [ "serde", "serde_json", "settings", + "smol", "theme", "tiktoken-rs", "util", @@ -642,7 +643,7 @@ dependencies = [ "http", "http-body", "hyper", - "itoa", + "itoa 1.0.6", "matchit", "memchr", "mime", @@ -3108,7 +3109,7 @@ checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" dependencies = [ "bytes 1.4.0", "fnv", - "itoa", + "itoa 1.0.6", ] [[package]] @@ -3167,7 +3168,7 @@ dependencies = [ "http-body", "httparse", "httpdate", - "itoa", + "itoa 1.0.6", "pin-project-lite 0.2.9", "socket2", "tokio", @@ -3433,6 +3434,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + [[package]] name = "itoa" version = "1.0.6" @@ -3493,12 +3500,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json_comments" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ee439ee368ba4a77ac70d04f14015415af8600d6c894dc1f11bd79758c57d5" - [[package]] name = "jwt" version = "0.16.0" @@ -3612,6 +3613,29 @@ dependencies = [ "workspace", ] +[[package]] +name = "language_tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "collections", + "editor", + "env_logger 0.9.3", + "futures 0.3.28", + "gpui", + "language", + "lsp", + "project", + "serde", + "settings", + "theme", + "tree-sitter", + "unindent", + "util", + "workspace", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -3856,28 +3880,6 @@ dependencies = [ "url", ] -[[package]] -name = "lsp_log" -version = "0.1.0" -dependencies = [ - "anyhow", - "client", - "collections", - "editor", - "env_logger 0.9.3", - "futures 0.3.28", - "gpui", - "language", - "lsp", - "project", - "serde", - "settings", - "theme", - "unindent", - "util", - "workspace", -] - [[package]] name = "mach" version = "0.3.2" @@ -5763,7 +5765,7 @@ dependencies = [ "bitflags", "errno 0.2.8", "io-lifetimes 0.5.3", - "itoa", + "itoa 1.0.6", "libc", "linux-raw-sys 0.0.42", "once_cell", @@ -6195,7 +6197,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1" dependencies = [ "indexmap", - "itoa", + "itoa 1.0.6", + "ryu", + "serde", +] + +[[package]] +name = "serde_json_lenient" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d7b9ce5b0a63c6269b9623ed828b39259545a6ec0d8a35d6135ad6af6232add" +dependencies = [ + "indexmap", + "itoa 0.4.8", "ryu", "serde", ] @@ -6218,7 +6232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa", + "itoa 1.0.6", "ryu", "serde", ] @@ -6244,7 +6258,7 @@ dependencies = [ "fs", "futures 0.3.28", "gpui", - "json_comments", + "indoc", "lazy_static", "postage", "pretty_assertions", @@ -6253,6 +6267,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "serde_json_lenient", "smallvec", "sqlez", "staff_mode", @@ -6603,7 +6618,7 @@ dependencies = [ "hkdf", "hmac 0.12.1", "indexmap", - "itoa", + "itoa 1.0.6", "libc", "libsqlite3-sys", "log", @@ -6998,18 +7013,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "theme_testbench" -version = "0.1.0" -dependencies = [ - "gpui", - "project", - "settings", - "smallvec", - "theme", - "workspace", -] - [[package]] name = "thiserror" version = "1.0.40" @@ -7089,7 +7092,7 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f3403384eaacbca9923fa06940178ac13e4edb725486d70e8e15881d0c836cc" dependencies = [ - "itoa", + "itoa 1.0.6", "serde", "time-core", "time-macros", @@ -7455,8 +7458,8 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.20.9" -source = "git+https://github.com/tree-sitter/tree-sitter?rev=c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14#c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14" +version = "0.20.10" +source = "git+https://github.com/tree-sitter/tree-sitter?rev=49226023693107fba9a1191136a4f47f38cdca73#49226023693107fba9a1191136a4f47f38cdca73" dependencies = [ "cc", "regex", @@ -7519,6 +7522,15 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-heex" +version = "0.0.1" +source = "git+https://github.com/phoenixframework/tree-sitter-heex?rev=2e1348c3cf2c9323e87c2744796cf3f3868aa82a#2e1348c3cf2c9323e87c2744796cf3f3868aa82a" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-html" version = "0.19.0" @@ -8893,7 +8905,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.91.0" +version = "0.93.0" dependencies = [ "activity_indicator", "ai", @@ -8937,11 +8949,11 @@ dependencies = [ "journal", "language", "language_selector", + "language_tools", "lazy_static", "libc", "log", "lsp", - "lsp_log", "node_runtime", "num_cpus", "outline", @@ -8972,7 +8984,6 @@ dependencies = [ "text", "theme", "theme_selector", - "theme_testbench", "thiserror", "tiny_http", "toml", @@ -8983,6 +8994,7 @@ dependencies = [ "tree-sitter-elixir", "tree-sitter-embedded-template", "tree-sitter-go", + "tree-sitter-heex", "tree-sitter-html", "tree-sitter-json 0.20.0", "tree-sitter-lua", diff --git a/Cargo.toml b/Cargo.toml index 528b7199cf..18e8e221a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,10 +32,10 @@ members = [ "crates/journal", "crates/language", "crates/language_selector", + "crates/language_tools", "crates/live_kit_client", "crates/live_kit_server", "crates/lsp", - "crates/lsp_log", "crates/media", "crates/menu", "crates/node_runtime", @@ -61,7 +61,6 @@ members = [ "crates/text", "crates/theme", "crates/theme_selector", - "crates/theme_testbench", "crates/util", "crates/vim", "crates/workspace", @@ -99,10 +98,11 @@ tempdir = { version = "0.3.7" } thiserror = { version = "1.0.29" } time = { version = "0.3", features = ["serde", "serde-well-known"] } toml = { version = "0.5" } +tree-sitter = "0.20" unindent = { version = "0.1.7" } [patch.crates-io] -tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14" } +tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "49226023693107fba9a1191136a4f47f38cdca73" } async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" } # TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/457 diff --git a/assets/icons/robot_14.svg b/assets/icons/robot_14.svg new file mode 100644 index 0000000000..7b6dc3f752 --- /dev/null +++ b/assets/icons/robot_14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/keymaps/atom.json b/assets/keymaps/atom.json index 25143914cc..60acf5ea6f 100644 --- a/assets/keymaps/atom.json +++ b/assets/keymaps/atom.json @@ -55,7 +55,40 @@ "context": "Pane", "bindings": { "alt-cmd-/": "search::ToggleRegex", - "ctrl-0": "project_panel::ToggleFocus" + "ctrl-0": "project_panel::ToggleFocus", + "cmd-1": [ + "pane::ActivateItem", + 0 + ], + "cmd-2": [ + "pane::ActivateItem", + 1 + ], + "cmd-3": [ + "pane::ActivateItem", + 2 + ], + "cmd-4": [ + "pane::ActivateItem", + 3 + ], + "cmd-5": [ + "pane::ActivateItem", + 4 + ], + "cmd-6": [ + "pane::ActivateItem", + 5 + ], + "cmd-7": [ + "pane::ActivateItem", + 6 + ], + "cmd-8": [ + "pane::ActivateItem", + 7 + ], + "cmd-9": "pane::ActivateLastItem" } }, { diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 45e85fd04f..84b11b53a1 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -200,7 +200,9 @@ "context": "AssistantEditor > Editor", "bindings": { "cmd-enter": "assistant::Assist", - "cmd->": "assistant::QuoteSelection" + "cmd->": "assistant::QuoteSelection", + "shift-enter": "assistant::Split", + "ctrl-r": "assistant::CycleMessageRole" } }, { @@ -409,6 +411,7 @@ "ctrl-shift-k": "editor::DeleteLine", "cmd-shift-d": "editor::DuplicateLine", "cmd-shift-l": "editor::SplitSelectionIntoLines", + "ctrl-j": "editor::JoinLines", "ctrl-cmd-up": "editor::MoveLineUp", "ctrl-cmd-down": "editor::MoveLineDown", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 47c5f8c458..05159e9446 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -25,11 +25,15 @@ } ], "h": "vim::Left", + "left": "vim::Left", "backspace": "vim::Backspace", "j": "vim::Down", + "down": "vim::Down", "enter": "vim::NextLineStart", "k": "vim::Up", + "up": "vim::Up", "l": "vim::Right", + "right": "vim::Right", "$": "vim::EndOfLine", "shift-g": "vim::EndOfDocument", "w": "vim::NextWordStart", @@ -90,6 +94,8 @@ } } ], + "ctrl-o": "pane::GoBack", + "ctrl-]": "editor::GoToDefinition", "escape": "editor::Cancel", "0": "vim::StartOfLine", // When no number operator present, use start of line motion "1": [ @@ -143,6 +149,7 @@ "Delete" ], "shift-d": "vim::DeleteToEndOfLine", + "shift-j": "editor::JoinLines", "y": [ "vim::PushOperator", "Yank" @@ -184,7 +191,6 @@ "p": "vim::Paste", "u": "editor::Undo", "ctrl-r": "editor::Redo", - "ctrl-o": "pane::GoBack", "/": [ "buffer_search::Deploy", { diff --git a/assets/settings/default.json b/assets/settings/default.json index c2c372fe51..bd73bcbf08 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -108,6 +108,8 @@ // Whether or not to remove any trailing whitespace from lines of a buffer // before saving it. "remove_trailing_whitespace_on_save": true, + // Whether to start a new line with a comment when a previous line is a comment as well. + "extend_comment_on_newline": true, // Whether or not to ensure there's a single newline at the end of a buffer // when saving it. "ensure_final_newline_on_save": true, diff --git a/assets/settings/initial_local_settings.json b/assets/settings/initial_local_settings.json index 69be683aa8..82ddf88042 100644 --- a/assets/settings/initial_local_settings.json +++ b/assets/settings/initial_local_settings.json @@ -1,11 +1,5 @@ -// Folder-specific Zed settings +// Folder-specific settings // -// A subset of Zed's settings can be configured on a per-folder basis. -// -// For information on how to configure Zed, see the Zed -// documentation: https://zed.dev/docs/configuring-zed -// -// To see all of Zed's default settings without changing your -// custom settings, run the `open default settings` command -// from the command palette or from `Zed` application menu. +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://docs.zed.dev/configuration/configuring-zed#folder-specific-settings {} diff --git a/assets/settings/initial_user_settings.json b/assets/settings/initial_user_settings.json index 2af677da9a..dc79fd7911 100644 --- a/assets/settings/initial_user_settings.json +++ b/assets/settings/initial_user_settings.json @@ -1,7 +1,7 @@ -// Folder-specific settings +// Zed settings // -// For a full list of overridable settings, and general information on folder-specific settings, see the documentation: -// https://docs.zed.dev/configuration/configuring-zed#folder-specific-settings +// For information on how to configure Zed, see the Zed +// documentation: https://zed.dev/docs/configuring-zed // // To see all of Zed's default settings without changing your // custom settings, run the `open default settings` command diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 801c8f7172..f795d7321c 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -326,7 +326,7 @@ impl View for ActivityIndicator { let mut element = MouseEventHandler::::new(0, cx, |state, cx| { let theme = &theme::current(cx).workspace.status_bar.lsp_status; let style = if state.hovered() && on_click.is_some() { - theme.hover.as_ref().unwrap_or(&theme.default) + theme.hovered.as_ref().unwrap_or(&theme.default) } else { &theme.default }; diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 9d67cbd108..7f8954bb21 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -28,6 +28,7 @@ isahc.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +smol.workspace = true tiktoken-rs = "0.4" [dev-dependencies] diff --git a/crates/ai/src/ai.rs b/crates/ai/src/ai.rs index 40224b3229..b3b62c6a24 100644 --- a/crates/ai/src/ai.rs +++ b/crates/ai/src/ai.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use std::fmt::{self, Display}; // Data types for chat completion requests -#[derive(Serialize)] +#[derive(Debug, Serialize)] struct OpenAIRequest { model: String, messages: Vec, diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index 77353e1ee4..5b254fac4b 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -6,12 +6,9 @@ use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; use collections::{HashMap, HashSet}; use editor::{ - display_map::ToDisplayPoint, - scroll::{ - autoscroll::{Autoscroll, AutoscrollStrategy}, - ScrollAnchor, - }, - Anchor, DisplayPoint, Editor, ExcerptId, ExcerptRange, MultiBuffer, + display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint}, + scroll::autoscroll::{Autoscroll, AutoscrollStrategy}, + Anchor, Editor, ToOffset, }; use fs::Fs; use futures::{io::BufReader, AsyncBufReadExt, AsyncReadExt, Stream, StreamExt}; @@ -19,17 +16,20 @@ use gpui::{ actions, elements::*, executor::Background, - geometry::vector::vec2f, + geometry::vector::{vec2f, Vector2F}, platform::{CursorStyle, MouseButton}, Action, AppContext, AsyncAppContext, ClipboardItem, Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext, }; use isahc::{http::StatusCode, Request, RequestExt}; -use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; +use language::{language_settings::SoftWrap, Buffer, LanguageRegistry, ToOffset as _}; use serde::Deserialize; use settings::SettingsStore; -use std::{borrow::Cow, cell::RefCell, cmp, fmt::Write, io, rc::Rc, sync::Arc, time::Duration}; -use util::{post_inc, truncate_and_trailoff, ResultExt, TryFutureExt}; +use std::{ + borrow::Cow, cell::RefCell, cmp, fmt::Write, io, iter, ops::Range, rc::Rc, sync::Arc, + time::Duration, +}; +use util::{channel::ReleaseChannel, post_inc, truncate_and_trailoff, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, item::Item, @@ -40,10 +40,24 @@ const OPENAI_API_URL: &'static str = "https://api.openai.com/v1"; actions!( assistant, - [NewContext, Assist, QuoteSelection, ToggleFocus, ResetKey] + [ + NewContext, + Assist, + Split, + CycleMessageRole, + QuoteSelection, + ToggleFocus, + ResetKey + ] ); pub fn init(cx: &mut AppContext) { + if *util::channel::RELEASE_CHANNEL == ReleaseChannel::Stable { + cx.update_default_global::(move |filter, _cx| { + filter.filtered_namespaces.insert("assistant"); + }); + } + settings::register::(cx); cx.add_action( |workspace: &mut Workspace, _: &NewContext, cx: &mut ViewContext| { @@ -58,8 +72,15 @@ pub fn init(cx: &mut AppContext) { cx.capture_action(AssistantEditor::cancel_last_assist); cx.add_action(AssistantEditor::quote_selection); cx.capture_action(AssistantEditor::copy); + cx.capture_action(AssistantEditor::split); + cx.capture_action(AssistantEditor::cycle_message_role); cx.add_action(AssistantPanel::save_api_key); cx.add_action(AssistantPanel::reset_api_key); + cx.add_action( + |workspace: &mut Workspace, _: &ToggleFocus, cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ); } pub enum AssistantPanelEvent { @@ -387,7 +408,7 @@ impl Panel for AssistantPanel { } fn icon_path(&self) -> &'static str { - "icons/speech_bubble_12.svg" + "icons/robot_14.svg" } fn icon_tooltip(&self) -> (String, Option>) { @@ -420,20 +441,20 @@ impl Panel for AssistantPanel { } enum AssistantEvent { - MessagesEdited { ids: Vec }, + MessagesEdited, SummaryChanged, StreamedCompletion, } struct Assistant { - buffer: ModelHandle, - messages: Vec, - messages_metadata: HashMap, + buffer: ModelHandle, + message_anchors: Vec, + messages_metadata: HashMap, + next_message_id: MessageId, summary: Option, pending_summary: Task>, completion_count: usize, pending_completions: Vec, - languages: Arc, model: String, token_count: Option, max_token_count: usize, @@ -452,16 +473,33 @@ impl Assistant { language_registry: Arc, cx: &mut ModelContext, ) -> Self { - let model = "gpt-3.5-turbo"; - let buffer = cx.add_model(|_| MultiBuffer::new(0)); + let model = "gpt-3.5-turbo-0613"; + let markdown = language_registry.language_for_name("Markdown"); + let buffer = cx.add_model(|cx| { + let mut buffer = Buffer::new(0, "", cx); + buffer.set_language_registry(language_registry); + cx.spawn_weak(|buffer, mut cx| async move { + let markdown = markdown.await?; + let buffer = buffer + .upgrade(&cx) + .ok_or_else(|| anyhow!("buffer was dropped"))?; + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(Some(markdown), cx) + }); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + buffer + }); + let mut this = Self { - messages: Default::default(), + message_anchors: Default::default(), messages_metadata: Default::default(), + next_message_id: Default::default(), summary: None, pending_summary: Task::ready(None), completion_count: Default::default(), pending_completions: Default::default(), - languages: language_registry, token_count: None, max_token_count: tiktoken_rs::model::get_context_size(model), pending_token_count: Task::ready(None), @@ -470,23 +508,34 @@ impl Assistant { api_key, buffer, }; - this.insert_message_after(ExcerptId::max(), Role::User, cx); + let message = MessageAnchor { + id: MessageId(post_inc(&mut this.next_message_id.0)), + start: language::Anchor::MIN, + }; + this.message_anchors.push(message.clone()); + this.messages_metadata.insert( + message.id, + MessageMetadata { + role: Role::User, + sent_at: Local::now(), + status: MessageStatus::Done, + }, + ); + this.count_remaining_tokens(cx); this } fn handle_buffer_event( &mut self, - _: ModelHandle, - event: &editor::multi_buffer::Event, + _: ModelHandle, + event: &language::Event, cx: &mut ModelContext, ) { match event { - editor::multi_buffer::Event::ExcerptsAdded { .. } - | editor::multi_buffer::Event::ExcerptsRemoved { .. } - | editor::multi_buffer::Event::Edited => self.count_remaining_tokens(cx), - editor::multi_buffer::Event::ExcerptsEdited { ids } => { - cx.emit(AssistantEvent::MessagesEdited { ids: ids.clone() }); + language::Event::Edited => { + self.count_remaining_tokens(cx); + cx.emit(AssistantEvent::MessagesEdited); } _ => {} } @@ -494,16 +543,16 @@ impl Assistant { fn count_remaining_tokens(&mut self, cx: &mut ModelContext) { let messages = self - .messages - .iter() + .messages(cx) + .into_iter() .filter_map(|message| { Some(tiktoken_rs::ChatCompletionRequestMessage { - role: match self.messages_metadata.get(&message.excerpt_id)?.role { + role: match message.role { Role::User => "user".into(), Role::Assistant => "assistant".into(), Role::System => "system".into(), }, - content: message.content.read(cx).text(), + content: self.buffer.read(cx).text_for_range(message.range).collect(), name: None, }) }) @@ -540,210 +589,336 @@ impl Assistant { cx.notify(); } - fn assist(&mut self, cx: &mut ModelContext) -> Option<(Message, Message)> { - let messages = self - .messages - .iter() - .filter_map(|message| { - Some(RequestMessage { - role: self.messages_metadata.get(&message.excerpt_id)?.role, - content: message.content.read(cx).text(), - }) - }) - .collect(); - let request = OpenAIRequest { - model: self.model.clone(), - messages, - stream: true, - }; - - let api_key = self.api_key.borrow().clone()?; - let stream = stream_completion(api_key, cx.background().clone(), request); - let assistant_message = self.insert_message_after(ExcerptId::max(), Role::Assistant, cx); - let user_message = self.insert_message_after(ExcerptId::max(), Role::User, cx); - let task = cx.spawn_weak({ - let assistant_message = assistant_message.clone(); - |this, mut cx| async move { - let assistant_message = assistant_message; - let stream_completion = async { - let mut messages = stream.await?; - - while let Some(message) = messages.next().await { - let mut message = message?; - if let Some(choice) = message.choices.pop() { - assistant_message.content.update(&mut cx, |content, cx| { - let text: Arc = choice.delta.content?.into(); - content.edit([(content.len()..content.len(), text)], None, cx); - Some(()) - }); - this.upgrade(&cx) - .ok_or_else(|| anyhow!("assistant was dropped"))? - .update(&mut cx, |_, cx| { - cx.emit(AssistantEvent::StreamedCompletion); - }); - } - } - - this.upgrade(&cx) - .ok_or_else(|| anyhow!("assistant was dropped"))? - .update(&mut cx, |this, cx| { - this.pending_completions - .retain(|completion| completion.id != this.completion_count); - this.summarize(cx); - }); - - anyhow::Ok(()) + fn assist( + &mut self, + selected_messages: HashSet, + cx: &mut ModelContext, + ) -> Vec { + let mut user_messages = Vec::new(); + let mut tasks = Vec::new(); + for selected_message_id in selected_messages { + let selected_message_role = + if let Some(metadata) = self.messages_metadata.get(&selected_message_id) { + metadata.role + } else { + continue; }; - let result = stream_completion.await; - if let Some(this) = this.upgrade(&cx) { - this.update(&mut cx, |this, cx| { - if let Err(error) = result { - if let Some(metadata) = this - .messages_metadata - .get_mut(&assistant_message.excerpt_id) - { - metadata.error = Some(error.to_string().trim().into()); - cx.notify(); - } - } - }); + if selected_message_role == Role::Assistant { + if let Some(user_message) = self.insert_message_after( + selected_message_id, + Role::User, + MessageStatus::Done, + cx, + ) { + user_messages.push(user_message); + } else { + continue; } - } - }); + } else { + let request = OpenAIRequest { + model: self.model.clone(), + messages: self + .messages(cx) + .filter(|message| matches!(message.status, MessageStatus::Done)) + .flat_map(|message| { + let mut system_message = None; + if message.id == selected_message_id { + system_message = Some(RequestMessage { + role: Role::System, + content: concat!( + "Treat the following messages as additional knowledge you have learned about, ", + "but act as if they were not part of this conversation. That is, treat them ", + "as if the user didn't see them and couldn't possibly inquire about them." + ).into() + }); + } - self.pending_completions.push(PendingCompletion { - id: post_inc(&mut self.completion_count), - _task: task, - }); - Some((assistant_message, user_message)) + Some(message.to_open_ai_message(self.buffer.read(cx))).into_iter().chain(system_message) + }) + .chain(Some(RequestMessage { + role: Role::System, + content: format!( + "Direct your reply to message with id {}. Do not include a [Message X] header.", + selected_message_id.0 + ), + })) + .collect(), + stream: true, + }; + + let Some(api_key) = self.api_key.borrow().clone() else { continue }; + let stream = stream_completion(api_key, cx.background().clone(), request); + let assistant_message = self + .insert_message_after( + selected_message_id, + Role::Assistant, + MessageStatus::Pending, + cx, + ) + .unwrap(); + + tasks.push(cx.spawn_weak({ + |this, mut cx| async move { + let assistant_message_id = assistant_message.id; + let stream_completion = async { + let mut messages = stream.await?; + + while let Some(message) = messages.next().await { + let mut message = message?; + if let Some(choice) = message.choices.pop() { + this.upgrade(&cx) + .ok_or_else(|| anyhow!("assistant was dropped"))? + .update(&mut cx, |this, cx| { + let text: Arc = choice.delta.content?.into(); + let message_ix = this.message_anchors.iter().position( + |message| message.id == assistant_message_id, + )?; + this.buffer.update(cx, |buffer, cx| { + let offset = this.message_anchors[message_ix + 1..] + .iter() + .find(|message| message.start.is_valid(buffer)) + .map_or(buffer.len(), |message| { + message + .start + .to_offset(buffer) + .saturating_sub(1) + }); + buffer.edit([(offset..offset, text)], None, cx); + }); + cx.emit(AssistantEvent::StreamedCompletion); + + Some(()) + }); + } + smol::future::yield_now().await; + } + + this.upgrade(&cx) + .ok_or_else(|| anyhow!("assistant was dropped"))? + .update(&mut cx, |this, cx| { + this.pending_completions.retain(|completion| { + completion.id != this.completion_count + }); + this.summarize(cx); + }); + + anyhow::Ok(()) + }; + + let result = stream_completion.await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + if let Some(metadata) = + this.messages_metadata.get_mut(&assistant_message.id) + { + match result { + Ok(_) => { + metadata.status = MessageStatus::Done; + } + Err(error) => { + metadata.status = MessageStatus::Error( + error.to_string().trim().into(), + ); + } + } + cx.notify(); + } + }); + } + } + })); + } + } + + if !tasks.is_empty() { + self.pending_completions.push(PendingCompletion { + id: post_inc(&mut self.completion_count), + _tasks: tasks, + }); + } + + user_messages } fn cancel_last_assist(&mut self) -> bool { self.pending_completions.pop().is_some() } - fn remove_empty_messages<'a>( - &mut self, - excerpts: HashSet, - protected_offsets: HashSet, - cx: &mut ModelContext, - ) { - let mut offset = 0; - let mut excerpts_to_remove = Vec::new(); - self.messages.retain(|message| { - let range = offset..offset + message.content.read(cx).len(); - offset = range.end + 1; - if range.is_empty() - && !protected_offsets.contains(&range.start) - && excerpts.contains(&message.excerpt_id) - { - excerpts_to_remove.push(message.excerpt_id); - self.messages_metadata.remove(&message.excerpt_id); - false - } else { - true + fn cycle_message_roles(&mut self, ids: HashSet, cx: &mut ModelContext) { + for id in ids { + if let Some(metadata) = self.messages_metadata.get_mut(&id) { + metadata.role.cycle(); + cx.emit(AssistantEvent::MessagesEdited); + cx.notify(); } - }); - - if !excerpts_to_remove.is_empty() { - self.buffer.update(cx, |buffer, cx| { - buffer.remove_excerpts(excerpts_to_remove, cx) - }); - cx.notify(); - } - } - - fn cycle_message_role(&mut self, excerpt_id: ExcerptId, cx: &mut ModelContext) { - if let Some(metadata) = self.messages_metadata.get_mut(&excerpt_id) { - metadata.role.cycle(); - cx.notify(); } } fn insert_message_after( &mut self, - excerpt_id: ExcerptId, + message_id: MessageId, role: Role, + status: MessageStatus, cx: &mut ModelContext, - ) -> Message { - let content = cx.add_model(|cx| { - let mut buffer = Buffer::new(0, "", cx); - let markdown = self.languages.language_for_name("Markdown"); - cx.spawn_weak(|buffer, mut cx| async move { - let markdown = markdown.await?; - let buffer = buffer - .upgrade(&cx) - .ok_or_else(|| anyhow!("buffer was dropped"))?; - buffer.update(&mut cx, |buffer, cx| { - buffer.set_language(Some(markdown), cx) - }); - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - buffer.set_language_registry(self.languages.clone()); - buffer - }); - let new_excerpt_id = self.buffer.update(cx, |buffer, cx| { - buffer - .insert_excerpts_after( - excerpt_id, - content.clone(), - vec![ExcerptRange { - context: 0..0, - primary: None, - }], - cx, - ) - .pop() - .unwrap() - }); - - let ix = self - .messages + ) -> Option { + if let Some(prev_message_ix) = self + .message_anchors .iter() - .position(|message| message.excerpt_id == excerpt_id) - .map_or(self.messages.len(), |ix| ix + 1); - let message = Message { - excerpt_id: new_excerpt_id, - content: content.clone(), - }; - self.messages.insert(ix, message.clone()); - self.messages_metadata.insert( - new_excerpt_id, - MessageMetadata { - role, - sent_at: Local::now(), - error: None, - }, - ); - message + .position(|message| message.id == message_id) + { + let start = self.buffer.update(cx, |buffer, cx| { + let offset = self.message_anchors[prev_message_ix + 1..] + .iter() + .find(|message| message.start.is_valid(buffer)) + .map_or(buffer.len(), |message| message.start.to_offset(buffer) - 1); + buffer.edit([(offset..offset, "\n")], None, cx); + buffer.anchor_before(offset + 1) + }); + let message = MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start, + }; + self.message_anchors + .insert(prev_message_ix + 1, message.clone()); + self.messages_metadata.insert( + message.id, + MessageMetadata { + role, + sent_at: Local::now(), + status, + }, + ); + cx.emit(AssistantEvent::MessagesEdited); + Some(message) + } else { + None + } + } + + fn split_message( + &mut self, + range: Range, + cx: &mut ModelContext, + ) -> (Option, Option) { + let start_message = self.message_for_offset(range.start, cx); + let end_message = self.message_for_offset(range.end, cx); + if let Some((start_message, end_message)) = start_message.zip(end_message) { + // Prevent splitting when range spans multiple messages. + if start_message.index != end_message.index { + return (None, None); + } + + let message = start_message; + let role = message.role; + let mut edited_buffer = false; + + let mut suffix_start = None; + if range.start > message.range.start && range.end < message.range.end - 1 { + if self.buffer.read(cx).chars_at(range.end).next() == Some('\n') { + suffix_start = Some(range.end + 1); + } else if self.buffer.read(cx).reversed_chars_at(range.end).next() == Some('\n') { + suffix_start = Some(range.end); + } + } + + let suffix = if let Some(suffix_start) = suffix_start { + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(suffix_start), + } + } else { + self.buffer.update(cx, |buffer, cx| { + buffer.edit([(range.end..range.end, "\n")], None, cx); + }); + edited_buffer = true; + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(range.end + 1), + } + }; + + self.message_anchors + .insert(message.index + 1, suffix.clone()); + self.messages_metadata.insert( + suffix.id, + MessageMetadata { + role, + sent_at: Local::now(), + status: MessageStatus::Done, + }, + ); + + let new_messages = if range.start == range.end || range.start == message.range.start { + (None, Some(suffix)) + } else { + let mut prefix_end = None; + if range.start > message.range.start && range.end < message.range.end - 1 { + if self.buffer.read(cx).chars_at(range.start).next() == Some('\n') { + prefix_end = Some(range.start + 1); + } else if self.buffer.read(cx).reversed_chars_at(range.start).next() + == Some('\n') + { + prefix_end = Some(range.start); + } + } + + let selection = if let Some(prefix_end) = prefix_end { + cx.emit(AssistantEvent::MessagesEdited); + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(prefix_end), + } + } else { + self.buffer.update(cx, |buffer, cx| { + buffer.edit([(range.start..range.start, "\n")], None, cx) + }); + edited_buffer = true; + MessageAnchor { + id: MessageId(post_inc(&mut self.next_message_id.0)), + start: self.buffer.read(cx).anchor_before(range.end + 1), + } + }; + + self.message_anchors + .insert(message.index + 1, selection.clone()); + self.messages_metadata.insert( + selection.id, + MessageMetadata { + role, + sent_at: Local::now(), + status: MessageStatus::Done, + }, + ); + (Some(selection), Some(suffix)) + }; + + if !edited_buffer { + cx.emit(AssistantEvent::MessagesEdited); + } + new_messages + } else { + (None, None) + } } fn summarize(&mut self, cx: &mut ModelContext) { - if self.messages.len() >= 2 && self.summary.is_none() { + if self.message_anchors.len() >= 2 && self.summary.is_none() { let api_key = self.api_key.borrow().clone(); if let Some(api_key) = api_key { let messages = self - .messages - .iter() + .messages(cx) .take(2) - .filter_map(|message| { - Some(RequestMessage { - role: self.messages_metadata.get(&message.excerpt_id)?.role, - content: message.content.read(cx).text(), - }) - }) + .map(|message| message.to_open_ai_message(self.buffer.read(cx))) .chain(Some(RequestMessage { role: Role::User, content: "Summarize the conversation into a short title without punctuation" .into(), - })) - .collect(); + })); let request = OpenAIRequest { model: self.model.clone(), - messages, + messages: messages.collect(), stream: true, }; @@ -770,21 +945,96 @@ impl Assistant { } } } + + fn message_for_offset(&self, offset: usize, cx: &AppContext) -> Option { + self.messages_for_offsets([offset], cx).pop() + } + + fn messages_for_offsets( + &self, + offsets: impl IntoIterator, + cx: &AppContext, + ) -> Vec { + let mut result = Vec::new(); + + let buffer_len = self.buffer.read(cx).len(); + let mut messages = self.messages(cx).peekable(); + let mut offsets = offsets.into_iter().peekable(); + while let Some(offset) = offsets.next() { + // Skip messages that start after the offset. + while messages.peek().map_or(false, |message| { + message.range.end < offset || (message.range.end == offset && offset < buffer_len) + }) { + messages.next(); + } + let Some(message) = messages.peek() else { continue }; + + // Skip offsets that are in the same message. + while offsets.peek().map_or(false, |offset| { + message.range.contains(offset) || message.range.end == buffer_len + }) { + offsets.next(); + } + + result.push(message.clone()); + } + result + } + + fn messages<'a>(&'a self, cx: &'a AppContext) -> impl 'a + Iterator { + let buffer = self.buffer.read(cx); + let mut message_anchors = self.message_anchors.iter().enumerate().peekable(); + iter::from_fn(move || { + while let Some((ix, message_anchor)) = message_anchors.next() { + let metadata = self.messages_metadata.get(&message_anchor.id)?; + let message_start = message_anchor.start.to_offset(buffer); + let mut message_end = None; + while let Some((_, next_message)) = message_anchors.peek() { + if next_message.start.is_valid(buffer) { + message_end = Some(next_message.start); + break; + } else { + message_anchors.next(); + } + } + let message_end = message_end + .unwrap_or(language::Anchor::MAX) + .to_offset(buffer); + return Some(Message { + index: ix, + range: message_start..message_end, + id: message_anchor.id, + anchor: message_anchor.start, + role: metadata.role, + sent_at: metadata.sent_at, + status: metadata.status.clone(), + }); + } + None + }) + } } struct PendingCompletion { id: usize, - _task: Task<()>, + _tasks: Vec>, } enum AssistantEditorEvent { TabContentChanged, } +#[derive(Copy, Clone, Debug, PartialEq)] +struct ScrollPosition { + offset_before_cursor: Vector2F, + cursor: Anchor, +} + struct AssistantEditor { assistant: ModelHandle, editor: ViewHandle, - scroll_bottom: ScrollAnchor, + blocks: HashSet, + scroll_position: Option, _subscriptions: Vec, } @@ -796,98 +1046,9 @@ impl AssistantEditor { ) -> Self { let assistant = cx.add_model(|cx| Assistant::new(api_key, language_registry, cx)); let editor = cx.add_view(|cx| { - let mut editor = Editor::for_multibuffer(assistant.read(cx).buffer.clone(), None, cx); + let mut editor = Editor::for_buffer(assistant.read(cx).buffer.clone(), None, cx); editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); editor.set_show_gutter(false, cx); - editor.set_render_excerpt_header( - { - let assistant = assistant.clone(); - move |_editor, params: editor::RenderExcerptHeaderParams, cx| { - enum Sender {} - enum ErrorTooltip {} - - let theme = theme::current(cx); - let style = &theme.assistant; - let excerpt_id = params.id; - if let Some(metadata) = assistant - .read(cx) - .messages_metadata - .get(&excerpt_id) - .cloned() - { - let sender = MouseEventHandler::::new( - params.id.into(), - cx, - |state, _| match metadata.role { - Role::User => { - let style = style.user_sender.style_for(state, false); - Label::new("You", style.text.clone()) - .contained() - .with_style(style.container) - } - Role::Assistant => { - let style = style.assistant_sender.style_for(state, false); - Label::new("Assistant", style.text.clone()) - .contained() - .with_style(style.container) - } - Role::System => { - let style = style.system_sender.style_for(state, false); - Label::new("System", style.text.clone()) - .contained() - .with_style(style.container) - } - }, - ) - .with_cursor_style(CursorStyle::PointingHand) - .on_down(MouseButton::Left, { - let assistant = assistant.clone(); - move |_, _, cx| { - assistant.update(cx, |assistant, cx| { - assistant.cycle_message_role(excerpt_id, cx) - }) - } - }); - - Flex::row() - .with_child(sender.aligned()) - .with_child( - Label::new( - metadata.sent_at.format("%I:%M%P").to_string(), - style.sent_at.text.clone(), - ) - .contained() - .with_style(style.sent_at.container) - .aligned(), - ) - .with_children(metadata.error.map(|error| { - Svg::new("icons/circle_x_mark_12.svg") - .with_color(style.error_icon.color) - .constrained() - .with_width(style.error_icon.width) - .contained() - .with_style(style.error_icon.container) - .with_tooltip::( - params.id.into(), - error, - None, - theme.tooltip.clone(), - cx, - ) - .aligned() - })) - .aligned() - .left() - .contained() - .with_style(style.header) - .into_any() - } else { - Empty::new().into_any() - } - } - }, - cx, - ); editor }); @@ -897,60 +1058,45 @@ impl AssistantEditor { cx.subscribe(&editor, Self::handle_editor_event), ]; - Self { + let mut this = Self { assistant, editor, - scroll_bottom: ScrollAnchor { - offset: Default::default(), - anchor: Anchor::max(), - }, + blocks: Default::default(), + scroll_position: None, _subscriptions, - } + }; + this.update_message_headers(cx); + this } fn assist(&mut self, _: &Assist, cx: &mut ViewContext) { - let user_message = self.assistant.update(cx, |assistant, cx| { - let editor = self.editor.read(cx); - let newest_selection = editor.selections.newest_anchor(); - let excerpt_id = if newest_selection.head() == Anchor::min() { - assistant - .messages - .first() - .map(|message| message.excerpt_id)? - } else if newest_selection.head() == Anchor::max() { - assistant - .messages - .last() - .map(|message| message.excerpt_id)? - } else { - newest_selection.head().excerpt_id() - }; + let cursors = self.cursors(cx); - let metadata = assistant.messages_metadata.get(&excerpt_id)?; - let user_message = if metadata.role == Role::User { - let (_, user_message) = assistant.assist(cx)?; - user_message - } else { - let user_message = assistant.insert_message_after(excerpt_id, Role::User, cx); - user_message - }; - Some(user_message) + let user_messages = self.assistant.update(cx, |assistant, cx| { + let selected_messages = assistant + .messages_for_offsets(cursors, cx) + .into_iter() + .map(|message| message.id) + .collect(); + assistant.assist(selected_messages, cx) }); - - if let Some(user_message) = user_message { + let new_selections = user_messages + .iter() + .map(|message| { + let cursor = message + .start + .to_offset(self.assistant.read(cx).buffer.read(cx)); + cursor..cursor + }) + .collect::>(); + if !new_selections.is_empty() { self.editor.update(cx, |editor, cx| { - let cursor = editor - .buffer() - .read(cx) - .snapshot(cx) - .anchor_in_excerpt(user_message.excerpt_id, language::Anchor::MIN); editor.change_selections( Some(Autoscroll::Strategy(AutoscrollStrategy::Fit)), cx, - |selections| selections.select_anchor_ranges([cursor..cursor]), + |selections| selections.select_ranges(new_selections), ); }); - self.update_scroll_bottom(cx); } } @@ -963,6 +1109,26 @@ impl AssistantEditor { } } + fn cycle_message_role(&mut self, _: &CycleMessageRole, cx: &mut ViewContext) { + let cursors = self.cursors(cx); + self.assistant.update(cx, |assistant, cx| { + let messages = assistant + .messages_for_offsets(cursors, cx) + .into_iter() + .map(|message| message.id) + .collect(); + assistant.cycle_message_roles(messages, cx) + }); + } + + fn cursors(&self, cx: &AppContext) -> Vec { + let selections = self.editor.read(cx).selections.all::(cx); + selections + .into_iter() + .map(|selection| selection.head()) + .collect() + } + fn handle_assistant_event( &mut self, _: ModelHandle, @@ -970,34 +1136,22 @@ impl AssistantEditor { cx: &mut ViewContext, ) { match event { - AssistantEvent::MessagesEdited { ids } => { - let selections = self.editor.read(cx).selections.all::(cx); - let selection_heads = selections - .iter() - .map(|selection| selection.head()) - .collect::>(); - let ids = ids.iter().copied().collect::>(); - self.assistant.update(cx, |assistant, cx| { - assistant.remove_empty_messages(ids, selection_heads, cx) - }); - } + AssistantEvent::MessagesEdited => self.update_message_headers(cx), AssistantEvent::SummaryChanged => { cx.emit(AssistantEditorEvent::TabContentChanged); } AssistantEvent::StreamedCompletion => { self.editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - let scroll_bottom_row = self - .scroll_bottom - .anchor - .to_display_point(&snapshot.display_snapshot) - .row(); - - let scroll_bottom = scroll_bottom_row as f32 + self.scroll_bottom.offset.y(); - let visible_line_count = editor.visible_line_count().unwrap_or(0.); - let scroll_top = scroll_bottom - visible_line_count; - editor - .set_scroll_position(vec2f(self.scroll_bottom.offset.x(), scroll_top), cx); + if let Some(scroll_position) = self.scroll_position { + let snapshot = editor.snapshot(cx); + let cursor_point = scroll_position.cursor.to_display_point(&snapshot); + let scroll_top = + cursor_point.row() as f32 - scroll_position.offset_before_cursor.y(); + editor.set_scroll_position( + vec2f(scroll_position.offset_before_cursor.x(), scroll_top), + cx, + ); + } }); } } @@ -1010,34 +1164,154 @@ impl AssistantEditor { cx: &mut ViewContext, ) { match event { - editor::Event::ScrollPositionChanged { .. } => self.update_scroll_bottom(cx), + editor::Event::ScrollPositionChanged { autoscroll, .. } => { + let cursor_scroll_position = self.cursor_scroll_position(cx); + if *autoscroll { + self.scroll_position = cursor_scroll_position; + } else if self.scroll_position != cursor_scroll_position { + self.scroll_position = None; + } + } + editor::Event::SelectionsChanged { .. } => { + self.scroll_position = self.cursor_scroll_position(cx); + } _ => {} } } - fn update_scroll_bottom(&mut self, cx: &mut ViewContext) { + fn cursor_scroll_position(&self, cx: &mut ViewContext) -> Option { self.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(cx); + let cursor = editor.selections.newest_anchor().head(); + let cursor_row = cursor.to_display_point(&snapshot.display_snapshot).row() as f32; let scroll_position = editor .scroll_manager .anchor() .scroll_position(&snapshot.display_snapshot); + let scroll_bottom = scroll_position.y() + editor.visible_line_count().unwrap_or(0.); - let scroll_bottom_point = cmp::min( - DisplayPoint::new(scroll_bottom.floor() as u32, 0), - snapshot.display_snapshot.max_point(), - ); - let scroll_bottom_anchor = snapshot - .buffer_snapshot - .anchor_after(scroll_bottom_point.to_point(&snapshot.display_snapshot)); - let scroll_bottom_offset = vec2f( - scroll_position.x(), - scroll_bottom - scroll_bottom_point.row() as f32, - ); - self.scroll_bottom = ScrollAnchor { - anchor: scroll_bottom_anchor, - offset: scroll_bottom_offset, - }; + if (scroll_position.y()..scroll_bottom).contains(&cursor_row) { + Some(ScrollPosition { + cursor, + offset_before_cursor: vec2f( + scroll_position.x(), + cursor_row - scroll_position.y(), + ), + }) + } else { + None + } + }) + } + + fn update_message_headers(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).snapshot(cx); + let excerpt_id = *buffer.as_singleton().unwrap().0; + let old_blocks = std::mem::take(&mut self.blocks); + let new_blocks = self + .assistant + .read(cx) + .messages(cx) + .map(|message| BlockProperties { + position: buffer.anchor_in_excerpt(excerpt_id, message.anchor), + height: 2, + style: BlockStyle::Sticky, + render: Arc::new({ + let assistant = self.assistant.clone(); + // let metadata = message.metadata.clone(); + // let message = message.clone(); + move |cx| { + enum Sender {} + enum ErrorTooltip {} + + let theme = theme::current(cx); + let style = &theme.assistant; + let message_id = message.id; + let sender = MouseEventHandler::::new( + message_id.0, + cx, + |state, _| match message.role { + Role::User => { + let style = style.user_sender.style_for(state); + Label::new("You", style.text.clone()) + .contained() + .with_style(style.container) + } + Role::Assistant => { + let style = style.assistant_sender.style_for(state); + Label::new("Assistant", style.text.clone()) + .contained() + .with_style(style.container) + } + Role::System => { + let style = style.system_sender.style_for(state); + Label::new("System", style.text.clone()) + .contained() + .with_style(style.container) + } + }, + ) + .with_cursor_style(CursorStyle::PointingHand) + .on_down(MouseButton::Left, { + let assistant = assistant.clone(); + move |_, _, cx| { + assistant.update(cx, |assistant, cx| { + assistant.cycle_message_roles( + HashSet::from_iter(Some(message_id)), + cx, + ) + }) + } + }); + + Flex::row() + .with_child(sender.aligned()) + .with_child( + Label::new( + message.sent_at.format("%I:%M%P").to_string(), + style.sent_at.text.clone(), + ) + .contained() + .with_style(style.sent_at.container) + .aligned(), + ) + .with_children( + if let MessageStatus::Error(error) = &message.status { + Some( + Svg::new("icons/circle_x_mark_12.svg") + .with_color(style.error_icon.color) + .constrained() + .with_width(style.error_icon.width) + .contained() + .with_style(style.error_icon.container) + .with_tooltip::( + message_id.0, + error.to_string(), + None, + theme.tooltip.clone(), + cx, + ) + .aligned(), + ) + } else { + None + }, + ) + .aligned() + .left() + .contained() + .with_style(style.header) + .into_any() + } + }), + disposition: BlockDisposition::Above, + }) + .collect::>(); + + editor.remove_blocks(old_blocks, None, cx); + let ids = editor.insert_blocks(new_blocks, None, cx); + self.blocks = HashSet::from_iter(ids); }); } @@ -1111,33 +1385,23 @@ impl AssistantEditor { let assistant = self.assistant.read(cx); if editor.selections.count() == 1 { let selection = editor.selections.newest::(cx); - let mut offset = 0; let mut copied_text = String::new(); let mut spanned_messages = 0; - for message in &assistant.messages { - let message_range = offset..offset + message.content.read(cx).len() + 1; - - if message_range.start >= selection.range().end { + for message in assistant.messages(cx) { + if message.range.start >= selection.range().end { break; - } else if message_range.end >= selection.range().start { - let range = cmp::max(message_range.start, selection.range().start) - ..cmp::min(message_range.end, selection.range().end); + } else if message.range.end >= selection.range().start { + let range = cmp::max(message.range.start, selection.range().start) + ..cmp::min(message.range.end, selection.range().end); if !range.is_empty() { - if let Some(metadata) = assistant.messages_metadata.get(&message.excerpt_id) - { - spanned_messages += 1; - write!(&mut copied_text, "## {}\n\n", metadata.role).unwrap(); - for chunk in - assistant.buffer.read(cx).snapshot(cx).text_for_range(range) - { - copied_text.push_str(&chunk); - } - copied_text.push('\n'); + spanned_messages += 1; + write!(&mut copied_text, "## {}\n\n", message.role).unwrap(); + for chunk in assistant.buffer.read(cx).text_for_range(range) { + copied_text.push_str(&chunk); } + copied_text.push('\n'); } } - - offset = message_range.end; } if spanned_messages > 1 { @@ -1150,11 +1414,24 @@ impl AssistantEditor { cx.propagate_action(); } + fn split(&mut self, _: &Split, cx: &mut ViewContext) { + self.assistant.update(cx, |assistant, cx| { + let selections = self.editor.read(cx).selections.disjoint_anchors(); + for selection in selections.into_iter() { + let buffer = self.editor.read(cx).buffer().read(cx).snapshot(cx); + let range = selection + .map(|endpoint| endpoint.to_offset(&buffer)) + .range(); + assistant.split_message(range, cx); + } + }); + } + fn cycle_model(&mut self, cx: &mut ViewContext) { self.assistant.update(cx, |assistant, cx| { let new_model = match assistant.model.as_str() { - "gpt-4" => "gpt-3.5-turbo", - _ => "gpt-4", + "gpt-4-0613" => "gpt-3.5-turbo-0613", + _ => "gpt-4-0613", }; assistant.set_model(new_model.into(), cx); }); @@ -1207,7 +1484,7 @@ impl View for AssistantEditor { Flex::row() .with_child( MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.model.style_for(state, false); + let style = theme.model.style_for(state); Label::new(model, style.text.clone()) .contained() .with_style(style.container) @@ -1255,17 +1532,49 @@ impl Item for AssistantEditor { } } +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)] +struct MessageId(usize); + #[derive(Clone, Debug)] -struct Message { - excerpt_id: ExcerptId, - content: ModelHandle, +struct MessageAnchor { + id: MessageId, + start: language::Anchor, } #[derive(Clone, Debug)] struct MessageMetadata { role: Role, sent_at: DateTime, - error: Option, + status: MessageStatus, +} + +#[derive(Clone, Debug)] +enum MessageStatus { + Pending, + Done, + Error(Arc), +} + +#[derive(Clone, Debug)] +pub struct Message { + range: Range, + index: usize, + id: MessageId, + anchor: language::Anchor, + role: Role, + sent_at: DateTime, + status: MessageStatus, +} + +impl Message { + fn to_open_ai_message(&self, buffer: &Buffer) -> RequestMessage { + let mut content = format!("[Message {}]\n", self.id.0).to_string(); + content.extend(buffer.text_for_range(self.range.clone())); + RequestMessage { + role: self.role, + content, + } + } } async fn stream_completion( @@ -1362,22 +1671,290 @@ mod tests { #[gpui::test] fn test_inserting_and_removing_messages(cx: &mut AppContext) { let registry = Arc::new(LanguageRegistry::test()); + let assistant = cx.add_model(|cx| Assistant::new(Default::default(), registry, cx)); + let buffer = assistant.read(cx).buffer.clone(); - cx.add_model(|cx| { - let mut assistant = Assistant::new(Default::default(), registry, cx); - let message_1 = assistant.messages[0].clone(); - let message_2 = assistant.insert_message_after(ExcerptId::max(), Role::Assistant, cx); - let message_3 = assistant.insert_message_after(message_2.excerpt_id, Role::User, cx); - let message_4 = assistant.insert_message_after(message_2.excerpt_id, Role::User, cx); - assistant.remove_empty_messages( - HashSet::from_iter([message_3.excerpt_id, message_4.excerpt_id]), - Default::default(), - cx, - ); - assert_eq!(assistant.messages.len(), 2); - assert_eq!(assistant.messages[0].excerpt_id, message_1.excerpt_id); - assert_eq!(assistant.messages[1].excerpt_id, message_2.excerpt_id); + let message_1 = assistant.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&assistant, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + let message_2 = assistant.update(cx, |assistant, cx| { assistant + .insert_message_after(message_1.id, Role::Assistant, MessageStatus::Done, cx) + .unwrap() }); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..1), + (message_2.id, Role::Assistant, 1..1) + ] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "1"), (1..1, "2")], None, cx) + }); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..3) + ] + ); + + let message_3 = assistant.update(cx, |assistant, cx| { + assistant + .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_3.id, Role::User, 4..4) + ] + ); + + let message_4 = assistant.update(cx, |assistant, cx| { + assistant + .insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..5), + (message_3.id, Role::User, 5..5), + ] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(4..4, "C"), (5..5, "D")], None, cx) + }); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..6), + (message_3.id, Role::User, 6..7), + ] + ); + + // Deleting across message boundaries merges the messages. + buffer.update(cx, |buffer, cx| buffer.edit([(1..4, "")], None, cx)); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_3.id, Role::User, 3..4), + ] + ); + + // Undoing the deletion should also undo the merge. + buffer.update(cx, |buffer, cx| buffer.undo(cx)); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..2), + (message_2.id, Role::Assistant, 2..4), + (message_4.id, Role::User, 4..6), + (message_3.id, Role::User, 6..7), + ] + ); + + // Redoing the deletion should also redo the merge. + buffer.update(cx, |buffer, cx| buffer.redo(cx)); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_3.id, Role::User, 3..4), + ] + ); + + // Ensure we can still insert after a merged message. + let message_5 = assistant.update(cx, |assistant, cx| { + assistant + .insert_message_after(message_1.id, Role::System, MessageStatus::Done, cx) + .unwrap() + }); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..3), + (message_5.id, Role::System, 3..4), + (message_3.id, Role::User, 4..5) + ] + ); + } + + #[gpui::test] + fn test_message_splitting(cx: &mut AppContext) { + let registry = Arc::new(LanguageRegistry::test()); + let assistant = cx.add_model(|cx| Assistant::new(Default::default(), registry, cx)); + let buffer = assistant.read(cx).buffer.clone(); + + let message_1 = assistant.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&assistant, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "aaa\nbbb\nccc\nddd\n")], None, cx) + }); + + let (_, message_2) = + assistant.update(cx, |assistant, cx| assistant.split_message(3..3, cx)); + let message_2 = message_2.unwrap(); + + // We recycle newlines in the middle of a split message + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..16), + ] + ); + + let (_, message_3) = + assistant.update(cx, |assistant, cx| assistant.split_message(3..3, cx)); + let message_3 = message_3.unwrap(); + + // We don't recycle newlines at the end of a split message + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..17), + ] + ); + + let (_, message_4) = + assistant.update(cx, |assistant, cx| assistant.split_message(9..9, cx)); + let message_4 = message_4.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\nccc\nddd\n"); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..17), + ] + ); + + let (_, message_5) = + assistant.update(cx, |assistant, cx| assistant.split_message(9..9, cx)); + let message_5 = message_5.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\nddd\n"); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..10), + (message_5.id, Role::User, 10..18), + ] + ); + + let (message_6, message_7) = + assistant.update(cx, |assistant, cx| assistant.split_message(14..16, cx)); + let message_6 = message_6.unwrap(); + let message_7 = message_7.unwrap(); + assert_eq!(buffer.read(cx).text(), "aaa\n\nbbb\n\nccc\ndd\nd\n"); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_3.id, Role::User, 4..5), + (message_2.id, Role::User, 5..9), + (message_4.id, Role::User, 9..10), + (message_5.id, Role::User, 10..14), + (message_6.id, Role::User, 14..17), + (message_7.id, Role::User, 17..19), + ] + ); + } + + #[gpui::test] + fn test_messages_for_offsets(cx: &mut AppContext) { + let registry = Arc::new(LanguageRegistry::test()); + let assistant = cx.add_model(|cx| Assistant::new(Default::default(), registry, cx)); + let buffer = assistant.read(cx).buffer.clone(); + + let message_1 = assistant.read(cx).message_anchors[0].clone(); + assert_eq!( + messages(&assistant, cx), + vec![(message_1.id, Role::User, 0..0)] + ); + + buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "aaa")], None, cx)); + let message_2 = assistant + .update(cx, |assistant, cx| { + assistant.insert_message_after(message_1.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(4..4, "bbb")], None, cx)); + + let message_3 = assistant + .update(cx, |assistant, cx| { + assistant.insert_message_after(message_2.id, Role::User, MessageStatus::Done, cx) + }) + .unwrap(); + buffer.update(cx, |buffer, cx| buffer.edit([(8..8, "ccc")], None, cx)); + + assert_eq!(buffer.read(cx).text(), "aaa\nbbb\nccc"); + assert_eq!( + messages(&assistant, cx), + vec![ + (message_1.id, Role::User, 0..4), + (message_2.id, Role::User, 4..8), + (message_3.id, Role::User, 8..11) + ] + ); + + assert_eq!( + message_ids_for_offsets(&assistant, &[0, 4, 9], cx), + [message_1.id, message_2.id, message_3.id] + ); + assert_eq!( + message_ids_for_offsets(&assistant, &[0, 1, 11], cx), + [message_1.id, message_3.id] + ); + + fn message_ids_for_offsets( + assistant: &ModelHandle, + offsets: &[usize], + cx: &AppContext, + ) -> Vec { + assistant + .read(cx) + .messages_for_offsets(offsets.iter().copied(), cx) + .into_iter() + .map(|message| message.id) + .collect() + } + } + + fn messages( + assistant: &ModelHandle, + cx: &AppContext, + ) -> Vec<(MessageId, Role, Range)> { + assistant + .read(cx) + .messages(cx) + .map(|message| (message.id, message.role, message.range)) + .collect() } } diff --git a/crates/auto_update/src/update_notification.rs b/crates/auto_update/src/update_notification.rs index 6f31df614d..cd2e53905d 100644 --- a/crates/auto_update/src/update_notification.rs +++ b/crates/auto_update/src/update_notification.rs @@ -49,7 +49,7 @@ impl View for UpdateNotification { ) .with_child( MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.dismiss_button.style_for(state, false); + let style = theme.dismiss_button.style_for(state); Svg::new("icons/x_mark_8.svg") .with_color(style.color) .constrained() @@ -74,7 +74,7 @@ impl View for UpdateNotification { ), ) .with_child({ - let style = theme.action_message.style_for(state, false); + let style = theme.action_message.style_for(state); Text::new("View the release notes", style.text.clone()) .contained() .with_style(style.container) diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index 906d70abb7..433dbed29b 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -83,7 +83,7 @@ impl View for Breadcrumbs { } MouseEventHandler::::new(0, cx, |state, _| { - let style = style.style_for(state, false); + let style = style.style_for(state); crumbs.with_style(style.container) }) .on_click(MouseButton::Left, |_, this, cx| { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index ce4ede8684..92b63478cb 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -39,7 +39,12 @@ use std::{ }, }; use unindent::Unindent as _; -use workspace::{item::ItemHandle as _, shared_screen::SharedScreen, SplitDirection, Workspace}; +use workspace::{ + dock::{test::TestPanel, DockPosition}, + item::{test::TestItem, ItemHandle as _}, + shared_screen::SharedScreen, + SplitDirection, Workspace, +}; #[ctor::ctor] fn init_logger() { @@ -6847,12 +6852,43 @@ async fn test_basic_following( ) }); - // Client B activates an external window again, and the previously-opened screen-sharing item - // gets activated. - active_call_b - .update(cx_b, |call, cx| call.set_location(None, cx)) - .await - .unwrap(); + // Client B activates a panel, and the previously-opened screen-sharing item gets activated. + let panel = cx_b.add_view(workspace_b.window_id(), |_| { + TestPanel::new(DockPosition::Left) + }); + workspace_b.update(cx_b, |workspace, cx| { + workspace.add_panel(panel, cx); + workspace.toggle_panel_focus::(cx); + }); + deterministic.run_until_parked(); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .id()), + shared_screen.id() + ); + + // Toggling the focus back to the pane causes client A to return to the multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + deterministic.run_until_parked(); + workspace_a.read_with(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().id(), + multibuffer_editor_a.id() + ) + }); + + // Client B activates an item that doesn't implement following, + // so the previously-opened screen-sharing item gets activated. + let unfollowable_item = cx_b.add_view(workspace_b.window_id(), |_| TestItem::new()); + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(unfollowable_item), true, true, None, cx) + }) + }); deterministic.run_until_parked(); assert_eq!( workspace_a.read_with(cx_a, |workspace, cx| workspace diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 720a73f477..04fc9650eb 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -299,7 +299,12 @@ impl CollabTitlebarItem { pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext) { let theme = theme::current(cx).clone(); let avatar_style = theme.workspace.titlebar.leader_avatar.clone(); - let item_style = theme.context_menu.item.disabled_style().clone(); + let item_style = theme + .context_menu + .item + .inactive_state() + .disabled_style() + .clone(); self.user_menu.update(cx, |user_menu, cx| { let items = if let Some(user) = self.user_store.read(cx).current_user() { vec![ @@ -361,8 +366,20 @@ impl CollabTitlebarItem { .contained() .with_style(titlebar.toggle_contacts_badge) .contained() - .with_margin_left(titlebar.toggle_contacts_button.default.icon_width) - .with_margin_top(titlebar.toggle_contacts_button.default.icon_width) + .with_margin_left( + titlebar + .toggle_contacts_button + .inactive_state() + .default + .icon_width, + ) + .with_margin_top( + titlebar + .toggle_contacts_button + .inactive_state() + .default + .icon_width, + ) .aligned(), ) }; @@ -372,7 +389,8 @@ impl CollabTitlebarItem { MouseEventHandler::::new(0, cx, |state, _| { let style = titlebar .toggle_contacts_button - .style_for(state, self.contacts_popover.is_some()); + .in_state(self.contacts_popover.is_some()) + .style_for(state); Svg::new("icons/user_plus_16.svg") .with_color(style.color) .constrained() @@ -419,7 +437,7 @@ impl CollabTitlebarItem { let titlebar = &theme.workspace.titlebar; MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar.call_control.style_for(state, false); + let style = titlebar.call_control.style_for(state); Svg::new(icon) .with_color(style.color) .constrained() @@ -473,7 +491,7 @@ impl CollabTitlebarItem { .with_child( MouseEventHandler::::new(0, cx, |state, _| { //TODO: Ensure this button has consistent width for both text variations - let style = titlebar.share_button.style_for(state, false); + let style = titlebar.share_button.inactive_state().style_for(state); Label::new(label, style.text.clone()) .contained() .with_style(style.container) @@ -511,7 +529,7 @@ impl CollabTitlebarItem { Stack::new() .with_child( MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar.call_control.style_for(state, false); + let style = titlebar.call_control.style_for(state); Svg::new("icons/ellipsis_14.svg") .with_color(style.color) .constrained() @@ -549,7 +567,7 @@ impl CollabTitlebarItem { fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext) -> AnyElement { let titlebar = &theme.workspace.titlebar; MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar.sign_in_prompt.style_for(state, false); + let style = titlebar.sign_in_prompt.inactive_state().style_for(state); Label::new("Sign In", style.text.clone()) .contained() .with_style(style.container) diff --git a/crates/collab_ui/src/contact_finder.rs b/crates/collab_ui/src/contact_finder.rs index b5f2416a5b..af59817ece 100644 --- a/crates/collab_ui/src/contact_finder.rs +++ b/crates/collab_ui/src/contact_finder.rs @@ -117,7 +117,8 @@ impl PickerDelegate for ContactFinderDelegate { .contact_finder .picker .item - .style_for(mouse_state, selected); + .in_state(selected) + .style_for(mouse_state); Flex::row() .with_children(user.avatar.clone().map(|avatar| { Image::from_data(avatar) diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs index e8dae210c4..0c8b27a151 100644 --- a/crates/collab_ui/src/contact_list.rs +++ b/crates/collab_ui/src/contact_list.rs @@ -774,7 +774,8 @@ impl ContactList { .with_style( *theme .contact_row - .style_for(&mut Default::default(), is_selected), + .in_state(is_selected) + .style_for(&mut Default::default()), ) .into_any() } @@ -797,7 +798,7 @@ impl ContactList { .width .or(theme.contact_avatar.height) .unwrap_or(0.); - let row = &theme.project_row.default; + let row = &theme.project_row.inactive_state().default; let tree_branch = theme.tree_branch; let line_height = row.name.text.line_height(font_cache); let cap_height = row.name.text.cap_height(font_cache); @@ -810,8 +811,11 @@ impl ContactList { }; MouseEventHandler::::new(project_id as usize, cx, |mouse_state, _| { - let tree_branch = *tree_branch.style_for(mouse_state, is_selected); - let row = theme.project_row.style_for(mouse_state, is_selected); + let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); + let row = theme + .project_row + .in_state(is_selected) + .style_for(mouse_state); Flex::row() .with_child( @@ -893,7 +897,7 @@ impl ContactList { .width .or(theme.contact_avatar.height) .unwrap_or(0.); - let row = &theme.project_row.default; + let row = &theme.project_row.inactive_state().default; let tree_branch = theme.tree_branch; let line_height = row.name.text.line_height(font_cache); let cap_height = row.name.text.cap_height(font_cache); @@ -904,8 +908,11 @@ impl ContactList { peer_id.as_u64() as usize, cx, |mouse_state, _| { - let tree_branch = *tree_branch.style_for(mouse_state, is_selected); - let row = theme.project_row.style_for(mouse_state, is_selected); + let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); + let row = theme + .project_row + .in_state(is_selected) + .style_for(mouse_state); Flex::row() .with_child( @@ -989,7 +996,8 @@ impl ContactList { let header_style = theme .header_row - .style_for(&mut Default::default(), is_selected); + .in_state(is_selected) + .style_for(&mut Default::default()); let text = match section { Section::ActiveCall => "Collaborators", Section::Requests => "Contact Requests", @@ -999,7 +1007,7 @@ impl ContactList { let leave_call = if section == Section::ActiveCall { Some( MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.leave_call.style_for(state, false); + let style = theme.leave_call.style_for(state); Label::new("Leave Call", style.text.clone()) .contained() .with_style(style.container) @@ -1110,8 +1118,7 @@ impl ContactList { contact.user.id as usize, cx, |mouse_state, _| { - let button_style = - theme.contact_button.style_for(mouse_state, false); + let button_style = theme.contact_button.style_for(mouse_state); render_icon_button(button_style, "icons/x_mark_8.svg") .aligned() .flex_float() @@ -1146,7 +1153,8 @@ impl ContactList { .with_style( *theme .contact_row - .style_for(&mut Default::default(), is_selected), + .in_state(is_selected) + .style_for(&mut Default::default()), ) }) .on_click(MouseButton::Left, move |_, this, cx| { @@ -1204,7 +1212,7 @@ impl ContactList { let button_style = if is_contact_request_pending { &theme.disabled_button } else { - theme.contact_button.style_for(mouse_state, false) + theme.contact_button.style_for(mouse_state) }; render_icon_button(button_style, "icons/x_mark_8.svg").aligned() }) @@ -1227,7 +1235,7 @@ impl ContactList { let button_style = if is_contact_request_pending { &theme.disabled_button } else { - theme.contact_button.style_for(mouse_state, false) + theme.contact_button.style_for(mouse_state) }; render_icon_button(button_style, "icons/check_8.svg") .aligned() @@ -1250,7 +1258,7 @@ impl ContactList { let button_style = if is_contact_request_pending { &theme.disabled_button } else { - theme.contact_button.style_for(mouse_state, false) + theme.contact_button.style_for(mouse_state) }; render_icon_button(button_style, "icons/x_mark_8.svg") .aligned() @@ -1277,7 +1285,8 @@ impl ContactList { .with_style( *theme .contact_row - .style_for(&mut Default::default(), is_selected), + .in_state(is_selected) + .style_for(&mut Default::default()), ) .into_any() } diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index abeb65b1dc..cbd072fe89 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -53,7 +53,7 @@ where ) .with_child( MouseEventHandler::::new(user.id as usize, cx, |state, _| { - let style = theme.dismiss_button.style_for(state, false); + let style = theme.dismiss_button.style_for(state); Svg::new("icons/x_mark_8.svg") .with_color(style.color) .constrained() @@ -93,7 +93,7 @@ where .with_children(buttons.into_iter().enumerate().map( |(ix, (message, handler))| { MouseEventHandler::::new(ix, cx, |state, _| { - let button = theme.button.style_for(state, false); + let button = theme.button.style_for(state); Label::new(message, button.text.clone()) .contained() .with_style(button.container) diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 2ee93a0734..aec876bd78 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -185,8 +185,8 @@ impl PickerDelegate for CommandPaletteDelegate { let mat = &self.matches[ix]; let command = &self.actions[mat.candidate_id]; let theme = theme::current(cx); - let style = theme.picker.item.style_for(mouse_state, selected); - let key_style = &theme.command_palette.key.style_for(mouse_state, selected); + let style = theme.picker.item.in_state(selected).style_for(mouse_state); + let key_style = &theme.command_palette.key.in_state(selected); let keystroke_spacing = theme.command_palette.keystroke_spacing; Flex::row() diff --git a/crates/context_menu/src/context_menu.rs b/crates/context_menu/src/context_menu.rs index fb455fe1d0..de78b51e9c 100644 --- a/crates/context_menu/src/context_menu.rs +++ b/crates/context_menu/src/context_menu.rs @@ -328,10 +328,8 @@ impl ContextMenu { Flex::column().with_children(self.items.iter().enumerate().map(|(ix, item)| { match item { ContextMenuItem::Item { label, .. } => { - let style = style.item.style_for( - &mut Default::default(), - Some(ix) == self.selected_index, - ); + let style = style.item.in_state(self.selected_index == Some(ix)); + let style = style.style_for(&mut Default::default()); match label { ContextMenuItemLabel::String(label) => { @@ -363,10 +361,8 @@ impl ContextMenu { .with_children(self.items.iter().enumerate().map(|(ix, item)| { match item { ContextMenuItem::Item { action, .. } => { - let style = style.item.style_for( - &mut Default::default(), - Some(ix) == self.selected_index, - ); + let style = style.item.in_state(self.selected_index == Some(ix)); + let style = style.style_for(&mut Default::default()); match action { ContextMenuItemAction::Action(action) => KeystrokeLabel::new( @@ -412,8 +408,8 @@ impl ContextMenu { let action = action.clone(); let view_id = self.parent_view_id; MouseEventHandler::::new(ix, cx, |state, _| { - let style = - style.item.style_for(state, Some(ix) == self.selected_index); + let style = style.item.in_state(self.selected_index == Some(ix)); + let style = style.style_for(state); let keystroke = match &action { ContextMenuItemAction::Action(action) => Some( KeystrokeLabel::new( diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 0993a33e6c..803cb5cc85 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -127,16 +127,16 @@ impl CopilotCodeVerification { .with_child( Label::new( if copied { "Copied!" } else { "Copy" }, - device_code_style.cta.style_for(state, false).text.clone(), + device_code_style.cta.style_for(state).text.clone(), ) .aligned() .contained() - .with_style(*device_code_style.right_container.style_for(state, false)) + .with_style(*device_code_style.right_container.style_for(state)) .constrained() .with_width(device_code_style.right), ) .contained() - .with_style(device_code_style.cta.style_for(state, false).container) + .with_style(device_code_style.cta.style_for(state).container) }) .on_click(gpui::platform::MouseButton::Left, { let user_code = data.user_code.clone(); diff --git a/crates/copilot_button/src/copilot_button.rs b/crates/copilot_button/src/copilot_button.rs index 2454074d45..9b0581492f 100644 --- a/crates/copilot_button/src/copilot_button.rs +++ b/crates/copilot_button/src/copilot_button.rs @@ -71,7 +71,8 @@ impl View for CopilotButton { .status_bar .panel_buttons .button - .style_for(state, active); + .in_state(active) + .style_for(state); Flex::row() .with_child( @@ -255,7 +256,7 @@ impl CopilotButton { move |state: &mut MouseState, style: &theme::ContextMenuItem| { Flex::row() .with_child(Label::new("Copilot Settings", style.label.clone())) - .with_child(theme::ui::icon(icon_style.style_for(state, false))) + .with_child(theme::ui::icon(icon_style.style_for(state))) .align_children_center() .into_any() }, diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 12fcc1395b..d0cd437946 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -430,7 +430,7 @@ impl ProjectDiagnosticsEditor { }); self.editor.update(cx, |editor, cx| { - editor.remove_blocks(blocks_to_remove, cx); + editor.remove_blocks(blocks_to_remove, None, cx); let block_ids = editor.insert_blocks( blocks_to_add.into_iter().map(|block| { let (excerpt_id, text_anchor) = block.position; @@ -442,6 +442,7 @@ impl ProjectDiagnosticsEditor { disposition: block.disposition, } }), + Some(Autoscroll::fit()), cx, ); @@ -1508,7 +1509,8 @@ mod tests { let snapshot = editor.snapshot(cx); snapshot .blocks_in_range(0..snapshot.max_point().row()) - .filter_map(|(row, block)| { + .enumerate() + .filter_map(|(ix, (row, block))| { let name = match block { TransformBlock::Custom(block) => block .render(&mut BlockContext { @@ -1519,6 +1521,7 @@ mod tests { gutter_width: 0., line_height: 0., em_width: 0., + block_id: ix, }) .name()? .to_string(), diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index f84846eae1..c106f042b5 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -100,7 +100,7 @@ impl View for DiagnosticIndicator { .workspace .status_bar .diagnostic_summary - .style_for(state, false); + .style_for(state); let mut summary_row = Flex::row(); if self.summary.error_count > 0 { @@ -198,7 +198,7 @@ impl View for DiagnosticIndicator { MouseEventHandler::::new(1, cx, |state, _| { Label::new( diagnostic.message.split('\n').next().unwrap().to_string(), - message_style.style_for(state, false).text.clone(), + message_style.style_for(state).text.clone(), ) .aligned() .contained() diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 325883b7c0..dcc2220227 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -83,7 +83,7 @@ ctor.workspace = true env_logger.workspace = true rand.workspace = true unindent.workspace = true -tree-sitter = "0.20" +tree-sitter.workspace = true tree-sitter-rust = "0.20" tree-sitter-html = "0.19" tree-sitter-typescript = { git = "https://github.com/tree-sitter/tree-sitter-typescript", rev = "5d20856f34315b068c41edaee2ac8a100081d259" } diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 05ff9886f1..b20ecaef1c 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -88,6 +88,7 @@ pub struct BlockContext<'a, 'b, 'c> { pub gutter_padding: f32, pub em_width: f32, pub line_height: f32, + pub block_id: usize, } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -243,7 +244,7 @@ impl BlockMap { // Preserve any old transforms that precede this edit. let old_start = WrapRow(edit.old.start); let new_start = WrapRow(edit.new.start); - new_transforms.push_tree(cursor.slice(&old_start, Bias::Left, &()), &()); + new_transforms.append(cursor.slice(&old_start, Bias::Left, &()), &()); if let Some(transform) = cursor.item() { if transform.is_isomorphic() && old_start == cursor.end(&()) { new_transforms.push(transform.clone(), &()); @@ -425,7 +426,7 @@ impl BlockMap { push_isomorphic(&mut new_transforms, extent_after_edit); } - new_transforms.push_tree(cursor.suffix(&()), &()); + new_transforms.append(cursor.suffix(&()), &()); debug_assert_eq!( new_transforms.summary().input_rows, wrap_snapshot.max_point().row() + 1 diff --git a/crates/editor/src/display_map/fold_map.rs b/crates/editor/src/display_map/fold_map.rs index 6ef1ebce1d..36cfeba4a1 100644 --- a/crates/editor/src/display_map/fold_map.rs +++ b/crates/editor/src/display_map/fold_map.rs @@ -115,10 +115,10 @@ impl<'a> FoldMapWriter<'a> { let mut new_tree = SumTree::new(); let mut cursor = self.0.folds.cursor::(); for fold in folds { - new_tree.push_tree(cursor.slice(&fold, Bias::Right, &buffer), &buffer); + new_tree.append(cursor.slice(&fold, Bias::Right, &buffer), &buffer); new_tree.push(fold, &buffer); } - new_tree.push_tree(cursor.suffix(&buffer), &buffer); + new_tree.append(cursor.suffix(&buffer), &buffer); new_tree }; @@ -165,10 +165,10 @@ impl<'a> FoldMapWriter<'a> { let mut cursor = self.0.folds.cursor::(); let mut folds = SumTree::new(); for fold_ix in fold_ixs_to_delete { - folds.push_tree(cursor.slice(&fold_ix, Bias::Right, &buffer), &buffer); + folds.append(cursor.slice(&fold_ix, Bias::Right, &buffer), &buffer); cursor.next(&buffer); } - folds.push_tree(cursor.suffix(&buffer), &buffer); + folds.append(cursor.suffix(&buffer), &buffer); folds }; @@ -302,7 +302,7 @@ impl FoldMap { cursor.seek(&0, Bias::Right, &()); while let Some(mut edit) = buffer_edits_iter.next() { - new_transforms.push_tree(cursor.slice(&edit.old.start, Bias::Left, &()), &()); + new_transforms.append(cursor.slice(&edit.old.start, Bias::Left, &()), &()); edit.new.start -= edit.old.start - cursor.start(); edit.old.start = *cursor.start(); @@ -412,7 +412,7 @@ impl FoldMap { } } - new_transforms.push_tree(cursor.suffix(&()), &()); + new_transforms.append(cursor.suffix(&()), &()); if new_transforms.is_empty() { let text_summary = new_buffer.text_summary(); new_transforms.push( diff --git a/crates/editor/src/display_map/wrap_map.rs b/crates/editor/src/display_map/wrap_map.rs index 478eaf4c7e..dad264d57d 100644 --- a/crates/editor/src/display_map/wrap_map.rs +++ b/crates/editor/src/display_map/wrap_map.rs @@ -353,7 +353,7 @@ impl WrapSnapshot { } old_cursor.next(&()); - new_transforms.push_tree( + new_transforms.append( old_cursor.slice(&next_edit.old.start, Bias::Right, &()), &(), ); @@ -366,7 +366,7 @@ impl WrapSnapshot { new_transforms.push_or_extend(Transform::isomorphic(summary)); } old_cursor.next(&()); - new_transforms.push_tree(old_cursor.suffix(&()), &()); + new_transforms.append(old_cursor.suffix(&()), &()); } } } @@ -500,7 +500,7 @@ impl WrapSnapshot { new_transforms.push_or_extend(Transform::isomorphic(summary)); } old_cursor.next(&()); - new_transforms.push_tree( + new_transforms.append( old_cursor.slice( &TabPoint::new(next_edit.old_rows.start, 0), Bias::Right, @@ -517,7 +517,7 @@ impl WrapSnapshot { new_transforms.push_or_extend(Transform::isomorphic(summary)); } old_cursor.next(&()); - new_transforms.push_tree(old_cursor.suffix(&()), &()); + new_transforms.append(old_cursor.suffix(&()), &()); } } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cecefc7061..c1e38614ef 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -31,13 +31,11 @@ use copilot::Copilot; pub use display_map::DisplayPoint; use display_map::*; pub use editor_settings::EditorSettings; -pub use element::RenderExcerptHeaderParams; pub use element::{ Cursor, EditorElement, HighlightedRange, HighlightedRangeLine, LineWithInvisibles, }; use futures::FutureExt; use fuzzy::{StringMatch, StringMatchCandidate}; -use gpui::LayoutContext; use gpui::{ actions, color::Color, @@ -208,6 +206,7 @@ actions!( DuplicateLine, MoveLineUp, MoveLineDown, + JoinLines, Transpose, Cut, Copy, @@ -323,6 +322,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(Editor::indent); cx.add_action(Editor::outdent); cx.add_action(Editor::delete_line); + cx.add_action(Editor::join_lines); cx.add_action(Editor::delete_to_previous_word_start); cx.add_action(Editor::delete_to_previous_subword_start); cx.add_action(Editor::delete_to_next_word_end); @@ -511,7 +511,6 @@ pub struct Editor { mode: EditorMode, show_gutter: bool, placeholder_text: Option>, - render_excerpt_header: Option, highlighted_rows: Option>, #[allow(clippy::type_complexity)] background_highlights: BTreeMap Color, Vec>)>, @@ -1317,7 +1316,6 @@ impl Editor { mode, show_gutter: mode == EditorMode::Full, placeholder_text: None, - render_excerpt_header: None, highlighted_rows: None, background_highlights: Default::default(), nav_history: None, @@ -2169,8 +2167,8 @@ impl Editor { self.transact(cx, |this, cx| { let (edits, selection_fixup_info): (Vec<_>, Vec<_>) = { let selections = this.selections.all::(cx); - - let buffer = this.buffer.read(cx).snapshot(cx); + let multi_buffer = this.buffer.read(cx); + let buffer = multi_buffer.snapshot(cx); selections .iter() .map(|selection| { @@ -2181,70 +2179,74 @@ impl Editor { let end = selection.end; let is_cursor = start == end; let language_scope = buffer.language_scope_at(start); - let (comment_delimiter, insert_extra_newline) = - if let Some(language) = &language_scope { - let leading_whitespace_len = buffer - .reversed_chars_at(start) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); + let (comment_delimiter, insert_extra_newline) = if let Some(language) = + &language_scope + { + let leading_whitespace_len = buffer + .reversed_chars_at(start) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); - let trailing_whitespace_len = buffer - .chars_at(end) - .take_while(|c| c.is_whitespace() && *c != '\n') - .map(|c| c.len_utf8()) - .sum::(); + let trailing_whitespace_len = buffer + .chars_at(end) + .take_while(|c| c.is_whitespace() && *c != '\n') + .map(|c| c.len_utf8()) + .sum::(); - let insert_extra_newline = - language.brackets().any(|(pair, enabled)| { - let pair_start = pair.start.trim_end(); - let pair_end = pair.end.trim_start(); + let insert_extra_newline = + language.brackets().any(|(pair, enabled)| { + let pair_start = pair.start.trim_end(); + let pair_end = pair.end.trim_start(); - enabled - && pair.newline - && buffer.contains_str_at( - end + trailing_whitespace_len, - pair_end, - ) - && buffer.contains_str_at( - (start - leading_whitespace_len) - .saturating_sub(pair_start.len()), - pair_start, - ) - }); - // Comment extension on newline is allowed only for cursor selections - let comment_delimiter = - language.line_comment_prefix().filter(|_| is_cursor); - let comment_delimiter = if let Some(delimiter) = comment_delimiter { - buffer - .buffer_line_for_row(start_point.row) - .is_some_and(|(snapshot, range)| { - let mut index_of_first_non_whitespace = 0; - let line_starts_with_comment = snapshot - .chars_for_range(range) - .skip_while(|c| { - let should_skip = c.is_whitespace(); - if should_skip { - index_of_first_non_whitespace += 1; - } - should_skip - }) - .take(delimiter.len()) - .eq(delimiter.chars()); - let cursor_is_placed_after_comment_marker = - index_of_first_non_whitespace + delimiter.len() - <= start_point.column as usize; - line_starts_with_comment - && cursor_is_placed_after_comment_marker - }) - .then(|| delimiter.clone()) - } else { - None - }; - (comment_delimiter, insert_extra_newline) + enabled + && pair.newline + && buffer.contains_str_at( + end + trailing_whitespace_len, + pair_end, + ) + && buffer.contains_str_at( + (start - leading_whitespace_len) + .saturating_sub(pair_start.len()), + pair_start, + ) + }); + // Comment extension on newline is allowed only for cursor selections + let comment_delimiter = language.line_comment_prefix().filter(|_| { + let is_comment_extension_enabled = + multi_buffer.settings_at(0, cx).extend_comment_on_newline; + is_cursor && is_comment_extension_enabled + }); + let comment_delimiter = if let Some(delimiter) = comment_delimiter { + buffer + .buffer_line_for_row(start_point.row) + .is_some_and(|(snapshot, range)| { + let mut index_of_first_non_whitespace = 0; + let line_starts_with_comment = snapshot + .chars_for_range(range) + .skip_while(|c| { + let should_skip = c.is_whitespace(); + if should_skip { + index_of_first_non_whitespace += 1; + } + should_skip + }) + .take(delimiter.len()) + .eq(delimiter.chars()); + let cursor_is_placed_after_comment_marker = + index_of_first_non_whitespace + delimiter.len() + <= start_point.column as usize; + line_starts_with_comment + && cursor_is_placed_after_comment_marker + }) + .then(|| delimiter.clone()) } else { - (None, false) + None }; + (comment_delimiter, insert_extra_newline) + } else { + (None, false) + }; let capacity_for_delimiter = comment_delimiter .as_deref() @@ -3320,15 +3322,21 @@ impl Editor { pub fn render_code_actions_indicator( &self, style: &EditorStyle, - active: bool, + is_active: bool, cx: &mut ViewContext, ) -> Option> { if self.available_code_actions.is_some() { enum CodeActions {} Some( MouseEventHandler::::new(0, cx, |state, _| { - Svg::new("icons/bolt_8.svg") - .with_color(style.code_actions.indicator.style_for(state, active).color) + Svg::new("icons/bolt_8.svg").with_color( + style + .code_actions + .indicator + .in_state(is_active) + .style_for(state) + .color, + ) }) .with_cursor_style(CursorStyle::PointingHand) .with_padding(Padding::uniform(3.)) @@ -3378,10 +3386,8 @@ impl Editor { .with_color( style .indicator - .style_for( - mouse_state, - fold_status == FoldStatus::Folded, - ) + .in_state(fold_status == FoldStatus::Folded) + .style_for(mouse_state) .color, ) .constrained() @@ -3952,6 +3958,60 @@ impl Editor { }); } + pub fn join_lines(&mut self, _: &JoinLines, cx: &mut ViewContext) { + let mut row_ranges = Vec::>::new(); + for selection in self.selections.all::(cx) { + let start = selection.start.row; + let end = if selection.start.row == selection.end.row { + selection.start.row + 1 + } else { + selection.end.row + }; + + if let Some(last_row_range) = row_ranges.last_mut() { + if start <= last_row_range.end { + last_row_range.end = end; + continue; + } + } + row_ranges.push(start..end); + } + + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut cursor_positions = Vec::new(); + for row_range in &row_ranges { + let anchor = snapshot.anchor_before(Point::new( + row_range.end - 1, + snapshot.line_len(row_range.end - 1), + )); + cursor_positions.push(anchor.clone()..anchor); + } + + self.transact(cx, |this, cx| { + for row_range in row_ranges.into_iter().rev() { + for row in row_range.rev() { + let end_of_line = Point::new(row, snapshot.line_len(row)); + let indent = snapshot.indent_size_for_line(row + 1); + let start_of_next_line = Point::new(row + 1, indent.len); + + let replace = if snapshot.line_len(row + 1) > indent.len { + " " + } else { + "" + }; + + this.buffer.update(cx, |buffer, cx| { + buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx) + }); + } + } + + this.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_anchor_ranges(cursor_positions) + }); + }); + } + pub fn duplicate_line(&mut self, _: &DuplicateLine, cx: &mut ViewContext) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let buffer = &display_map.buffer_snapshot; @@ -5492,7 +5552,7 @@ impl Editor { let mut all_selection_lines_are_comments = true; for row in start_row..=end_row { - if snapshot.is_line_blank(row) { + if snapshot.is_line_blank(row) && start_row < end_row { continue; } @@ -6268,6 +6328,7 @@ impl Editor { }), disposition: BlockDisposition::Below, }], + Some(Autoscroll::fit()), cx, )[0]; this.pending_rename = Some(RenameState { @@ -6334,7 +6395,11 @@ impl Editor { cx: &mut ViewContext, ) -> Option { let rename = self.pending_rename.take()?; - self.remove_blocks([rename.block_id].into_iter().collect(), cx); + self.remove_blocks( + [rename.block_id].into_iter().collect(), + Some(Autoscroll::fit()), + cx, + ); self.clear_text_highlights::(cx); self.show_local_selections = true; @@ -6720,29 +6785,43 @@ impl Editor { pub fn insert_blocks( &mut self, blocks: impl IntoIterator>, + autoscroll: Option, cx: &mut ViewContext, ) -> Vec { let blocks = self .display_map .update(cx, |display_map, cx| display_map.insert_blocks(blocks, cx)); - self.request_autoscroll(Autoscroll::fit(), cx); + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } blocks } pub fn replace_blocks( &mut self, blocks: HashMap, + autoscroll: Option, cx: &mut ViewContext, ) { self.display_map .update(cx, |display_map, _| display_map.replace_blocks(blocks)); - self.request_autoscroll(Autoscroll::fit(), cx); + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } } - pub fn remove_blocks(&mut self, block_ids: HashSet, cx: &mut ViewContext) { + pub fn remove_blocks( + &mut self, + block_ids: HashSet, + autoscroll: Option, + cx: &mut ViewContext, + ) { self.display_map.update(cx, |display_map, cx| { display_map.remove_blocks(block_ids, cx) }); + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } } pub fn longest_row(&self, cx: &mut AppContext) -> u32 { @@ -6823,20 +6902,6 @@ impl Editor { cx.notify(); } - pub fn set_render_excerpt_header( - &mut self, - render_excerpt_header: impl 'static - + Fn( - &mut Editor, - RenderExcerptHeaderParams, - &mut LayoutContext, - ) -> AnyElement, - cx: &mut ViewContext, - ) { - self.render_excerpt_header = Some(Arc::new(render_excerpt_header)); - cx.notify(); - } - pub fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext) { if let Some(buffer) = self.buffer().read(cx).as_singleton() { if let Some(file) = buffer.read(cx).file().and_then(|f| f.as_local()) { @@ -7102,7 +7167,7 @@ impl Editor { let mut new_selections_by_buffer = HashMap::default(); for selection in editor.selections.all::(cx) { - for (buffer, mut range) in + for (buffer, mut range, _) in buffer.range_to_buffer_ranges(selection.start..selection.end, cx) { if selection.reversed { @@ -7272,7 +7337,7 @@ impl Editor { let vim_mode = cx .global::() - .untyped_user_settings() + .raw_user_settings() .get("vim_mode") == Some(&serde_json::Value::Bool(true)); let telemetry_settings = *settings::get::(cx); @@ -7444,6 +7509,7 @@ pub enum Event { }, ScrollPositionChanged { local: bool, + autoscroll: bool, }, Closed, } @@ -7475,12 +7541,8 @@ impl View for Editor { }); } - let mut editor = EditorElement::new(style.clone()); - if let Some(render_excerpt_header) = self.render_excerpt_header.clone() { - editor = editor.with_render_excerpt_header(render_excerpt_header); - } Stack::new() - .with_child(editor) + .with_child(EditorElement::new(style.clone())) .with_child(ChildView::new(&self.mouse_context_menu, cx)) .into_any() } @@ -7947,6 +8009,7 @@ impl Deref for EditorStyle { pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> RenderBlock { let mut highlighted_lines = Vec::new(); + for (index, line) in diagnostic.message.lines().enumerate() { let line = match &diagnostic.source { Some(source) if index == 0 => { @@ -7958,25 +8021,44 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend }; highlighted_lines.push(line); } - + let message = diagnostic.message; Arc::new(move |cx: &mut BlockContext| { + let message = message.clone(); let settings = settings::get::(cx); + let tooltip_style = settings.theme.tooltip.clone(); let theme = &settings.theme.editor; let style = diagnostic_style(diagnostic.severity, is_valid, theme); let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round(); - Flex::column() - .with_children(highlighted_lines.iter().map(|(line, highlights)| { - Label::new( - line.clone(), - style.message.clone().with_font_size(font_size), - ) - .with_highlights(highlights.clone()) - .contained() - .with_margin_left(cx.anchor_x) - })) - .aligned() - .left() - .into_any() + let anchor_x = cx.anchor_x; + enum BlockContextToolip {} + MouseEventHandler::::new(cx.block_id, cx, |_, _| { + Flex::column() + .with_children(highlighted_lines.iter().map(|(line, highlights)| { + Label::new( + line.clone(), + style.message.clone().with_font_size(font_size), + ) + .with_highlights(highlights.clone()) + .contained() + .with_margin_left(anchor_x) + })) + .aligned() + .left() + .into_any() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new(message.clone())); + }) + // We really need to rethink this ID system... + .with_tooltip::( + cx.block_id, + "Copy diagnostic message".to_string(), + None, + tooltip_style, + cx, + ) + .into_any() }) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 5cf79f9163..6fcb6f778f 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -1,7 +1,10 @@ use super::*; -use crate::test::{ - assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext, - editor_test_context::EditorTestContext, select_ranges, +use crate::{ + test::{ + assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext, + editor_test_context::EditorTestContext, select_ranges, + }, + JoinLines, }; use drag_and_drop::DragAndDrop; use futures::StreamExt; @@ -1732,26 +1735,40 @@ async fn test_newline_comments(cx: &mut gpui::TestAppContext) { }, None, )); - - let mut cx = EditorTestContext::new(cx).await; - cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - cx.set_state(indoc! {" + { + let mut cx = EditorTestContext::new(cx).await; + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + cx.set_state(indoc! {" // Fooˇ "}); - cx.update_editor(|e, cx| e.newline(&Newline, cx)); - cx.assert_editor_state(indoc! {" + cx.update_editor(|e, cx| e.newline(&Newline, cx)); + cx.assert_editor_state(indoc! {" // Foo //ˇ "}); - // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix. - cx.set_state(indoc! {" + // Ensure that if cursor is before the comment start, we do not actually insert a comment prefix. + cx.set_state(indoc! {" ˇ// Foo + "}); + cx.update_editor(|e, cx| e.newline(&Newline, cx)); + cx.assert_editor_state(indoc! {" + + ˇ// Foo + "}); + } + // Ensure that comment continuations can be disabled. + update_test_settings(cx, |settings| { + settings.defaults.extend_comment_on_newline = Some(false); + }); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state(indoc! {" + // Fooˇ "}); cx.update_editor(|e, cx| e.newline(&Newline, cx)); cx.assert_editor_state(indoc! {" - - ˇ// Foo + // Foo + ˇ "}); } @@ -2311,6 +2328,137 @@ fn test_delete_line(cx: &mut TestAppContext) { }); } +#[gpui::test] +fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); + let mut editor = build_editor(buffer.clone(), cx); + let buffer = buffer.read(cx).as_singleton().unwrap(); + + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 0)..Point::new(0, 0)] + ); + + // When on single line, replace newline at end by space + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 3)..Point::new(0, 3)] + ); + + // When multiple lines are selected, remove newlines that are spanned by the selection + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(0, 5)..Point::new(2, 2)]) + }); + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb ccc ddd\n\n"); + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 11)..Point::new(0, 11)] + ); + + // Undo should be transactional + editor.undo(&Undo, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 5)..Point::new(2, 2)] + ); + + // When joining an empty line don't insert a space + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(2, 1)..Point::new(2, 2)]) + }); + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n"); + assert_eq!( + editor.selections.ranges::(cx), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // We can remove trailing newlines + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); + assert_eq!( + editor.selections.ranges::(cx), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // We don't blow up on the last line + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); + assert_eq!( + editor.selections.ranges::(cx), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // reset to test indentation + editor.buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(1, 0)..Point::new(1, 2), " "), + (Point::new(2, 0)..Point::new(2, 3), " \n\td"), + ], + None, + cx, + ) + }); + + // We remove any leading spaces + assert_eq!(buffer.read(cx).text(), "aaa bbb\n c\n \n\td"); + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(0, 1)..Point::new(0, 1)]) + }); + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c\n \n\td"); + + // We don't insert a space for a line containing only spaces + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c\n\td"); + + // We ignore any leading tabs + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c d"); + + editor + }); +} + +#[gpui::test] +fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); + let mut editor = build_editor(buffer.clone(), cx); + let buffer = buffer.read(cx).as_singleton().unwrap(); + + editor.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(0, 2)..Point::new(1, 1), + Point::new(1, 2)..Point::new(1, 2), + Point::new(3, 1)..Point::new(3, 2), + ]) + }); + + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n"); + + assert_eq!( + editor.selections.ranges::(cx), + [ + Point::new(0, 7)..Point::new(0, 7), + Point::new(1, 3)..Point::new(1, 3) + ] + ); + editor + }); +} + #[gpui::test] fn test_duplicate_line(cx: &mut TestAppContext) { init_test(cx, |_| {}); @@ -2481,6 +2629,7 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { height: 1, render: Arc::new(|_| Empty::new().into_any()), }], + Some(Autoscroll::fit()), cx, ); editor.change_selections(None, cx, |s| { @@ -4930,7 +5079,7 @@ async fn test_completion(cx: &mut gpui::TestAppContext) { #[gpui::test] async fn test_toggle_comment(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); - + let mut cx = EditorTestContext::new(cx).await; let language = Arc::new(Language::new( LanguageConfig { line_comment: Some("// ".into()), @@ -4938,77 +5087,95 @@ async fn test_toggle_comment(cx: &mut gpui::TestAppContext) { }, Some(tree_sitter_rust::language()), )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - let text = " + // If multiple selections intersect a line, the line is only toggled once. + cx.set_state(indoc! {" fn a() { - //b(); - // c(); - // d(); + «//b(); + ˇ»// «c(); + //ˇ» d(); } - " - .unindent(); + "}); - let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx)); - let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - let (_, view) = cx.add_window(|cx| build_editor(buffer, cx)); + cx.update_editor(|e, cx| e.toggle_comments(&ToggleComments::default(), cx)); - view.update(cx, |editor, cx| { - // If multiple selections intersect a line, the line is only - // toggled once. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(1, 3)..DisplayPoint::new(2, 3), - DisplayPoint::new(3, 5)..DisplayPoint::new(3, 6), - ]) - }); - editor.toggle_comments(&ToggleComments::default(), cx); - assert_eq!( - editor.text(cx), - " - fn a() { - b(); - c(); - d(); - } - " - .unindent() - ); + cx.assert_editor_state(indoc! {" + fn a() { + «b(); + c(); + ˇ» d(); + } + "}); - // The comment prefix is inserted at the same column for every line - // in a selection. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(1, 3)..DisplayPoint::new(3, 6)]) - }); - editor.toggle_comments(&ToggleComments::default(), cx); - assert_eq!( - editor.text(cx), - " - fn a() { - // b(); - // c(); - // d(); - } - " - .unindent() - ); + // The comment prefix is inserted at the same column for every line in a + // selection. + cx.update_editor(|e, cx| e.toggle_comments(&ToggleComments::default(), cx)); - // If a selection ends at the beginning of a line, that line is not toggled. - editor.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(3, 0)]) - }); - editor.toggle_comments(&ToggleComments::default(), cx); - assert_eq!( - editor.text(cx), - " - fn a() { - // b(); - c(); - // d(); - } - " - .unindent() - ); - }); + cx.assert_editor_state(indoc! {" + fn a() { + // «b(); + // c(); + ˇ»// d(); + } + "}); + + // If a selection ends at the beginning of a line, that line is not toggled. + cx.set_selections_state(indoc! {" + fn a() { + // b(); + «// c(); + ˇ» // d(); + } + "}); + + cx.update_editor(|e, cx| e.toggle_comments(&ToggleComments::default(), cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // b(); + «c(); + ˇ» // d(); + } + "}); + + // If a selection span a single line and is empty, the line is toggled. + cx.set_state(indoc! {" + fn a() { + a(); + b(); + ˇ + } + "}); + + cx.update_editor(|e, cx| e.toggle_comments(&ToggleComments::default(), cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + a(); + b(); + //•ˇ + } + "}); + + // If a selection span multiple lines, empty lines are not toggled. + cx.set_state(indoc! {" + fn a() { + «a(); + + c();ˇ» + } + "}); + + cx.update_editor(|e, cx| e.toggle_comments(&ToggleComments::default(), cx)); + + cx.assert_editor_state(indoc! {" + fn a() { + // «a(); + + // c();ˇ» + } + "}); } #[gpui::test] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 9aec670659..6525e7fc22 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -91,41 +91,17 @@ impl SelectionLayout { } } -pub struct RenderExcerptHeaderParams<'a> { - pub id: crate::ExcerptId, - pub buffer: &'a language::BufferSnapshot, - pub range: &'a crate::ExcerptRange, - pub starts_new_buffer: bool, - pub gutter_padding: f32, - pub editor_style: &'a EditorStyle, -} - -pub type RenderExcerptHeader = Arc< - dyn Fn( - &mut Editor, - RenderExcerptHeaderParams, - &mut LayoutContext, - ) -> AnyElement, ->; - pub struct EditorElement { style: Arc, - render_excerpt_header: RenderExcerptHeader, } impl EditorElement { pub fn new(style: EditorStyle) -> Self { Self { style: Arc::new(style), - render_excerpt_header: Arc::new(render_excerpt_header), } } - pub fn with_render_excerpt_header(mut self, render: RenderExcerptHeader) -> Self { - self.render_excerpt_header = render; - self - } - fn attach_mouse_handlers( scene: &mut SceneBuilder, position_map: &Arc, @@ -1491,6 +1467,7 @@ impl EditorElement { editor: &mut Editor, cx: &mut LayoutContext, ) -> (f32, Vec) { + let mut block_id = 0; let scroll_x = snapshot.scroll_anchor.offset.x(); let (fixed_blocks, non_fixed_blocks) = snapshot .blocks_in_range(rows.clone()) @@ -1498,7 +1475,7 @@ impl EditorElement { TransformBlock::ExcerptHeader { .. } => false, TransformBlock::Custom(block) => block.style() == BlockStyle::Fixed, }); - let mut render_block = |block: &TransformBlock, width: f32| { + let mut render_block = |block: &TransformBlock, width: f32, block_id: usize| { let mut element = match block { TransformBlock::Custom(block) => { let align_to = block @@ -1523,6 +1500,7 @@ impl EditorElement { scroll_x, gutter_width, em_width, + block_id, }) } TransformBlock::ExcerptHeader { @@ -1531,18 +1509,117 @@ impl EditorElement { range, starts_new_buffer, .. - } => (self.render_excerpt_header)( - editor, - RenderExcerptHeaderParams { - id: *id, - buffer, - range, - starts_new_buffer: *starts_new_buffer, - gutter_padding, - editor_style: style, - }, - cx, - ), + } => { + let tooltip_style = theme::current(cx).tooltip.clone(); + let include_root = editor + .project + .as_ref() + .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) + .unwrap_or_default(); + let jump_icon = project::File::from_dyn(buffer.file()).map(|file| { + let jump_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }; + let jump_anchor = range + .primary + .as_ref() + .map_or(range.context.start, |primary| primary.start); + let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); + + enum JumpIcon {} + MouseEventHandler::::new((*id).into(), cx, |state, _| { + let style = style.jump_icon.style_for(state); + Svg::new("icons/arrow_up_right_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_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, editor, cx| { + if let Some(workspace) = editor + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade(cx)) + { + workspace.update(cx, |workspace, cx| { + Editor::jump( + workspace, + jump_path.clone(), + jump_position, + jump_anchor, + cx, + ); + }); + } + }) + .with_tooltip::( + (*id).into(), + "Jump to Buffer".to_string(), + Some(Box::new(crate::OpenExcerpts)), + tooltip_style.clone(), + cx, + ) + .aligned() + .flex_float() + }); + + if *starts_new_buffer { + let editor_font_size = style.text.font_size; + let style = &style.diagnostic_path_header; + let font_size = (style.text_scale_factor * editor_font_size).round(); + + let path = buffer.resolve_file_path(cx, include_root); + let mut filename = None; + let mut parent_path = None; + // Can't use .and_then() because `.file_name()` and `.parent()` return references :( + if let Some(path) = path { + filename = path.file_name().map(|f| f.to_string_lossy().to_string()); + parent_path = + path.parent().map(|p| p.to_string_lossy().to_string() + "/"); + } + + Flex::row() + .with_child( + Label::new( + filename.unwrap_or_else(|| "untitled".to_string()), + style.filename.text.clone().with_font_size(font_size), + ) + .contained() + .with_style(style.filename.container) + .aligned(), + ) + .with_children(parent_path.map(|path| { + Label::new(path, style.path.text.clone().with_font_size(font_size)) + .contained() + .with_style(style.path.container) + .aligned() + })) + .with_children(jump_icon) + .contained() + .with_style(style.container) + .with_padding_left(gutter_padding) + .with_padding_right(gutter_padding) + .expanded() + .into_any_named("path header block") + } else { + let text_style = style.text.clone(); + Flex::row() + .with_child(Label::new("⋯", text_style)) + .with_children(jump_icon) + .contained() + .with_padding_left(gutter_padding) + .with_padding_right(gutter_padding) + .expanded() + .into_any_named("collapsed context") + } + } }; element.layout( @@ -1559,7 +1636,8 @@ impl EditorElement { let mut fixed_block_max_width = 0f32; let mut blocks = Vec::new(); for (row, block) in fixed_blocks { - let element = render_block(block, f32::INFINITY); + let element = render_block(block, f32::INFINITY, block_id); + block_id += 1; fixed_block_max_width = fixed_block_max_width.max(element.size().x() + em_width); blocks.push(BlockLayout { row, @@ -1579,7 +1657,8 @@ impl EditorElement { .max(gutter_width + scroll_width), BlockStyle::Fixed => unreachable!(), }; - let element = render_block(block, width); + let element = render_block(block, width, block_id); + block_id += 1; blocks.push(BlockLayout { row, element, @@ -2015,7 +2094,7 @@ impl Element for EditorElement { .folds .ellipses .background - .style_for(&mut cx.mouse_state::(id as usize), false) + .style_for(&mut cx.mouse_state::(id as usize)) .color; (id, fold, color) @@ -2679,121 +2758,6 @@ impl HighlightedRange { } } -fn render_excerpt_header( - editor: &mut Editor, - RenderExcerptHeaderParams { - id, - buffer, - range, - starts_new_buffer, - gutter_padding, - editor_style, - }: RenderExcerptHeaderParams, - cx: &mut LayoutContext, -) -> AnyElement { - let tooltip_style = theme::current(cx).tooltip.clone(); - let include_root = editor - .project - .as_ref() - .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) - .unwrap_or_default(); - let jump_icon = project::File::from_dyn(buffer.file()).map(|file| { - let jump_path = ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }; - let jump_anchor = range - .primary - .as_ref() - .map_or(range.context.start, |primary| primary.start); - let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); - - enum JumpIcon {} - MouseEventHandler::::new(id.into(), cx, |state, _| { - let style = editor_style.jump_icon.style_for(state, false); - Svg::new("icons/arrow_up_right_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_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, editor, cx| { - if let Some(workspace) = editor - .workspace - .as_ref() - .and_then(|(workspace, _)| workspace.upgrade(cx)) - { - workspace.update(cx, |workspace, cx| { - Editor::jump(workspace, jump_path.clone(), jump_position, jump_anchor, cx); - }); - } - }) - .with_tooltip::( - id.into(), - "Jump to Buffer".to_string(), - Some(Box::new(crate::OpenExcerpts)), - tooltip_style.clone(), - cx, - ) - .aligned() - .flex_float() - }); - - if starts_new_buffer { - let style = &editor_style.diagnostic_path_header; - let font_size = (style.text_scale_factor * editor_style.text.font_size).round(); - - let path = buffer.resolve_file_path(cx, include_root); - let mut filename = None; - let mut parent_path = None; - // Can't use .and_then() because `.file_name()` and `.parent()` return references :( - if let Some(path) = path { - filename = path.file_name().map(|f| f.to_string_lossy().to_string()); - parent_path = path.parent().map(|p| p.to_string_lossy().to_string() + "/"); - } - - Flex::row() - .with_child( - Label::new( - filename.unwrap_or_else(|| "untitled".to_string()), - style.filename.text.clone().with_font_size(font_size), - ) - .contained() - .with_style(style.filename.container) - .aligned(), - ) - .with_children(parent_path.map(|path| { - Label::new(path, style.path.text.clone().with_font_size(font_size)) - .contained() - .with_style(style.path.container) - .aligned() - })) - .with_children(jump_icon) - .contained() - .with_style(style.container) - .with_padding_left(gutter_padding) - .with_padding_right(gutter_padding) - .expanded() - .into_any_named("path header block") - } else { - let text_style = editor_style.text.clone(); - Flex::row() - .with_child(Label::new("⋯", text_style)) - .with_children(jump_icon) - .contained() - .with_padding_left(gutter_padding) - .with_padding_right(gutter_padding) - .expanded() - .into_any_named("collapsed context") - } -} - fn position_to_display_point( position: Vector2F, text_bounds: RectF, @@ -2923,6 +2887,7 @@ mod tests { position: Anchor::min(), render: Arc::new(|_| Empty::new().into_any()), }], + None, cx, ); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 9d639f9b7b..74b8e0ddb6 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -294,7 +294,7 @@ impl FollowableItem for Editor { match event { Event::Edited => true, Event::SelectionsChanged { local } => *local, - Event::ScrollPositionChanged { local } => *local, + Event::ScrollPositionChanged { local, .. } => *local, _ => false, } } diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 0d7fb6a450..31af03f768 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -1010,7 +1010,7 @@ impl MultiBuffer { let suffix = cursor.suffix(&()); let changed_trailing_excerpt = suffix.is_empty(); - new_excerpts.push_tree(suffix, &()); + new_excerpts.append(suffix, &()); drop(cursor); snapshot.excerpts = new_excerpts; snapshot.excerpt_ids = new_excerpt_ids; @@ -1118,7 +1118,7 @@ impl MultiBuffer { &self, point: T, cx: &AppContext, - ) -> Option<(ModelHandle, usize)> { + ) -> Option<(ModelHandle, usize, ExcerptId)> { let snapshot = self.read(cx); let offset = point.to_offset(&snapshot); let mut cursor = snapshot.excerpts.cursor::(); @@ -1132,7 +1132,7 @@ impl MultiBuffer { let buffer_point = excerpt_start + offset - *cursor.start(); let buffer = self.buffers.borrow()[&excerpt.buffer_id].buffer.clone(); - (buffer, buffer_point) + (buffer, buffer_point, excerpt.id) }) } @@ -1140,7 +1140,7 @@ impl MultiBuffer { &self, range: Range, cx: &AppContext, - ) -> Vec<(ModelHandle, Range)> { + ) -> Vec<(ModelHandle, Range, ExcerptId)> { let snapshot = self.read(cx); let start = range.start.to_offset(&snapshot); let end = range.end.to_offset(&snapshot); @@ -1165,7 +1165,7 @@ impl MultiBuffer { let start = excerpt_start + (cmp::max(start, *cursor.start()) - *cursor.start()); let end = excerpt_start + (cmp::min(end, end_before_newline) - *cursor.start()); let buffer = self.buffers.borrow()[&excerpt.buffer_id].buffer.clone(); - result.push((buffer, start..end)); + result.push((buffer, start..end, excerpt.id)); cursor.next(&()); } @@ -1193,7 +1193,7 @@ impl MultiBuffer { while let Some(excerpt_id) = excerpt_ids.next() { // Seek to the next excerpt to remove, preserving any preceding excerpts. let locator = snapshot.excerpt_locator_for_id(excerpt_id); - new_excerpts.push_tree(cursor.slice(&Some(locator), Bias::Left, &()), &()); + new_excerpts.append(cursor.slice(&Some(locator), Bias::Left, &()), &()); if let Some(mut excerpt) = cursor.item() { if excerpt.id != excerpt_id { @@ -1245,7 +1245,7 @@ impl MultiBuffer { } let suffix = cursor.suffix(&()); let changed_trailing_excerpt = suffix.is_empty(); - new_excerpts.push_tree(suffix, &()); + new_excerpts.append(suffix, &()); drop(cursor); snapshot.excerpts = new_excerpts; @@ -1387,7 +1387,7 @@ impl MultiBuffer { cx: &'a AppContext, ) -> Option> { self.point_to_buffer_offset(point, cx) - .and_then(|(buffer, offset)| buffer.read(cx).language_at(offset)) + .and_then(|(buffer, offset, _)| buffer.read(cx).language_at(offset)) } pub fn settings_at<'a, T: ToOffset>( @@ -1397,7 +1397,7 @@ impl MultiBuffer { ) -> &'a LanguageSettings { let mut language = None; let mut file = None; - if let Some((buffer, offset)) = self.point_to_buffer_offset(point, cx) { + if let Some((buffer, offset, _)) = self.point_to_buffer_offset(point, cx) { let buffer = buffer.read(cx); language = buffer.language_at(offset); file = buffer.file(); @@ -1509,7 +1509,7 @@ impl MultiBuffer { let mut cursor = snapshot.excerpts.cursor::<(Option<&Locator>, usize)>(); for (locator, buffer, buffer_edited) in excerpts_to_edit { - new_excerpts.push_tree(cursor.slice(&Some(locator), Bias::Left, &()), &()); + new_excerpts.append(cursor.slice(&Some(locator), Bias::Left, &()), &()); let old_excerpt = cursor.item().unwrap(); let buffer = buffer.read(cx); let buffer_id = buffer.remote_id(); @@ -1549,7 +1549,7 @@ impl MultiBuffer { new_excerpts.push(new_excerpt, &()); cursor.next(&()); } - new_excerpts.push_tree(cursor.suffix(&()), &()); + new_excerpts.append(cursor.suffix(&()), &()); drop(cursor); snapshot.excerpts = new_excerpts; @@ -5196,7 +5196,7 @@ mod tests { .range_to_buffer_ranges(start_ix..end_ix, cx); let excerpted_buffers_text = excerpted_buffer_ranges .iter() - .map(|(buffer, buffer_range)| { + .map(|(buffer, buffer_range, _)| { buffer .read(cx) .text_for_range(buffer_range.clone()) diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 17e8d18a62..a13619a82a 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -173,6 +173,7 @@ impl ScrollManager { scroll_position: Vector2F, map: &DisplaySnapshot, local: bool, + autoscroll: bool, workspace_id: Option, cx: &mut ViewContext, ) { @@ -203,7 +204,7 @@ impl ScrollManager { ) }; - self.set_anchor(new_anchor, top_row, local, workspace_id, cx); + self.set_anchor(new_anchor, top_row, local, autoscroll, workspace_id, cx); } fn set_anchor( @@ -211,11 +212,12 @@ impl ScrollManager { anchor: ScrollAnchor, top_row: u32, local: bool, + autoscroll: bool, workspace_id: Option, cx: &mut ViewContext, ) { self.anchor = anchor; - cx.emit(Event::ScrollPositionChanged { local }); + cx.emit(Event::ScrollPositionChanged { local, autoscroll }); self.show_scrollbar(cx); self.autoscroll_request.take(); if let Some(workspace_id) = workspace_id { @@ -296,21 +298,28 @@ impl Editor { } pub fn set_scroll_position(&mut self, scroll_position: Vector2F, cx: &mut ViewContext) { - self.set_scroll_position_internal(scroll_position, true, cx); + self.set_scroll_position_internal(scroll_position, true, false, cx); } pub(crate) fn set_scroll_position_internal( &mut self, scroll_position: Vector2F, local: bool, + autoscroll: bool, cx: &mut ViewContext, ) { let map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); hide_hover(self, cx); let workspace_id = self.workspace.as_ref().map(|workspace| workspace.1); - self.scroll_manager - .set_scroll_position(scroll_position, &map, local, workspace_id, cx); + self.scroll_manager.set_scroll_position( + scroll_position, + &map, + local, + autoscroll, + workspace_id, + cx, + ); } pub fn scroll_position(&self, cx: &mut ViewContext) -> Vector2F { @@ -326,7 +335,7 @@ impl Editor { .to_point(&self.buffer().read(cx).snapshot(cx)) .row; self.scroll_manager - .set_anchor(scroll_anchor, top_row, true, workspace_id, cx); + .set_anchor(scroll_anchor, top_row, true, false, workspace_id, cx); } pub(crate) fn set_scroll_anchor_remote( @@ -341,7 +350,7 @@ impl Editor { .to_point(&self.buffer().read(cx).snapshot(cx)) .row; self.scroll_manager - .set_anchor(scroll_anchor, top_row, false, workspace_id, cx); + .set_anchor(scroll_anchor, top_row, false, false, workspace_id, cx); } pub fn scroll_screen(&mut self, amount: &ScrollAmount, cx: &mut ViewContext) { diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index ed098293f6..e83e2286b1 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -136,23 +136,23 @@ impl Editor { if target_top < start_row { scroll_position.set_y(target_top); - self.set_scroll_position_internal(scroll_position, local, cx); + self.set_scroll_position_internal(scroll_position, local, true, cx); } else if target_bottom >= end_row { scroll_position.set_y(target_bottom - visible_lines); - self.set_scroll_position_internal(scroll_position, local, cx); + self.set_scroll_position_internal(scroll_position, local, true, cx); } } AutoscrollStrategy::Center => { scroll_position.set_y((first_cursor_top - margin).max(0.0)); - self.set_scroll_position_internal(scroll_position, local, cx); + self.set_scroll_position_internal(scroll_position, local, true, cx); } AutoscrollStrategy::Top => { scroll_position.set_y((first_cursor_top).max(0.0)); - self.set_scroll_position_internal(scroll_position, local, cx); + self.set_scroll_position_internal(scroll_position, local, true, cx); } AutoscrollStrategy::Bottom => { scroll_position.set_y((last_cursor_bottom - visible_lines).max(0.0)); - self.set_scroll_position_internal(scroll_position, local, cx); + self.set_scroll_position_internal(scroll_position, local, true, cx); } } diff --git a/crates/feedback/src/deploy_feedback_button.rs b/crates/feedback/src/deploy_feedback_button.rs index d32a3e5b4c..beb5284031 100644 --- a/crates/feedback/src/deploy_feedback_button.rs +++ b/crates/feedback/src/deploy_feedback_button.rs @@ -41,7 +41,8 @@ impl View for DeployFeedbackButton { .status_bar .panel_buttons .button - .style_for(state, active); + .in_state(active) + .style_for(state); Svg::new("icons/feedback_16.svg") .with_color(style.icon_color) diff --git a/crates/feedback/src/submit_feedback_button.rs b/crates/feedback/src/submit_feedback_button.rs index 56bc235570..15f77bd561 100644 --- a/crates/feedback/src/submit_feedback_button.rs +++ b/crates/feedback/src/submit_feedback_button.rs @@ -48,7 +48,7 @@ impl View for SubmitFeedbackButton { let theme = theme::current(cx).clone(); enum SubmitFeedbackButton {} MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.feedback.submit_button.style_for(state, false); + let style = theme.feedback.submit_button.style_for(state); Label::new("Submit as Markdown", style.text.clone()) .contained() .with_style(style.container) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 73e7ca6eaa..3f6bd83760 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -546,7 +546,7 @@ impl PickerDelegate for FileFinderDelegate { .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 style = theme.picker.item.in_state(selected).style_for(mouse_state); let (file_name, file_name_positions, full_path, full_path_positions) = self.labels_for_match(path_match, cx, ix); Flex::column() diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 882800f128..41684f3226 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -445,7 +445,7 @@ type WindowBoundsCallback = Box>, &mut WindowContext) -> bool>; type ActiveLabeledTasksCallback = Box bool>; -type DeserializeActionCallback = fn(json: &str) -> anyhow::Result>; +type DeserializeActionCallback = fn(json: serde_json::Value) -> anyhow::Result>; type WindowShouldCloseSubscriptionCallback = Box bool>; pub struct AppContext { @@ -624,14 +624,14 @@ impl AppContext { pub fn deserialize_action( &self, name: &str, - argument: Option<&str>, + argument: Option, ) -> Result> { let callback = self .action_deserializers .get(name) .ok_or_else(|| anyhow!("unknown action {}", name))? .1; - callback(argument.unwrap_or("{}")) + callback(argument.unwrap_or_else(|| serde_json::Value::Object(Default::default()))) .with_context(|| format!("invalid data for action {}", name)) } @@ -5573,7 +5573,7 @@ mod tests { let action1 = cx .deserialize_action( "test::something::ComplexAction", - Some(r#"{"arg": "a", "count": 5}"#), + Some(serde_json::from_str(r#"{"arg": "a", "count": 5}"#).unwrap()), ) .unwrap(); let action2 = cx diff --git a/crates/gpui/src/app/action.rs b/crates/gpui/src/app/action.rs index 5b4df68a65..c6b43e489b 100644 --- a/crates/gpui/src/app/action.rs +++ b/crates/gpui/src/app/action.rs @@ -11,7 +11,7 @@ pub trait Action: 'static { fn qualified_name() -> &'static str where Self: Sized; - fn from_json_str(json: &str) -> anyhow::Result> + fn from_json_str(json: serde_json::Value) -> anyhow::Result> where Self: Sized; } @@ -38,7 +38,7 @@ macro_rules! actions { $crate::__impl_action! { $namespace, $name, - fn from_json_str(_: &str) -> $crate::anyhow::Result> { + fn from_json_str(_: $crate::serde_json::Value) -> $crate::anyhow::Result> { Ok(Box::new(Self)) } } @@ -58,8 +58,8 @@ macro_rules! impl_actions { $crate::__impl_action! { $namespace, $name, - fn from_json_str(json: &str) -> $crate::anyhow::Result> { - Ok(Box::new($crate::serde_json::from_str::(json)?)) + fn from_json_str(json: $crate::serde_json::Value) -> $crate::anyhow::Result> { + Ok(Box::new($crate::serde_json::from_value::(json)?)) } } )* diff --git a/crates/gpui/src/app/window.rs b/crates/gpui/src/app/window.rs index cfcef626df..cffce6c3a6 100644 --- a/crates/gpui/src/app/window.rs +++ b/crates/gpui/src/app/window.rs @@ -394,7 +394,7 @@ impl<'a> WindowContext<'a> { .iter() .filter_map(move |(name, (type_id, deserialize))| { if let Some(action_depth) = handler_depths_by_action_type.get(type_id).copied() { - let action = deserialize("{}").ok()?; + let action = deserialize(serde_json::Value::Object(Default::default())).ok()?; let bindings = self .keystroke_matcher .bindings_for_action_type(*type_id) diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 1cf8cc986f..4c6298d8f5 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -211,7 +211,7 @@ impl Element for List { let mut cursor = old_items.cursor::(); if state.rendered_range.start < new_rendered_range.start { - new_items.push_tree( + new_items.append( cursor.slice(&Count(state.rendered_range.start), Bias::Right, &()), &(), ); @@ -221,7 +221,7 @@ impl Element for List { cursor.next(&()); } } - new_items.push_tree( + new_items.append( cursor.slice(&Count(new_rendered_range.start), Bias::Right, &()), &(), ); @@ -230,7 +230,7 @@ impl Element for List { cursor.seek(&Count(new_rendered_range.end), Bias::Right, &()); if new_rendered_range.end < state.rendered_range.start { - new_items.push_tree( + new_items.append( cursor.slice(&Count(state.rendered_range.start), Bias::Right, &()), &(), ); @@ -240,7 +240,7 @@ impl Element for List { cursor.next(&()); } - new_items.push_tree(cursor.suffix(&()), &()); + new_items.append(cursor.suffix(&()), &()); state.items = new_items; state.rendered_range = new_rendered_range; @@ -413,7 +413,7 @@ impl ListState { old_heights.seek_forward(&Count(old_range.end), Bias::Right, &()); new_heights.extend((0..count).map(|_| ListItem::Unrendered), &()); - new_heights.push_tree(old_heights.suffix(&()), &()); + new_heights.append(old_heights.suffix(&()), &()); drop(old_heights); state.items = new_heights; } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 8b5b801ada..ea415cc6a6 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -786,7 +786,7 @@ impl platform::Platform for MacPlatform { fn set_cursor_style(&self, style: CursorStyle) { unsafe { - let cursor: id = match style { + let new_cursor: id = match style { CursorStyle::Arrow => msg_send![class!(NSCursor), arrowCursor], CursorStyle::ResizeLeftRight => { msg_send![class!(NSCursor), resizeLeftRightCursor] @@ -795,7 +795,11 @@ impl platform::Platform for MacPlatform { CursorStyle::PointingHand => msg_send![class!(NSCursor), pointingHandCursor], CursorStyle::IBeam => msg_send![class!(NSCursor), IBeamCursor], }; - let _: () = msg_send![cursor, set]; + + let old_cursor: id = msg_send![class!(NSCursor), currentCursor]; + if new_cursor != old_cursor { + let _: () = msg_send![new_cursor, set]; + } } } diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 9722b618f3..c1f7e79d58 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -55,7 +55,7 @@ serde_json.workspace = true similar = "1.3" smallvec.workspace = true smol.workspace = true -tree-sitter = "0.20" +tree-sitter.workspace = true tree-sitter-rust = { version = "*", optional = true } tree-sitter-typescript = { version = "*", optional = true } unicase = "2.6" @@ -72,6 +72,8 @@ ctor.workspace = true env_logger.workspace = true indoc.workspace = true rand.workspace = true +unindent.workspace = true + tree-sitter-embedded-template = "*" tree-sitter-html = "*" tree-sitter-javascript = "*" @@ -81,4 +83,3 @@ tree-sitter-rust = "*" tree-sitter-python = "*" tree-sitter-typescript = "*" tree-sitter-ruby = "*" -unindent.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index e09ee48da6..5041ab759d 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -8,7 +8,8 @@ use crate::{ language_settings::{language_settings, LanguageSettings}, outline::OutlineItem, syntax_map::{ - SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxSnapshot, ToTreeSitterPoint, + SyntaxLayerInfo, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxSnapshot, + ToTreeSitterPoint, }, CodeLabel, LanguageScope, Outline, }; @@ -2116,12 +2117,20 @@ impl BufferSnapshot { } } - pub fn language_at(&self, position: D) -> Option<&Arc> { + pub fn syntax_layers(&self) -> impl Iterator + '_ { + self.syntax.layers_for_range(0..self.len(), &self.text) + } + + pub fn syntax_layer_at(&self, position: D) -> Option { let offset = position.to_offset(self); self.syntax .layers_for_range(offset..offset, &self.text) - .filter(|l| l.node.end_byte() > offset) + .filter(|l| l.node().end_byte() > offset) .last() + } + + pub fn language_at(&self, position: D) -> Option<&Arc> { + self.syntax_layer_at(position) .map(|info| info.language) .or(self.language.as_ref()) } @@ -2140,7 +2149,7 @@ impl BufferSnapshot { if let Some(layer_info) = self .syntax .layers_for_range(offset..offset, &self.text) - .filter(|l| l.node.end_byte() > offset) + .filter(|l| l.node().end_byte() > offset) .last() { Some(LanguageScope { @@ -2188,7 +2197,7 @@ impl BufferSnapshot { let range = range.start.to_offset(self)..range.end.to_offset(self); let mut result: Option> = None; 'outer: for layer in self.syntax.layers_for_range(range.clone(), &self.text) { - let mut cursor = layer.node.walk(); + let mut cursor = layer.node().walk(); // Descend to the first leaf that touches the start of the range, // and if the range is non-empty, extends beyond the start. diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 9f44de40ac..38cefbcef9 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -2242,7 +2242,7 @@ fn get_tree_sexp(buffer: &ModelHandle, cx: &gpui::TestAppContext) -> Str buffer.read_with(cx, |buffer, _| { let snapshot = buffer.snapshot(); let layers = snapshot.syntax.layers(buffer.as_text_snapshot()); - layers[0].node.to_sexp() + layers[0].node().to_sexp() }) } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 0ff1d973d3..5a4d604ce3 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -17,7 +17,7 @@ use futures::{ future::{BoxFuture, Shared}, FutureExt, TryFutureExt as _, }; -use gpui::{executor::Background, AppContext, Task}; +use gpui::{executor::Background, AppContext, AsyncAppContext, Task}; use highlight_map::HighlightMap; use lazy_static::lazy_static; use lsp::CodeActionKind; @@ -57,6 +57,7 @@ pub use buffer::*; pub use diagnostic_set::DiagnosticEntry; pub use lsp::LanguageServerId; pub use outline::{Outline, OutlineItem}; +pub use syntax_map::{OwnedSyntaxLayerInfo, SyntaxLayerInfo}; pub use tree_sitter::{Parser, Tree}; pub fn init(cx: &mut AppContext) { @@ -124,27 +125,46 @@ impl CachedLspAdapter { pub async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result> { - self.adapter.fetch_latest_server_version(http).await + self.adapter.fetch_latest_server_version(delegate).await + } + + pub fn will_fetch_server( + &self, + delegate: &Arc, + cx: &mut AsyncAppContext, + ) -> Option>> { + self.adapter.will_fetch_server(delegate, cx) + } + + pub fn will_start_server( + &self, + delegate: &Arc, + cx: &mut AsyncAppContext, + ) -> Option>> { + self.adapter.will_start_server(delegate, cx) } pub async fn fetch_server_binary( &self, version: Box, - http: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result { self.adapter - .fetch_server_binary(version, http, container_dir) + .fetch_server_binary(version, container_dir, delegate) .await } pub async fn cached_server_binary( &self, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Option { - self.adapter.cached_server_binary(container_dir).await + self.adapter + .cached_server_binary(container_dir, delegate) + .await } pub fn code_action_kinds(&self) -> Option> { @@ -186,23 +206,48 @@ impl CachedLspAdapter { } } +pub trait LspAdapterDelegate: Send + Sync { + fn show_notification(&self, message: &str, cx: &mut AppContext); + fn http_client(&self) -> Arc; +} + #[async_trait] pub trait LspAdapter: 'static + Send + Sync { async fn name(&self) -> LanguageServerName; async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result>; + fn will_fetch_server( + &self, + _: &Arc, + _: &mut AsyncAppContext, + ) -> Option>> { + None + } + + fn will_start_server( + &self, + _: &Arc, + _: &mut AsyncAppContext, + ) -> Option>> { + None + } + async fn fetch_server_binary( &self, version: Box, - http: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result; - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option; + async fn cached_server_binary( + &self, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Option; async fn process_diagnostics(&self, _: &mut lsp::PublishDiagnosticsParams) {} @@ -512,10 +557,7 @@ pub struct LanguageRegistry { login_shell_env_loaded: Shared>, #[allow(clippy::type_complexity)] lsp_binary_paths: Mutex< - HashMap< - LanguageServerName, - Shared>>>, - >, + HashMap>>>>, >, executor: Option>, } @@ -811,7 +853,7 @@ impl LanguageRegistry { language: Arc, adapter: Arc, root_path: Arc, - http_client: Arc, + delegate: Arc, cx: &mut AppContext, ) -> Option { let server_id = self.state.write().next_language_server_id(); @@ -859,35 +901,40 @@ impl LanguageRegistry { .log_err()?; let this = self.clone(); let language = language.clone(); - let http_client = http_client.clone(); let download_dir = download_dir.clone(); let root_path = root_path.clone(); let adapter = adapter.clone(); let lsp_binary_statuses = self.lsp_binary_statuses_tx.clone(); let login_shell_env_loaded = self.login_shell_env_loaded.clone(); - let task = cx.spawn(|cx| async move { + let task = cx.spawn(|mut cx| async move { login_shell_env_loaded.await; - let mut lock = this.lsp_binary_paths.lock(); - let entry = lock + let entry = this + .lsp_binary_paths + .lock() .entry(adapter.name.clone()) .or_insert_with(|| { - get_binary( - adapter.clone(), - language.clone(), - http_client, - download_dir, - lsp_binary_statuses, - ) - .map_err(Arc::new) - .boxed() + cx.spawn(|cx| { + get_binary( + adapter.clone(), + language.clone(), + delegate.clone(), + download_dir, + lsp_binary_statuses, + cx, + ) + .map_err(Arc::new) + }) .shared() }) .clone(); - drop(lock); let binary = entry.clone().map_err(|e| anyhow!(e)).await?; + if let Some(task) = adapter.will_start_server(&delegate, &mut cx) { + task.await?; + } + let server = lsp::LanguageServer::new( server_id, &binary.path, @@ -957,9 +1004,10 @@ impl Default for LanguageRegistry { async fn get_binary( adapter: Arc, language: Arc, - http_client: Arc, + delegate: Arc, download_dir: Arc, statuses: async_broadcast::Sender<(Arc, LanguageServerBinaryStatus)>, + mut cx: AsyncAppContext, ) -> Result { let container_dir = download_dir.join(adapter.name.0.as_ref()); if !container_dir.exists() { @@ -968,17 +1016,24 @@ async fn get_binary( .context("failed to create container directory")?; } + if let Some(task) = adapter.will_fetch_server(&delegate, &mut cx) { + task.await?; + } + let binary = fetch_latest_binary( adapter.clone(), language.clone(), - http_client, + delegate.as_ref(), &container_dir, statuses.clone(), ) .await; if let Err(error) = binary.as_ref() { - if let Some(cached) = adapter.cached_server_binary(container_dir).await { + if let Some(cached) = adapter + .cached_server_binary(container_dir, delegate.as_ref()) + .await + { statuses .broadcast((language.clone(), LanguageServerBinaryStatus::Cached)) .await?; @@ -1000,7 +1055,7 @@ async fn get_binary( async fn fetch_latest_binary( adapter: Arc, language: Arc, - http_client: Arc, + delegate: &dyn LspAdapterDelegate, container_dir: &Path, lsp_binary_statuses_tx: async_broadcast::Sender<(Arc, LanguageServerBinaryStatus)>, ) -> Result { @@ -1011,14 +1066,12 @@ async fn fetch_latest_binary( LanguageServerBinaryStatus::CheckingForUpdate, )) .await?; - let version_info = adapter - .fetch_latest_server_version(http_client.clone()) - .await?; + let version_info = adapter.fetch_latest_server_version(delegate).await?; lsp_binary_statuses_tx .broadcast((language.clone(), LanguageServerBinaryStatus::Downloading)) .await?; let binary = adapter - .fetch_server_binary(version_info, http_client, container_dir.to_path_buf()) + .fetch_server_binary(version_info, container_dir.to_path_buf(), delegate) .await?; lsp_binary_statuses_tx .broadcast((language.clone(), LanguageServerBinaryStatus::Downloaded)) @@ -1542,7 +1595,7 @@ impl LspAdapter for Arc { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { unreachable!(); } @@ -1550,13 +1603,17 @@ impl LspAdapter for Arc { async fn fetch_server_binary( &self, _: Box, - _: Arc, _: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { unreachable!(); } - async fn cached_server_binary(&self, _: PathBuf) -> Option { + async fn cached_server_binary( + &self, + _: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { unreachable!(); } diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 1a953b0bf2..832bb59222 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -51,6 +51,7 @@ pub struct LanguageSettings { pub enable_language_server: bool, pub show_copilot_suggestions: bool, pub show_whitespaces: ShowWhitespaceSetting, + pub extend_comment_on_newline: bool, } #[derive(Clone, Debug, Default)] @@ -95,6 +96,8 @@ pub struct LanguageSettingsContent { pub show_copilot_suggestions: Option, #[serde(default)] pub show_whitespaces: Option, + #[serde(default)] + pub extend_comment_on_newline: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] @@ -340,7 +343,10 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent src.show_copilot_suggestions, ); merge(&mut settings.show_whitespaces, src.show_whitespaces); - + merge( + &mut settings.extend_comment_on_newline, + src.extend_comment_on_newline, + ); fn merge(target: &mut T, value: Option) { if let Some(value) = value { *target = value; diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 16313db498..c589e37d8a 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -1,3 +1,6 @@ +#[cfg(test)] +mod syntax_map_tests; + use crate::{Grammar, InjectionConfig, Language, LanguageRegistry}; use collections::HashMap; use futures::FutureExt; @@ -125,8 +128,17 @@ impl SyntaxLayerContent { #[derive(Debug)] pub struct SyntaxLayerInfo<'a> { pub depth: usize, - pub node: Node<'a>, pub language: &'a Arc, + tree: &'a Tree, + offset: (usize, tree_sitter::Point), +} + +#[derive(Clone)] +pub struct OwnedSyntaxLayerInfo { + pub depth: usize, + pub language: Arc, + tree: tree_sitter::Tree, + offset: (usize, tree_sitter::Point), } #[derive(Debug, Clone)] @@ -276,7 +288,7 @@ impl SyntaxSnapshot { }; if target.cmp(&cursor.start(), text).is_gt() { let slice = cursor.slice(&target, Bias::Left, text); - layers.push_tree(slice, text); + layers.append(slice, text); } } // If this layer follows all of the edits, then preserve it and any @@ -291,7 +303,7 @@ impl SyntaxSnapshot { Bias::Left, text, ); - layers.push_tree(slice, text); + layers.append(slice, text); continue; }; @@ -357,7 +369,7 @@ impl SyntaxSnapshot { cursor.next(text); } - layers.push_tree(cursor.suffix(&text), &text); + layers.append(cursor.suffix(&text), &text); drop(cursor); self.layers = layers; } @@ -466,7 +478,7 @@ impl SyntaxSnapshot { if bounded_position.cmp(&cursor.start(), &text).is_gt() { let slice = cursor.slice(&bounded_position, Bias::Left, text); if !slice.is_empty() { - layers.push_tree(slice, &text); + layers.append(slice, &text); if changed_regions.prune(cursor.end(text), text) { done = false; } @@ -529,6 +541,15 @@ impl SyntaxSnapshot { .to_ts_point(); } + if included_ranges.is_empty() { + included_ranges.push(tree_sitter::Range { + start_byte: 0, + end_byte: 0, + start_point: Default::default(), + end_point: Default::default(), + }); + } + if let Some(SyntaxLayerContent::Parsed { tree: old_tree, .. }) = old_layer.map(|layer| &layer.content) { @@ -664,8 +685,9 @@ impl SyntaxSnapshot { text, [SyntaxLayerInfo { language, + tree, depth: 0, - node: tree.root_node(), + offset: (0, tree_sitter::Point::new(0, 0)), }] .into_iter(), query, @@ -728,9 +750,10 @@ impl SyntaxSnapshot { while let Some(layer) = cursor.item() { if let SyntaxLayerContent::Parsed { tree, language } = &layer.content { let info = SyntaxLayerInfo { + tree, language, depth: layer.depth, - node: tree.root_node_with_offset( + offset: ( layer.range.start.to_offset(buffer), layer.range.start.to_point(buffer).to_ts_point(), ), @@ -766,13 +789,8 @@ impl<'a> SyntaxMapCaptures<'a> { grammars: Vec::new(), active_layer_count: 0, }; - for SyntaxLayerInfo { - language, - depth, - node, - } in layers - { - let grammar = match &language.grammar { + for layer in layers { + let grammar = match &layer.language.grammar { Some(grammar) => grammar, None => continue, }; @@ -789,7 +807,7 @@ impl<'a> SyntaxMapCaptures<'a> { }; cursor.set_byte_range(range.clone()); - let captures = cursor.captures(query, node, TextProvider(text)); + let captures = cursor.captures(query, layer.node(), TextProvider(text)); let grammar_index = result .grammars .iter() @@ -799,7 +817,7 @@ impl<'a> SyntaxMapCaptures<'a> { result.grammars.len() - 1 }); let mut layer = SyntaxMapCapturesLayer { - depth, + depth: layer.depth, grammar_index, next_capture: None, captures, @@ -889,13 +907,8 @@ impl<'a> SyntaxMapMatches<'a> { query: fn(&Grammar) -> Option<&Query>, ) -> Self { let mut result = Self::default(); - for SyntaxLayerInfo { - language, - depth, - node, - } in layers - { - let grammar = match &language.grammar { + for layer in layers { + let grammar = match &layer.language.grammar { Some(grammar) => grammar, None => continue, }; @@ -912,7 +925,7 @@ impl<'a> SyntaxMapMatches<'a> { }; cursor.set_byte_range(range.clone()); - let matches = cursor.matches(query, node, TextProvider(text)); + let matches = cursor.matches(query, layer.node(), TextProvider(text)); let grammar_index = result .grammars .iter() @@ -922,7 +935,7 @@ impl<'a> SyntaxMapMatches<'a> { result.grammars.len() - 1 }); let mut layer = SyntaxMapMatchesLayer { - depth, + depth: layer.depth, grammar_index, matches, next_pattern_index: 0, @@ -1219,7 +1232,7 @@ fn get_injections( } } -fn splice_included_ranges( +pub(crate) fn splice_included_ranges( mut ranges: Vec, changed_ranges: &[Range], new_ranges: &[tree_sitter::Range], @@ -1290,7 +1303,28 @@ fn splice_included_ranges( ranges } +impl OwnedSyntaxLayerInfo { + pub fn node(&self) -> Node { + self.tree + .root_node_with_offset(self.offset.0, self.offset.1) + } +} + impl<'a> SyntaxLayerInfo<'a> { + pub fn to_owned(&self) -> OwnedSyntaxLayerInfo { + OwnedSyntaxLayerInfo { + tree: self.tree.clone(), + offset: self.offset, + depth: self.depth, + language: self.language.clone(), + } + } + + pub fn node(&self) -> Node<'a> { + self.tree + .root_node_with_offset(self.offset.0, self.offset.1) + } + pub(crate) fn override_id(&self, offset: usize, text: &text::BufferSnapshot) -> Option { let text = TextProvider(text.as_rope()); let config = self.language.grammar.as_ref()?.override_config.as_ref()?; @@ -1299,7 +1333,7 @@ impl<'a> SyntaxLayerInfo<'a> { query_cursor.set_byte_range(offset..offset); let mut smallest_match: Option<(u32, Range)> = None; - for mat in query_cursor.matches(&config.query, self.node, text) { + for mat in query_cursor.matches(&config.query, self.node(), text) { for capture in mat.captures { if !config.values.contains_key(&capture.index) { continue; @@ -1594,1171 +1628,3 @@ impl ToTreeSitterPoint for Point { Point::new(point.row as u32, point.column as u32) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::LanguageConfig; - use rand::rngs::StdRng; - use std::env; - use text::Buffer; - use unindent::Unindent as _; - use util::test::marked_text_ranges; - - #[test] - fn test_splice_included_ranges() { - let ranges = vec![ts_range(20..30), ts_range(50..60), ts_range(80..90)]; - - let new_ranges = splice_included_ranges( - ranges.clone(), - &[54..56, 58..68], - &[ts_range(50..54), ts_range(59..67)], - ); - assert_eq!( - new_ranges, - &[ - ts_range(20..30), - ts_range(50..54), - ts_range(59..67), - ts_range(80..90), - ] - ); - - let new_ranges = splice_included_ranges(ranges.clone(), &[70..71, 91..100], &[]); - assert_eq!( - new_ranges, - &[ts_range(20..30), ts_range(50..60), ts_range(80..90)] - ); - - let new_ranges = - splice_included_ranges(ranges.clone(), &[], &[ts_range(0..2), ts_range(70..75)]); - assert_eq!( - new_ranges, - &[ - ts_range(0..2), - ts_range(20..30), - ts_range(50..60), - ts_range(70..75), - ts_range(80..90) - ] - ); - - let new_ranges = splice_included_ranges(ranges.clone(), &[30..50], &[ts_range(25..55)]); - assert_eq!(new_ranges, &[ts_range(25..55), ts_range(80..90)]); - - fn ts_range(range: Range) -> tree_sitter::Range { - tree_sitter::Range { - start_byte: range.start, - start_point: tree_sitter::Point { - row: 0, - column: range.start, - }, - end_byte: range.end, - end_point: tree_sitter::Point { - row: 0, - column: range.end, - }, - } - } - } - - #[gpui::test] - fn test_syntax_map_layers_for_range() { - let registry = Arc::new(LanguageRegistry::test()); - let language = Arc::new(rust_lang()); - registry.add(language.clone()); - - let mut buffer = Buffer::new( - 0, - 0, - r#" - fn a() { - assert_eq!( - b(vec![C {}]), - vec![d.e], - ); - println!("{}", f(|_| true)); - } - "# - .unindent(), - ); - - let mut syntax_map = SyntaxMap::new(); - syntax_map.set_language_registry(registry.clone()); - syntax_map.reparse(language.clone(), &buffer); - - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(2, 0)..Point::new(2, 0), - &[ - "...(function_item ... (block (expression_statement (macro_invocation...", - "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", - ], - ); - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(2, 14)..Point::new(2, 16), - &[ - "...(function_item ...", - "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", - "...(array_expression (struct_expression ...", - ], - ); - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(3, 14)..Point::new(3, 16), - &[ - "...(function_item ...", - "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", - "...(array_expression (field_expression ...", - ], - ); - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(5, 12)..Point::new(5, 16), - &[ - "...(function_item ...", - "...(call_expression ... (arguments (closure_expression ...", - ], - ); - - // Replace a vec! macro invocation with a plain slice, removing a syntactic layer. - let macro_name_range = range_for_text(&buffer, "vec!"); - buffer.edit([(macro_name_range, "&")]); - syntax_map.interpolate(&buffer); - syntax_map.reparse(language.clone(), &buffer); - - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(2, 14)..Point::new(2, 16), - &[ - "...(function_item ...", - "...(tuple_expression (call_expression ... arguments: (arguments (reference_expression value: (array_expression...", - ], - ); - - // Put the vec! macro back, adding back the syntactic layer. - buffer.undo(); - syntax_map.interpolate(&buffer); - syntax_map.reparse(language.clone(), &buffer); - - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(2, 14)..Point::new(2, 16), - &[ - "...(function_item ...", - "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", - "...(array_expression (struct_expression ...", - ], - ); - } - - #[gpui::test] - fn test_dynamic_language_injection() { - let registry = Arc::new(LanguageRegistry::test()); - let markdown = Arc::new(markdown_lang()); - registry.add(markdown.clone()); - registry.add(Arc::new(rust_lang())); - registry.add(Arc::new(ruby_lang())); - - let mut buffer = Buffer::new( - 0, - 0, - r#" - This is a code block: - - ```rs - fn foo() {} - ``` - "# - .unindent(), - ); - - let mut syntax_map = SyntaxMap::new(); - syntax_map.set_language_registry(registry.clone()); - syntax_map.reparse(markdown.clone(), &buffer); - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(3, 0)..Point::new(3, 0), - &[ - "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter...", - "...(function_item name: (identifier) parameters: (parameters) body: (block)...", - ], - ); - - // Replace Rust with Ruby in code block. - let macro_name_range = range_for_text(&buffer, "rs"); - buffer.edit([(macro_name_range, "ruby")]); - syntax_map.interpolate(&buffer); - syntax_map.reparse(markdown.clone(), &buffer); - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(3, 0)..Point::new(3, 0), - &[ - "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter...", - "...(call method: (identifier) arguments: (argument_list (call method: (identifier) arguments: (argument_list) block: (block)...", - ], - ); - - // Replace Ruby with a language that hasn't been loaded yet. - let macro_name_range = range_for_text(&buffer, "ruby"); - buffer.edit([(macro_name_range, "html")]); - syntax_map.interpolate(&buffer); - syntax_map.reparse(markdown.clone(), &buffer); - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(3, 0)..Point::new(3, 0), - &[ - "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter..." - ], - ); - assert!(syntax_map.contains_unknown_injections()); - - registry.add(Arc::new(html_lang())); - syntax_map.reparse(markdown.clone(), &buffer); - assert_layers_for_range( - &syntax_map, - &buffer, - Point::new(3, 0)..Point::new(3, 0), - &[ - "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter...", - "(fragment (text))", - ], - ); - assert!(!syntax_map.contains_unknown_injections()); - } - - #[gpui::test] - fn test_typing_multiple_new_injections() { - let (buffer, syntax_map) = test_edit_sequence( - "Rust", - &[ - "fn a() { dbg }", - "fn a() { dbg«!» }", - "fn a() { dbg!«()» }", - "fn a() { dbg!(«b») }", - "fn a() { dbg!(b«.») }", - "fn a() { dbg!(b.«c») }", - "fn a() { dbg!(b.c«()») }", - "fn a() { dbg!(b.c(«vec»)) }", - "fn a() { dbg!(b.c(vec«!»)) }", - "fn a() { dbg!(b.c(vec!«[]»)) }", - "fn a() { dbg!(b.c(vec![«d»])) }", - "fn a() { dbg!(b.c(vec![d«.»])) }", - "fn a() { dbg!(b.c(vec![d.«e»])) }", - ], - ); - - assert_capture_ranges( - &syntax_map, - &buffer, - &["field"], - "fn a() { dbg!(b.«c»(vec![d.«e»])) }", - ); - } - - #[gpui::test] - fn test_pasting_new_injection_line_between_others() { - let (buffer, syntax_map) = test_edit_sequence( - "Rust", - &[ - " - fn a() { - b!(B {}); - c!(C {}); - d!(D {}); - e!(E {}); - f!(F {}); - g!(G {}); - } - ", - " - fn a() { - b!(B {}); - c!(C {}); - d!(D {}); - « h!(H {}); - » e!(E {}); - f!(F {}); - g!(G {}); - } - ", - ], - ); - - assert_capture_ranges( - &syntax_map, - &buffer, - &["struct"], - " - fn a() { - b!(«B {}»); - c!(«C {}»); - d!(«D {}»); - h!(«H {}»); - e!(«E {}»); - f!(«F {}»); - g!(«G {}»); - } - ", - ); - } - - #[gpui::test] - fn test_joining_injections_with_child_injections() { - let (buffer, syntax_map) = test_edit_sequence( - "Rust", - &[ - " - fn a() { - b!( - c![one.two.three], - d![four.five.six], - ); - e!( - f![seven.eight], - ); - } - ", - " - fn a() { - b!( - c![one.two.three], - d![four.five.six], - ˇ f![seven.eight], - ); - } - ", - ], - ); - - assert_capture_ranges( - &syntax_map, - &buffer, - &["field"], - " - fn a() { - b!( - c![one.«two».«three»], - d![four.«five».«six»], - f![seven.«eight»], - ); - } - ", - ); - } - - #[gpui::test] - fn test_editing_edges_of_injection() { - test_edit_sequence( - "Rust", - &[ - " - fn a() { - b!(c!()) - } - ", - " - fn a() { - «d»!(c!()) - } - ", - " - fn a() { - «e»d!(c!()) - } - ", - " - fn a() { - ed!«[»c!()«]» - } - ", - ], - ); - } - - #[gpui::test] - fn test_edits_preceding_and_intersecting_injection() { - test_edit_sequence( - "Rust", - &[ - // - "const aaaaaaaaaaaa: B = c!(d(e.f));", - "const aˇa: B = c!(d(eˇ));", - ], - ); - } - - #[gpui::test] - fn test_non_local_changes_create_injections() { - test_edit_sequence( - "Rust", - &[ - " - // a! { - static B: C = d; - // } - ", - " - ˇa! { - static B: C = d; - ˇ} - ", - ], - ); - } - - #[gpui::test] - fn test_creating_many_injections_in_one_edit() { - test_edit_sequence( - "Rust", - &[ - " - fn a() { - one(Two::three(3)); - four(Five::six(6)); - seven(Eight::nine(9)); - } - ", - " - fn a() { - one«!»(Two::three(3)); - four«!»(Five::six(6)); - seven«!»(Eight::nine(9)); - } - ", - " - fn a() { - one!(Two::three«!»(3)); - four!(Five::six«!»(6)); - seven!(Eight::nine«!»(9)); - } - ", - ], - ); - } - - #[gpui::test] - fn test_editing_across_injection_boundary() { - test_edit_sequence( - "Rust", - &[ - " - fn one() { - two(); - three!( - three.four, - five.six, - ); - } - ", - " - fn one() { - two(); - th«irty_five![» - three.four, - five.six, - « seven.eight, - ];» - } - ", - ], - ); - } - - #[gpui::test] - fn test_removing_injection_by_replacing_across_boundary() { - test_edit_sequence( - "Rust", - &[ - " - fn one() { - two!( - three.four, - ); - } - ", - " - fn one() { - t«en - .eleven( - twelve, - » - three.four, - ); - } - ", - ], - ); - } - - #[gpui::test] - fn test_combined_injections() { - let (buffer, syntax_map) = test_edit_sequence( - "ERB", - &[ - " - - <% if @one %> -
- <% else %> -
- <% end %> -
- - ", - " - - <% if @one %> -
- ˇ else ˇ -
- <% end %> -
- - ", - " - - <% if @one «;» end %> -
- - ", - ], - ); - - assert_capture_ranges( - &syntax_map, - &buffer, - &["tag", "ivar"], - " - <«body»> - <% if «@one» ; end %> - - - ", - ); - } - - #[gpui::test] - fn test_combined_injections_empty_ranges() { - test_edit_sequence( - "ERB", - &[ - " - <% if @one %> - <% else %> - <% end %> - ", - " - <% if @one %> - ˇ<% end %> - ", - ], - ); - } - - #[gpui::test] - fn test_combined_injections_edit_edges_of_ranges() { - let (buffer, syntax_map) = test_edit_sequence( - "ERB", - &[ - " - <%= one @two %> - <%= three @four %> - ", - " - <%= one @two %ˇ - <%= three @four %> - ", - " - <%= one @two %«>» - <%= three @four %> - ", - ], - ); - - assert_capture_ranges( - &syntax_map, - &buffer, - &["tag", "ivar"], - " - <%= one «@two» %> - <%= three «@four» %> - ", - ); - } - - #[gpui::test] - fn test_combined_injections_splitting_some_injections() { - let (_buffer, _syntax_map) = test_edit_sequence( - "ERB", - &[ - r#" - <%A if b(:c) %> - d - <% end %> - eee - <% f %> - "#, - r#" - <%« AAAAAAA %> - hhhhhhh - <%=» if b(:c) %> - d - <% end %> - eee - <% f %> - "#, - ], - ); - } - - #[gpui::test] - fn test_combined_injections_inside_injections() { - let (_buffer, _syntax_map) = test_edit_sequence( - "Markdown", - &[ - r#" - here is some ERB code: - - ```erb -
    - <% people.each do |person| %> -
  • <%= person.name %>
  • - <% end %> -
- ``` - "#, - r#" - here is some ERB code: - - ```erb -
    - <% people«2».each do |person| %> -
  • <%= person.name %>
  • - <% end %> -
- ``` - "#, - ], - ); - } - - #[gpui::test(iterations = 50)] - fn test_random_syntax_map_edits(mut rng: StdRng) { - let operations = env::var("OPERATIONS") - .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) - .unwrap_or(10); - - let text = r#" - fn test_something() { - let vec = vec![5, 1, 3, 8]; - assert_eq!( - vec - .into_iter() - .map(|i| i * 2) - .collect::>(), - vec![ - 5 * 2, 1 * 2, 3 * 2, 8 * 2 - ], - ); - } - "# - .unindent() - .repeat(2); - - let registry = Arc::new(LanguageRegistry::test()); - let language = Arc::new(rust_lang()); - registry.add(language.clone()); - let mut buffer = Buffer::new(0, 0, text); - - let mut syntax_map = SyntaxMap::new(); - syntax_map.set_language_registry(registry.clone()); - syntax_map.reparse(language.clone(), &buffer); - - let mut reference_syntax_map = SyntaxMap::new(); - reference_syntax_map.set_language_registry(registry.clone()); - - log::info!("initial text:\n{}", buffer.text()); - - for _ in 0..operations { - let prev_buffer = buffer.snapshot(); - let prev_syntax_map = syntax_map.snapshot(); - - buffer.randomly_edit(&mut rng, 3); - log::info!("text:\n{}", buffer.text()); - - syntax_map.interpolate(&buffer); - check_interpolation(&prev_syntax_map, &syntax_map, &prev_buffer, &buffer); - - syntax_map.reparse(language.clone(), &buffer); - - reference_syntax_map.clear(); - reference_syntax_map.reparse(language.clone(), &buffer); - } - - for i in 0..operations { - let i = operations - i - 1; - buffer.undo(); - log::info!("undoing operation {}", i); - log::info!("text:\n{}", buffer.text()); - - syntax_map.interpolate(&buffer); - syntax_map.reparse(language.clone(), &buffer); - - reference_syntax_map.clear(); - reference_syntax_map.reparse(language.clone(), &buffer); - assert_eq!( - syntax_map.layers(&buffer).len(), - reference_syntax_map.layers(&buffer).len(), - "wrong number of layers after undoing edit {i}" - ); - } - - let layers = syntax_map.layers(&buffer); - let reference_layers = reference_syntax_map.layers(&buffer); - for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter()) - { - assert_eq!(edited_layer.node.to_sexp(), reference_layer.node.to_sexp()); - assert_eq!(edited_layer.node.range(), reference_layer.node.range()); - } - } - - #[gpui::test(iterations = 50)] - fn test_random_syntax_map_edits_with_combined_injections(mut rng: StdRng) { - let operations = env::var("OPERATIONS") - .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) - .unwrap_or(10); - - let text = r#" -
- <% if one?(:two) %> -

- <%= yield :five %> -

- <% elsif Six.seven(8) %> -

- <%= yield :five %> -

- <% else %> - Ok - <% end %> -
- "# - .unindent() - .repeat(8); - - let registry = Arc::new(LanguageRegistry::test()); - let language = Arc::new(erb_lang()); - registry.add(language.clone()); - registry.add(Arc::new(ruby_lang())); - registry.add(Arc::new(html_lang())); - let mut buffer = Buffer::new(0, 0, text); - - let mut syntax_map = SyntaxMap::new(); - syntax_map.set_language_registry(registry.clone()); - syntax_map.reparse(language.clone(), &buffer); - - let mut reference_syntax_map = SyntaxMap::new(); - reference_syntax_map.set_language_registry(registry.clone()); - - log::info!("initial text:\n{}", buffer.text()); - - for _ in 0..operations { - let prev_buffer = buffer.snapshot(); - let prev_syntax_map = syntax_map.snapshot(); - - buffer.randomly_edit(&mut rng, 3); - log::info!("text:\n{}", buffer.text()); - - syntax_map.interpolate(&buffer); - check_interpolation(&prev_syntax_map, &syntax_map, &prev_buffer, &buffer); - - syntax_map.reparse(language.clone(), &buffer); - - reference_syntax_map.clear(); - reference_syntax_map.reparse(language.clone(), &buffer); - } - - for i in 0..operations { - let i = operations - i - 1; - buffer.undo(); - log::info!("undoing operation {}", i); - log::info!("text:\n{}", buffer.text()); - - syntax_map.interpolate(&buffer); - syntax_map.reparse(language.clone(), &buffer); - - reference_syntax_map.clear(); - reference_syntax_map.reparse(language.clone(), &buffer); - assert_eq!( - syntax_map.layers(&buffer).len(), - reference_syntax_map.layers(&buffer).len(), - "wrong number of layers after undoing edit {i}" - ); - } - - let layers = syntax_map.layers(&buffer); - let reference_layers = reference_syntax_map.layers(&buffer); - for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter()) - { - assert_eq!(edited_layer.node.to_sexp(), reference_layer.node.to_sexp()); - assert_eq!(edited_layer.node.range(), reference_layer.node.range()); - } - } - - fn check_interpolation( - old_syntax_map: &SyntaxSnapshot, - new_syntax_map: &SyntaxSnapshot, - old_buffer: &BufferSnapshot, - new_buffer: &BufferSnapshot, - ) { - let edits = new_buffer - .edits_since::(&old_buffer.version()) - .collect::>(); - - for (old_layer, new_layer) in old_syntax_map - .layers - .iter() - .zip(new_syntax_map.layers.iter()) - { - assert_eq!(old_layer.range, new_layer.range); - let Some(old_tree) = old_layer.content.tree() else { continue }; - let Some(new_tree) = new_layer.content.tree() else { continue }; - let old_start_byte = old_layer.range.start.to_offset(old_buffer); - let new_start_byte = new_layer.range.start.to_offset(new_buffer); - let old_start_point = old_layer.range.start.to_point(old_buffer).to_ts_point(); - let new_start_point = new_layer.range.start.to_point(new_buffer).to_ts_point(); - let old_node = old_tree.root_node_with_offset(old_start_byte, old_start_point); - let new_node = new_tree.root_node_with_offset(new_start_byte, new_start_point); - check_node_edits( - old_layer.depth, - &old_layer.range, - old_node, - new_node, - old_buffer, - new_buffer, - &edits, - ); - } - - fn check_node_edits( - depth: usize, - range: &Range, - old_node: Node, - new_node: Node, - old_buffer: &BufferSnapshot, - new_buffer: &BufferSnapshot, - edits: &[text::Edit], - ) { - assert_eq!(old_node.kind(), new_node.kind()); - - let old_range = old_node.byte_range(); - let new_range = new_node.byte_range(); - - let is_edited = edits - .iter() - .any(|edit| edit.new.start < new_range.end && edit.new.end > new_range.start); - if is_edited { - assert!( - new_node.has_changes(), - concat!( - "failed to mark node as edited.\n", - "layer depth: {}, old layer range: {:?}, new layer range: {:?},\n", - "node kind: {}, old node range: {:?}, new node range: {:?}", - ), - depth, - range.to_offset(old_buffer), - range.to_offset(new_buffer), - new_node.kind(), - old_range, - new_range, - ); - } - - if !new_node.has_changes() { - assert_eq!( - old_buffer - .text_for_range(old_range.clone()) - .collect::(), - new_buffer - .text_for_range(new_range.clone()) - .collect::(), - concat!( - "mismatched text for node\n", - "layer depth: {}, old layer range: {:?}, new layer range: {:?},\n", - "node kind: {}, old node range:{:?}, new node range:{:?}", - ), - depth, - range.to_offset(old_buffer), - range.to_offset(new_buffer), - new_node.kind(), - old_range, - new_range, - ); - } - - for i in 0..new_node.child_count() { - check_node_edits( - depth, - range, - old_node.child(i).unwrap(), - new_node.child(i).unwrap(), - old_buffer, - new_buffer, - edits, - ) - } - } - } - - fn test_edit_sequence(language_name: &str, steps: &[&str]) -> (Buffer, SyntaxMap) { - let registry = Arc::new(LanguageRegistry::test()); - registry.add(Arc::new(rust_lang())); - registry.add(Arc::new(ruby_lang())); - registry.add(Arc::new(html_lang())); - registry.add(Arc::new(erb_lang())); - registry.add(Arc::new(markdown_lang())); - let language = registry - .language_for_name(language_name) - .now_or_never() - .unwrap() - .unwrap(); - let mut buffer = Buffer::new(0, 0, Default::default()); - - let mut mutated_syntax_map = SyntaxMap::new(); - mutated_syntax_map.set_language_registry(registry.clone()); - mutated_syntax_map.reparse(language.clone(), &buffer); - - for (i, marked_string) in steps.into_iter().enumerate() { - buffer.edit_via_marked_text(&marked_string.unindent()); - - // Reparse the syntax map - mutated_syntax_map.interpolate(&buffer); - mutated_syntax_map.reparse(language.clone(), &buffer); - - // Create a second syntax map from scratch - let mut reference_syntax_map = SyntaxMap::new(); - reference_syntax_map.set_language_registry(registry.clone()); - reference_syntax_map.reparse(language.clone(), &buffer); - - // Compare the mutated syntax map to the new syntax map - let mutated_layers = mutated_syntax_map.layers(&buffer); - let reference_layers = reference_syntax_map.layers(&buffer); - assert_eq!( - mutated_layers.len(), - reference_layers.len(), - "wrong number of layers at step {i}" - ); - for (edited_layer, reference_layer) in - mutated_layers.into_iter().zip(reference_layers.into_iter()) - { - assert_eq!( - edited_layer.node.to_sexp(), - reference_layer.node.to_sexp(), - "different layer at step {i}" - ); - assert_eq!( - edited_layer.node.range(), - reference_layer.node.range(), - "different layer at step {i}" - ); - } - } - - (buffer, mutated_syntax_map) - } - - fn html_lang() -> Language { - Language::new( - LanguageConfig { - name: "HTML".into(), - path_suffixes: vec!["html".to_string()], - ..Default::default() - }, - Some(tree_sitter_html::language()), - ) - .with_highlights_query( - r#" - (tag_name) @tag - (erroneous_end_tag_name) @tag - (attribute_name) @property - "#, - ) - .unwrap() - } - - fn ruby_lang() -> Language { - Language::new( - LanguageConfig { - name: "Ruby".into(), - path_suffixes: vec!["rb".to_string()], - ..Default::default() - }, - Some(tree_sitter_ruby::language()), - ) - .with_highlights_query( - r#" - ["if" "do" "else" "end"] @keyword - (instance_variable) @ivar - "#, - ) - .unwrap() - } - - fn erb_lang() -> Language { - Language::new( - LanguageConfig { - name: "ERB".into(), - path_suffixes: vec!["erb".to_string()], - ..Default::default() - }, - Some(tree_sitter_embedded_template::language()), - ) - .with_highlights_query( - r#" - ["<%" "%>"] @keyword - "#, - ) - .unwrap() - .with_injection_query( - r#" - ((code) @content - (#set! "language" "ruby") - (#set! "combined")) - - ((content) @content - (#set! "language" "html") - (#set! "combined")) - "#, - ) - .unwrap() - } - - fn rust_lang() -> Language { - Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ) - .with_highlights_query( - r#" - (field_identifier) @field - (struct_expression) @struct - "#, - ) - .unwrap() - .with_injection_query( - r#" - (macro_invocation - (token_tree) @content - (#set! "language" "rust")) - "#, - ) - .unwrap() - } - - fn markdown_lang() -> Language { - Language::new( - LanguageConfig { - name: "Markdown".into(), - path_suffixes: vec!["md".into()], - ..Default::default() - }, - Some(tree_sitter_markdown::language()), - ) - .with_injection_query( - r#" - (fenced_code_block - (info_string - (language) @language) - (code_fence_content) @content) - "#, - ) - .unwrap() - } - - fn range_for_text(buffer: &Buffer, text: &str) -> Range { - let start = buffer.as_rope().to_string().find(text).unwrap(); - start..start + text.len() - } - - fn assert_layers_for_range( - syntax_map: &SyntaxMap, - buffer: &BufferSnapshot, - range: Range, - expected_layers: &[&str], - ) { - let layers = syntax_map - .layers_for_range(range, &buffer) - .collect::>(); - assert_eq!( - layers.len(), - expected_layers.len(), - "wrong number of layers" - ); - for (i, (SyntaxLayerInfo { node, .. }, expected_s_exp)) in - layers.iter().zip(expected_layers.iter()).enumerate() - { - let actual_s_exp = node.to_sexp(); - assert!( - string_contains_sequence( - &actual_s_exp, - &expected_s_exp.split("...").collect::>() - ), - "layer {i}:\n\nexpected: {expected_s_exp}\nactual: {actual_s_exp}", - ); - } - } - - fn assert_capture_ranges( - syntax_map: &SyntaxMap, - buffer: &BufferSnapshot, - highlight_query_capture_names: &[&str], - marked_string: &str, - ) { - let mut actual_ranges = Vec::>::new(); - let captures = syntax_map.captures(0..buffer.len(), buffer, |grammar| { - grammar.highlights_query.as_ref() - }); - let queries = captures - .grammars() - .iter() - .map(|grammar| grammar.highlights_query.as_ref().unwrap()) - .collect::>(); - for capture in captures { - let name = &queries[capture.grammar_index].capture_names()[capture.index as usize]; - if highlight_query_capture_names.contains(&name.as_str()) { - actual_ranges.push(capture.node.byte_range()); - } - } - - let (text, expected_ranges) = marked_text_ranges(&marked_string.unindent(), false); - assert_eq!(text, buffer.text()); - assert_eq!(actual_ranges, expected_ranges); - } - - pub fn string_contains_sequence(text: &str, parts: &[&str]) -> bool { - let mut last_part_end = 0; - for part in parts { - if let Some(start_ix) = text[last_part_end..].find(part) { - last_part_end = start_ix + part.len(); - } else { - return false; - } - } - true - } -} diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs new file mode 100644 index 0000000000..57b5cd4a8c --- /dev/null +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -0,0 +1,1199 @@ +use super::*; +use crate::LanguageConfig; +use rand::rngs::StdRng; +use std::{env, ops::Range, sync::Arc}; +use text::Buffer; +use tree_sitter::Node; +use unindent::Unindent as _; +use util::test::marked_text_ranges; + +#[test] +fn test_splice_included_ranges() { + let ranges = vec![ts_range(20..30), ts_range(50..60), ts_range(80..90)]; + + let new_ranges = splice_included_ranges( + ranges.clone(), + &[54..56, 58..68], + &[ts_range(50..54), ts_range(59..67)], + ); + assert_eq!( + new_ranges, + &[ + ts_range(20..30), + ts_range(50..54), + ts_range(59..67), + ts_range(80..90), + ] + ); + + let new_ranges = splice_included_ranges(ranges.clone(), &[70..71, 91..100], &[]); + assert_eq!( + new_ranges, + &[ts_range(20..30), ts_range(50..60), ts_range(80..90)] + ); + + let new_ranges = + splice_included_ranges(ranges.clone(), &[], &[ts_range(0..2), ts_range(70..75)]); + assert_eq!( + new_ranges, + &[ + ts_range(0..2), + ts_range(20..30), + ts_range(50..60), + ts_range(70..75), + ts_range(80..90) + ] + ); + + let new_ranges = splice_included_ranges(ranges.clone(), &[30..50], &[ts_range(25..55)]); + assert_eq!(new_ranges, &[ts_range(25..55), ts_range(80..90)]); + + fn ts_range(range: Range) -> tree_sitter::Range { + tree_sitter::Range { + start_byte: range.start, + start_point: tree_sitter::Point { + row: 0, + column: range.start, + }, + end_byte: range.end, + end_point: tree_sitter::Point { + row: 0, + column: range.end, + }, + } + } +} + +#[gpui::test] +fn test_syntax_map_layers_for_range() { + let registry = Arc::new(LanguageRegistry::test()); + let language = Arc::new(rust_lang()); + registry.add(language.clone()); + + let mut buffer = Buffer::new( + 0, + 0, + r#" + fn a() { + assert_eq!( + b(vec![C {}]), + vec![d.e], + ); + println!("{}", f(|_| true)); + } + "# + .unindent(), + ); + + let mut syntax_map = SyntaxMap::new(); + syntax_map.set_language_registry(registry.clone()); + syntax_map.reparse(language.clone(), &buffer); + + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(2, 0)..Point::new(2, 0), + &[ + "...(function_item ... (block (expression_statement (macro_invocation...", + "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", + ], + ); + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(2, 14)..Point::new(2, 16), + &[ + "...(function_item ...", + "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", + "...(array_expression (struct_expression ...", + ], + ); + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(3, 14)..Point::new(3, 16), + &[ + "...(function_item ...", + "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", + "...(array_expression (field_expression ...", + ], + ); + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(5, 12)..Point::new(5, 16), + &[ + "...(function_item ...", + "...(call_expression ... (arguments (closure_expression ...", + ], + ); + + // Replace a vec! macro invocation with a plain slice, removing a syntactic layer. + let macro_name_range = range_for_text(&buffer, "vec!"); + buffer.edit([(macro_name_range, "&")]); + syntax_map.interpolate(&buffer); + syntax_map.reparse(language.clone(), &buffer); + + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(2, 14)..Point::new(2, 16), + &[ + "...(function_item ...", + "...(tuple_expression (call_expression ... arguments: (arguments (reference_expression value: (array_expression...", + ], + ); + + // Put the vec! macro back, adding back the syntactic layer. + buffer.undo(); + syntax_map.interpolate(&buffer); + syntax_map.reparse(language.clone(), &buffer); + + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(2, 14)..Point::new(2, 16), + &[ + "...(function_item ...", + "...(tuple_expression (call_expression ... arguments: (arguments (macro_invocation...", + "...(array_expression (struct_expression ...", + ], + ); +} + +#[gpui::test] +fn test_dynamic_language_injection() { + let registry = Arc::new(LanguageRegistry::test()); + let markdown = Arc::new(markdown_lang()); + registry.add(markdown.clone()); + registry.add(Arc::new(rust_lang())); + registry.add(Arc::new(ruby_lang())); + + let mut buffer = Buffer::new( + 0, + 0, + r#" + This is a code block: + + ```rs + fn foo() {} + ``` + "# + .unindent(), + ); + + let mut syntax_map = SyntaxMap::new(); + syntax_map.set_language_registry(registry.clone()); + syntax_map.reparse(markdown.clone(), &buffer); + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(3, 0)..Point::new(3, 0), + &[ + "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter...", + "...(function_item name: (identifier) parameters: (parameters) body: (block)...", + ], + ); + + // Replace Rust with Ruby in code block. + let macro_name_range = range_for_text(&buffer, "rs"); + buffer.edit([(macro_name_range, "ruby")]); + syntax_map.interpolate(&buffer); + syntax_map.reparse(markdown.clone(), &buffer); + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(3, 0)..Point::new(3, 0), + &[ + "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter...", + "...(call method: (identifier) arguments: (argument_list (call method: (identifier) arguments: (argument_list) block: (block)...", + ], + ); + + // Replace Ruby with a language that hasn't been loaded yet. + let macro_name_range = range_for_text(&buffer, "ruby"); + buffer.edit([(macro_name_range, "html")]); + syntax_map.interpolate(&buffer); + syntax_map.reparse(markdown.clone(), &buffer); + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(3, 0)..Point::new(3, 0), + &[ + "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter..." + ], + ); + assert!(syntax_map.contains_unknown_injections()); + + registry.add(Arc::new(html_lang())); + syntax_map.reparse(markdown.clone(), &buffer); + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(3, 0)..Point::new(3, 0), + &[ + "...(fenced_code_block (fenced_code_block_delimiter) (info_string (language)) (code_fence_content) (fenced_code_block_delimiter...", + "(fragment (text))", + ], + ); + assert!(!syntax_map.contains_unknown_injections()); +} + +#[gpui::test] +fn test_typing_multiple_new_injections() { + let (buffer, syntax_map) = test_edit_sequence( + "Rust", + &[ + "fn a() { dbg }", + "fn a() { dbg«!» }", + "fn a() { dbg!«()» }", + "fn a() { dbg!(«b») }", + "fn a() { dbg!(b«.») }", + "fn a() { dbg!(b.«c») }", + "fn a() { dbg!(b.c«()») }", + "fn a() { dbg!(b.c(«vec»)) }", + "fn a() { dbg!(b.c(vec«!»)) }", + "fn a() { dbg!(b.c(vec!«[]»)) }", + "fn a() { dbg!(b.c(vec![«d»])) }", + "fn a() { dbg!(b.c(vec![d«.»])) }", + "fn a() { dbg!(b.c(vec![d.«e»])) }", + ], + ); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["field"], + "fn a() { dbg!(b.«c»(vec![d.«e»])) }", + ); +} + +#[gpui::test] +fn test_pasting_new_injection_line_between_others() { + let (buffer, syntax_map) = test_edit_sequence( + "Rust", + &[ + " + fn a() { + b!(B {}); + c!(C {}); + d!(D {}); + e!(E {}); + f!(F {}); + g!(G {}); + } + ", + " + fn a() { + b!(B {}); + c!(C {}); + d!(D {}); + « h!(H {}); + » e!(E {}); + f!(F {}); + g!(G {}); + } + ", + ], + ); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["struct"], + " + fn a() { + b!(«B {}»); + c!(«C {}»); + d!(«D {}»); + h!(«H {}»); + e!(«E {}»); + f!(«F {}»); + g!(«G {}»); + } + ", + ); +} + +#[gpui::test] +fn test_joining_injections_with_child_injections() { + let (buffer, syntax_map) = test_edit_sequence( + "Rust", + &[ + " + fn a() { + b!( + c![one.two.three], + d![four.five.six], + ); + e!( + f![seven.eight], + ); + } + ", + " + fn a() { + b!( + c![one.two.three], + d![four.five.six], + ˇ f![seven.eight], + ); + } + ", + ], + ); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["field"], + " + fn a() { + b!( + c![one.«two».«three»], + d![four.«five».«six»], + f![seven.«eight»], + ); + } + ", + ); +} + +#[gpui::test] +fn test_editing_edges_of_injection() { + test_edit_sequence( + "Rust", + &[ + " + fn a() { + b!(c!()) + } + ", + " + fn a() { + «d»!(c!()) + } + ", + " + fn a() { + «e»d!(c!()) + } + ", + " + fn a() { + ed!«[»c!()«]» + } + ", + ], + ); +} + +#[gpui::test] +fn test_edits_preceding_and_intersecting_injection() { + test_edit_sequence( + "Rust", + &[ + // + "const aaaaaaaaaaaa: B = c!(d(e.f));", + "const aˇa: B = c!(d(eˇ));", + ], + ); +} + +#[gpui::test] +fn test_non_local_changes_create_injections() { + test_edit_sequence( + "Rust", + &[ + " + // a! { + static B: C = d; + // } + ", + " + ˇa! { + static B: C = d; + ˇ} + ", + ], + ); +} + +#[gpui::test] +fn test_creating_many_injections_in_one_edit() { + test_edit_sequence( + "Rust", + &[ + " + fn a() { + one(Two::three(3)); + four(Five::six(6)); + seven(Eight::nine(9)); + } + ", + " + fn a() { + one«!»(Two::three(3)); + four«!»(Five::six(6)); + seven«!»(Eight::nine(9)); + } + ", + " + fn a() { + one!(Two::three«!»(3)); + four!(Five::six«!»(6)); + seven!(Eight::nine«!»(9)); + } + ", + ], + ); +} + +#[gpui::test] +fn test_editing_across_injection_boundary() { + test_edit_sequence( + "Rust", + &[ + " + fn one() { + two(); + three!( + three.four, + five.six, + ); + } + ", + " + fn one() { + two(); + th«irty_five![» + three.four, + five.six, + « seven.eight, + ];» + } + ", + ], + ); +} + +#[gpui::test] +fn test_removing_injection_by_replacing_across_boundary() { + test_edit_sequence( + "Rust", + &[ + " + fn one() { + two!( + three.four, + ); + } + ", + " + fn one() { + t«en + .eleven( + twelve, + » + three.four, + ); + } + ", + ], + ); +} + +#[gpui::test] +fn test_combined_injections() { + let (buffer, syntax_map) = test_edit_sequence( + "ERB", + &[ + " + + <% if @one %> +
+ <% else %> +
+ <% end %> +
+ + ", + " + + <% if @one %> +
+ ˇ else ˇ +
+ <% end %> +
+ + ", + " + + <% if @one «;» end %> +
+ + ", + ], + ); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["tag", "ivar"], + " + <«body»> + <% if «@one» ; end %> + + + ", + ); +} + +#[gpui::test] +fn test_combined_injections_empty_ranges() { + test_edit_sequence( + "ERB", + &[ + " + <% if @one %> + <% else %> + <% end %> + ", + " + <% if @one %> + ˇ<% end %> + ", + ], + ); +} + +#[gpui::test] +fn test_combined_injections_edit_edges_of_ranges() { + let (buffer, syntax_map) = test_edit_sequence( + "ERB", + &[ + " + <%= one @two %> + <%= three @four %> + ", + " + <%= one @two %ˇ + <%= three @four %> + ", + " + <%= one @two %«>» + <%= three @four %> + ", + ], + ); + + assert_capture_ranges( + &syntax_map, + &buffer, + &["tag", "ivar"], + " + <%= one «@two» %> + <%= three «@four» %> + ", + ); +} + +#[gpui::test] +fn test_combined_injections_splitting_some_injections() { + let (_buffer, _syntax_map) = test_edit_sequence( + "ERB", + &[ + r#" + <%A if b(:c) %> + d + <% end %> + eee + <% f %> + "#, + r#" + <%« AAAAAAA %> + hhhhhhh + <%=» if b(:c) %> + d + <% end %> + eee + <% f %> + "#, + ], + ); +} + +#[gpui::test] +fn test_combined_injections_inside_injections() { + let (_buffer, _syntax_map) = test_edit_sequence( + "Markdown", + &[ + r#" + here is some ERB code: + + ```erb +
    + <% people.each do |person| %> +
  • <%= person.name %>
  • + <% end %> +
+ ``` + "#, + r#" + here is some ERB code: + + ```erb +
    + <% people«2».each do |person| %> +
  • <%= person.name %>
  • + <% end %> +
+ ``` + "#, + ], + ); +} + +#[gpui::test] +fn test_empty_combined_injections_inside_injections() { + let (buffer, syntax_map) = test_edit_sequence( + "Markdown", + &[r#" + ```erb + hello + ``` + + goodbye + "#], + ); + + assert_layers_for_range( + &syntax_map, + &buffer, + Point::new(0, 0)..Point::new(5, 0), + &[ + "...(paragraph)...", + "(template...", + "(fragment...", + // The ruby syntax tree should be empty, since there are + // no interpolations in the ERB template. + "(program)", + ], + ); +} + +#[gpui::test(iterations = 50)] +fn test_random_syntax_map_edits(mut rng: StdRng) { + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let text = r#" + fn test_something() { + let vec = vec![5, 1, 3, 8]; + assert_eq!( + vec + .into_iter() + .map(|i| i * 2) + .collect::>(), + vec![ + 5 * 2, 1 * 2, 3 * 2, 8 * 2 + ], + ); + } + "# + .unindent() + .repeat(2); + + let registry = Arc::new(LanguageRegistry::test()); + let language = Arc::new(rust_lang()); + registry.add(language.clone()); + let mut buffer = Buffer::new(0, 0, text); + + let mut syntax_map = SyntaxMap::new(); + syntax_map.set_language_registry(registry.clone()); + syntax_map.reparse(language.clone(), &buffer); + + let mut reference_syntax_map = SyntaxMap::new(); + reference_syntax_map.set_language_registry(registry.clone()); + + log::info!("initial text:\n{}", buffer.text()); + + for _ in 0..operations { + let prev_buffer = buffer.snapshot(); + let prev_syntax_map = syntax_map.snapshot(); + + buffer.randomly_edit(&mut rng, 3); + log::info!("text:\n{}", buffer.text()); + + syntax_map.interpolate(&buffer); + check_interpolation(&prev_syntax_map, &syntax_map, &prev_buffer, &buffer); + + syntax_map.reparse(language.clone(), &buffer); + + reference_syntax_map.clear(); + reference_syntax_map.reparse(language.clone(), &buffer); + } + + for i in 0..operations { + let i = operations - i - 1; + buffer.undo(); + log::info!("undoing operation {}", i); + log::info!("text:\n{}", buffer.text()); + + syntax_map.interpolate(&buffer); + syntax_map.reparse(language.clone(), &buffer); + + reference_syntax_map.clear(); + reference_syntax_map.reparse(language.clone(), &buffer); + assert_eq!( + syntax_map.layers(&buffer).len(), + reference_syntax_map.layers(&buffer).len(), + "wrong number of layers after undoing edit {i}" + ); + } + + let layers = syntax_map.layers(&buffer); + let reference_layers = reference_syntax_map.layers(&buffer); + for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter()) { + assert_eq!( + edited_layer.node().to_sexp(), + reference_layer.node().to_sexp() + ); + assert_eq!(edited_layer.node().range(), reference_layer.node().range()); + } +} + +#[gpui::test(iterations = 50)] +fn test_random_syntax_map_edits_with_combined_injections(mut rng: StdRng) { + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let text = r#" +
+ <% if one?(:two) %> +

+ <%= yield :five %> +

+ <% elsif Six.seven(8) %> +

+ <%= yield :five %> +

+ <% else %> + Ok + <% end %> +
+ "# + .unindent() + .repeat(8); + + let registry = Arc::new(LanguageRegistry::test()); + let language = Arc::new(erb_lang()); + registry.add(language.clone()); + registry.add(Arc::new(ruby_lang())); + registry.add(Arc::new(html_lang())); + let mut buffer = Buffer::new(0, 0, text); + + let mut syntax_map = SyntaxMap::new(); + syntax_map.set_language_registry(registry.clone()); + syntax_map.reparse(language.clone(), &buffer); + + let mut reference_syntax_map = SyntaxMap::new(); + reference_syntax_map.set_language_registry(registry.clone()); + + log::info!("initial text:\n{}", buffer.text()); + + for _ in 0..operations { + let prev_buffer = buffer.snapshot(); + let prev_syntax_map = syntax_map.snapshot(); + + buffer.randomly_edit(&mut rng, 3); + log::info!("text:\n{}", buffer.text()); + + syntax_map.interpolate(&buffer); + check_interpolation(&prev_syntax_map, &syntax_map, &prev_buffer, &buffer); + + syntax_map.reparse(language.clone(), &buffer); + + reference_syntax_map.clear(); + reference_syntax_map.reparse(language.clone(), &buffer); + } + + for i in 0..operations { + let i = operations - i - 1; + buffer.undo(); + log::info!("undoing operation {}", i); + log::info!("text:\n{}", buffer.text()); + + syntax_map.interpolate(&buffer); + syntax_map.reparse(language.clone(), &buffer); + + reference_syntax_map.clear(); + reference_syntax_map.reparse(language.clone(), &buffer); + assert_eq!( + syntax_map.layers(&buffer).len(), + reference_syntax_map.layers(&buffer).len(), + "wrong number of layers after undoing edit {i}" + ); + } + + let layers = syntax_map.layers(&buffer); + let reference_layers = reference_syntax_map.layers(&buffer); + for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter()) { + assert_eq!( + edited_layer.node().to_sexp(), + reference_layer.node().to_sexp() + ); + assert_eq!(edited_layer.node().range(), reference_layer.node().range()); + } +} + +fn check_interpolation( + old_syntax_map: &SyntaxSnapshot, + new_syntax_map: &SyntaxSnapshot, + old_buffer: &BufferSnapshot, + new_buffer: &BufferSnapshot, +) { + let edits = new_buffer + .edits_since::(&old_buffer.version()) + .collect::>(); + + for (old_layer, new_layer) in old_syntax_map + .layers + .iter() + .zip(new_syntax_map.layers.iter()) + { + assert_eq!(old_layer.range, new_layer.range); + let Some(old_tree) = old_layer.content.tree() else { continue }; + let Some(new_tree) = new_layer.content.tree() else { continue }; + let old_start_byte = old_layer.range.start.to_offset(old_buffer); + let new_start_byte = new_layer.range.start.to_offset(new_buffer); + let old_start_point = old_layer.range.start.to_point(old_buffer).to_ts_point(); + let new_start_point = new_layer.range.start.to_point(new_buffer).to_ts_point(); + let old_node = old_tree.root_node_with_offset(old_start_byte, old_start_point); + let new_node = new_tree.root_node_with_offset(new_start_byte, new_start_point); + check_node_edits( + old_layer.depth, + &old_layer.range, + old_node, + new_node, + old_buffer, + new_buffer, + &edits, + ); + } + + fn check_node_edits( + depth: usize, + range: &Range, + old_node: Node, + new_node: Node, + old_buffer: &BufferSnapshot, + new_buffer: &BufferSnapshot, + edits: &[text::Edit], + ) { + assert_eq!(old_node.kind(), new_node.kind()); + + let old_range = old_node.byte_range(); + let new_range = new_node.byte_range(); + + let is_edited = edits + .iter() + .any(|edit| edit.new.start < new_range.end && edit.new.end > new_range.start); + if is_edited { + assert!( + new_node.has_changes(), + concat!( + "failed to mark node as edited.\n", + "layer depth: {}, old layer range: {:?}, new layer range: {:?},\n", + "node kind: {}, old node range: {:?}, new node range: {:?}", + ), + depth, + range.to_offset(old_buffer), + range.to_offset(new_buffer), + new_node.kind(), + old_range, + new_range, + ); + } + + if !new_node.has_changes() { + assert_eq!( + old_buffer + .text_for_range(old_range.clone()) + .collect::(), + new_buffer + .text_for_range(new_range.clone()) + .collect::(), + concat!( + "mismatched text for node\n", + "layer depth: {}, old layer range: {:?}, new layer range: {:?},\n", + "node kind: {}, old node range:{:?}, new node range:{:?}", + ), + depth, + range.to_offset(old_buffer), + range.to_offset(new_buffer), + new_node.kind(), + old_range, + new_range, + ); + } + + for i in 0..new_node.child_count() { + check_node_edits( + depth, + range, + old_node.child(i).unwrap(), + new_node.child(i).unwrap(), + old_buffer, + new_buffer, + edits, + ) + } + } +} + +fn test_edit_sequence(language_name: &str, steps: &[&str]) -> (Buffer, SyntaxMap) { + let registry = Arc::new(LanguageRegistry::test()); + registry.add(Arc::new(rust_lang())); + registry.add(Arc::new(ruby_lang())); + registry.add(Arc::new(html_lang())); + registry.add(Arc::new(erb_lang())); + registry.add(Arc::new(markdown_lang())); + let language = registry + .language_for_name(language_name) + .now_or_never() + .unwrap() + .unwrap(); + let mut buffer = Buffer::new(0, 0, Default::default()); + + let mut mutated_syntax_map = SyntaxMap::new(); + mutated_syntax_map.set_language_registry(registry.clone()); + mutated_syntax_map.reparse(language.clone(), &buffer); + + for (i, marked_string) in steps.into_iter().enumerate() { + buffer.edit_via_marked_text(&marked_string.unindent()); + + // Reparse the syntax map + mutated_syntax_map.interpolate(&buffer); + mutated_syntax_map.reparse(language.clone(), &buffer); + + // Create a second syntax map from scratch + let mut reference_syntax_map = SyntaxMap::new(); + reference_syntax_map.set_language_registry(registry.clone()); + reference_syntax_map.reparse(language.clone(), &buffer); + + // Compare the mutated syntax map to the new syntax map + let mutated_layers = mutated_syntax_map.layers(&buffer); + let reference_layers = reference_syntax_map.layers(&buffer); + assert_eq!( + mutated_layers.len(), + reference_layers.len(), + "wrong number of layers at step {i}" + ); + for (edited_layer, reference_layer) in + mutated_layers.into_iter().zip(reference_layers.into_iter()) + { + assert_eq!( + edited_layer.node().to_sexp(), + reference_layer.node().to_sexp(), + "different layer at step {i}" + ); + assert_eq!( + edited_layer.node().range(), + reference_layer.node().range(), + "different layer at step {i}" + ); + } + } + + (buffer, mutated_syntax_map) +} + +fn html_lang() -> Language { + Language::new( + LanguageConfig { + name: "HTML".into(), + path_suffixes: vec!["html".to_string()], + ..Default::default() + }, + Some(tree_sitter_html::language()), + ) + .with_highlights_query( + r#" + (tag_name) @tag + (erroneous_end_tag_name) @tag + (attribute_name) @property + "#, + ) + .unwrap() +} + +fn ruby_lang() -> Language { + Language::new( + LanguageConfig { + name: "Ruby".into(), + path_suffixes: vec!["rb".to_string()], + ..Default::default() + }, + Some(tree_sitter_ruby::language()), + ) + .with_highlights_query( + r#" + ["if" "do" "else" "end"] @keyword + (instance_variable) @ivar + "#, + ) + .unwrap() +} + +fn erb_lang() -> Language { + Language::new( + LanguageConfig { + name: "ERB".into(), + path_suffixes: vec!["erb".to_string()], + ..Default::default() + }, + Some(tree_sitter_embedded_template::language()), + ) + .with_highlights_query( + r#" + ["<%" "%>"] @keyword + "#, + ) + .unwrap() + .with_injection_query( + r#" + ( + (code) @content + (#set! "language" "ruby") + (#set! "combined") + ) + + ( + (content) @content + (#set! "language" "html") + (#set! "combined") + ) + "#, + ) + .unwrap() +} + +fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_highlights_query( + r#" + (field_identifier) @field + (struct_expression) @struct + "#, + ) + .unwrap() + .with_injection_query( + r#" + (macro_invocation + (token_tree) @content + (#set! "language" "rust")) + "#, + ) + .unwrap() +} + +fn markdown_lang() -> Language { + Language::new( + LanguageConfig { + name: "Markdown".into(), + path_suffixes: vec!["md".into()], + ..Default::default() + }, + Some(tree_sitter_markdown::language()), + ) + .with_injection_query( + r#" + (fenced_code_block + (info_string + (language) @language) + (code_fence_content) @content) + "#, + ) + .unwrap() +} + +fn range_for_text(buffer: &Buffer, text: &str) -> Range { + let start = buffer.as_rope().to_string().find(text).unwrap(); + start..start + text.len() +} + +fn assert_layers_for_range( + syntax_map: &SyntaxMap, + buffer: &BufferSnapshot, + range: Range, + expected_layers: &[&str], +) { + let layers = syntax_map + .layers_for_range(range, &buffer) + .collect::>(); + assert_eq!( + layers.len(), + expected_layers.len(), + "wrong number of layers" + ); + for (i, (layer, expected_s_exp)) in layers.iter().zip(expected_layers.iter()).enumerate() { + let actual_s_exp = layer.node().to_sexp(); + assert!( + string_contains_sequence( + &actual_s_exp, + &expected_s_exp.split("...").collect::>() + ), + "layer {i}:\n\nexpected: {expected_s_exp}\nactual: {actual_s_exp}", + ); + } +} + +fn assert_capture_ranges( + syntax_map: &SyntaxMap, + buffer: &BufferSnapshot, + highlight_query_capture_names: &[&str], + marked_string: &str, +) { + let mut actual_ranges = Vec::>::new(); + let captures = syntax_map.captures(0..buffer.len(), buffer, |grammar| { + grammar.highlights_query.as_ref() + }); + let queries = captures + .grammars() + .iter() + .map(|grammar| grammar.highlights_query.as_ref().unwrap()) + .collect::>(); + for capture in captures { + let name = &queries[capture.grammar_index].capture_names()[capture.index as usize]; + if highlight_query_capture_names.contains(&name.as_str()) { + actual_ranges.push(capture.node.byte_range()); + } + } + + let (text, expected_ranges) = marked_text_ranges(&marked_string.unindent(), false); + assert_eq!(text, buffer.text()); + assert_eq!(actual_ranges, expected_ranges); +} + +pub fn string_contains_sequence(text: &str, parts: &[&str]) -> bool { + let mut last_part_end = 0; + for part in parts { + if let Some(start_ix) = text[last_part_end..].find(part) { + last_part_end = start_ix + part.len(); + } else { + return false; + } + } + true +} diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index 2c78b89f31..b97417580f 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -55,7 +55,7 @@ impl View for ActiveBufferLanguage { MouseEventHandler::::new(0, cx, |state, cx| { let theme = &theme::current(cx).workspace.status_bar; - let style = theme.active_language.style_for(state, false); + let style = theme.active_language.style_for(state); Label::new(active_language_text, style.text.clone()) .contained() .with_style(style.container) diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 817901cd3a..6362b8247d 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -180,7 +180,7 @@ impl PickerDelegate for LanguageSelectorDelegate { ) -> AnyElement> { let theme = theme::current(cx); let mat = &self.matches[ix]; - let style = theme.picker.item.style_for(mouse_state, selected); + let style = theme.picker.item.in_state(selected).style_for(mouse_state); let buffer_language_name = self.buffer.read(cx).language().map(|l| l.name()); let mut label = mat.string.clone(); if buffer_language_name.as_deref() == Some(mat.string.as_str()) { diff --git a/crates/lsp_log/Cargo.toml b/crates/language_tools/Cargo.toml similarity index 90% rename from crates/lsp_log/Cargo.toml rename to crates/language_tools/Cargo.toml index 46f6006a23..e67a4b36df 100644 --- a/crates/lsp_log/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "lsp_log" +name = "language_tools" version = "0.1.0" edition = "2021" publish = false [lib] -path = "src/lsp_log.rs" +path = "src/language_tools.rs" doctest = false [dependencies] @@ -22,6 +22,7 @@ lsp = { path = "../lsp" } futures.workspace = true serde.workspace = true anyhow.workspace = true +tree-sitter.workspace = true [dev-dependencies] client = { path = "../client", features = ["test-support"] } diff --git a/crates/language_tools/src/language_tools.rs b/crates/language_tools/src/language_tools.rs new file mode 100644 index 0000000000..0a1f31f03f --- /dev/null +++ b/crates/language_tools/src/language_tools.rs @@ -0,0 +1,15 @@ +mod lsp_log; +mod syntax_tree_view; + +#[cfg(test)] +mod lsp_log_tests; + +use gpui::AppContext; + +pub use lsp_log::{LogStore, LspLogToolbarItemView, LspLogView}; +pub use syntax_tree_view::{SyntaxTreeToolbarItemView, SyntaxTreeView}; + +pub fn init(cx: &mut AppContext) { + lsp_log::init(cx); + syntax_tree_view::init(cx); +} diff --git a/crates/lsp_log/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs similarity index 94% rename from crates/lsp_log/src/lsp_log.rs rename to crates/language_tools/src/lsp_log.rs index 5808b4da2e..12d8c6b34d 100644 --- a/crates/lsp_log/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -1,6 +1,3 @@ -#[cfg(test)] -mod lsp_log_tests; - use collections::HashMap; use editor::Editor; use futures::{channel::mpsc, StreamExt}; @@ -27,7 +24,7 @@ use workspace::{ const SEND_LINE: &str = "// Send:\n"; const RECEIVE_LINE: &str = "// Receive:\n"; -struct LogStore { +pub struct LogStore { projects: HashMap, ProjectState>, io_tx: mpsc::UnboundedSender<(WeakModelHandle, LanguageServerId, bool, String)>, } @@ -49,10 +46,10 @@ struct LanguageServerRpcState { } pub struct LspLogView { + pub(crate) editor: ViewHandle, log_store: ModelHandle, current_server_id: Option, is_showing_rpc_trace: bool, - editor: ViewHandle, project: ModelHandle, } @@ -68,16 +65,16 @@ enum MessageKind { } #[derive(Clone, Debug, PartialEq)] -struct LogMenuItem { - server_id: LanguageServerId, - server_name: LanguageServerName, - worktree: ModelHandle, - rpc_trace_enabled: bool, - rpc_trace_selected: bool, - logs_selected: bool, +pub(crate) struct LogMenuItem { + pub server_id: LanguageServerId, + pub server_name: LanguageServerName, + pub worktree: ModelHandle, + pub rpc_trace_enabled: bool, + pub rpc_trace_selected: bool, + pub logs_selected: bool, } -actions!(log, [OpenLanguageServerLogs]); +actions!(debug, [OpenLanguageServerLogs]); pub fn init(cx: &mut AppContext) { let log_store = cx.add_model(|cx| LogStore::new(cx)); @@ -114,7 +111,7 @@ pub fn init(cx: &mut AppContext) { } impl LogStore { - fn new(cx: &mut ModelContext) -> Self { + pub fn new(cx: &mut ModelContext) -> Self { let (io_tx, mut io_rx) = mpsc::unbounded(); let this = Self { projects: HashMap::default(), @@ -320,7 +317,7 @@ impl LogStore { } impl LspLogView { - fn new( + pub fn new( project: ModelHandle, log_store: ModelHandle, cx: &mut ViewContext, @@ -360,7 +357,7 @@ impl LspLogView { editor } - fn menu_items<'a>(&'a self, cx: &'a AppContext) -> Option> { + pub(crate) fn menu_items<'a>(&'a self, cx: &'a AppContext) -> Option> { let log_store = self.log_store.read(cx); let state = log_store.projects.get(&self.project.downgrade())?; let mut rows = self @@ -544,12 +541,7 @@ impl View for LspLogToolbarItemView { let theme = theme::current(cx).clone(); let Some(log_view) = self.log_view.as_ref() else { return Empty::new().into_any() }; let log_view = log_view.read(cx); - - let menu_rows = self - .log_view - .as_ref() - .and_then(|view| view.read(cx).menu_items(cx)) - .unwrap_or_default(); + let menu_rows = log_view.menu_items(cx).unwrap_or_default(); let current_server_id = log_view.current_server_id; let current_server = current_server_id.and_then(|current_server_id| { @@ -586,7 +578,7 @@ impl View for LspLogToolbarItemView { ) })) .contained() - .with_style(theme.lsp_log_menu.container) + .with_style(theme.toolbar_dropdown_menu.container) .constrained() .with_width(400.) .with_height(400.) @@ -596,6 +588,7 @@ impl View for LspLogToolbarItemView { cx.notify() }), ) + .with_hoverable(true) .with_fit_mode(OverlayFitMode::SwitchAnchor) .with_anchor_corner(AnchorCorner::TopLeft) .with_z_index(999) @@ -688,7 +681,7 @@ impl LspLogToolbarItemView { ) }) .unwrap_or_else(|| "No server selected".into()); - let style = theme.lsp_log_menu.header.style_for(state, false); + let style = theme.toolbar_dropdown_menu.header.style_for(state); Label::new(label, style.text.clone()) .contained() .with_style(style.container) @@ -714,7 +707,7 @@ impl LspLogToolbarItemView { Flex::column() .with_child({ - let style = &theme.lsp_log_menu.server; + let style = &theme.toolbar_dropdown_menu.section_header; Label::new( format!("{} ({})", name.0, worktree.read(cx).root_name()), style.text.clone(), @@ -722,16 +715,20 @@ impl LspLogToolbarItemView { .contained() .with_style(style.container) .constrained() - .with_height(theme.lsp_log_menu.row_height) + .with_height(theme.toolbar_dropdown_menu.row_height) }) .with_child( MouseEventHandler::::new(id.0, cx, move |state, _| { - let style = theme.lsp_log_menu.item.style_for(state, logs_selected); + let style = theme + .toolbar_dropdown_menu + .item + .in_state(logs_selected) + .style_for(state); Label::new(SERVER_LOGS, style.text.clone()) .contained() .with_style(style.container) .constrained() - .with_height(theme.lsp_log_menu.row_height) + .with_height(theme.toolbar_dropdown_menu.row_height) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, view, cx| { @@ -740,12 +737,16 @@ impl LspLogToolbarItemView { ) .with_child( MouseEventHandler::::new(id.0, cx, move |state, cx| { - let style = theme.lsp_log_menu.item.style_for(state, rpc_trace_selected); + let style = theme + .toolbar_dropdown_menu + .item + .in_state(rpc_trace_selected) + .style_for(state); Flex::row() .with_child( Label::new(RPC_MESSAGES, style.text.clone()) .constrained() - .with_height(theme.lsp_log_menu.row_height), + .with_height(theme.toolbar_dropdown_menu.row_height), ) .with_child( ui::checkbox_with_label::( @@ -764,7 +765,7 @@ impl LspLogToolbarItemView { .contained() .with_style(style.container) .constrained() - .with_height(theme.lsp_log_menu.row_height) + .with_height(theme.toolbar_dropdown_menu.row_height) }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, view, cx| { diff --git a/crates/lsp_log/src/lsp_log_tests.rs b/crates/language_tools/src/lsp_log_tests.rs similarity index 95% rename from crates/lsp_log/src/lsp_log_tests.rs rename to crates/language_tools/src/lsp_log_tests.rs index 572758ad63..d4a16b5758 100644 --- a/crates/lsp_log/src/lsp_log_tests.rs +++ b/crates/language_tools/src/lsp_log_tests.rs @@ -1,7 +1,12 @@ +use std::sync::Arc; + +use crate::lsp_log::LogMenuItem; + use super::*; +use futures::StreamExt; use gpui::{serde_json::json, TestAppContext}; -use language::{tree_sitter_rust, FakeLspAdapter, Language, LanguageConfig}; -use project::FakeFs; +use language::{tree_sitter_rust, FakeLspAdapter, Language, LanguageConfig, LanguageServerName}; +use project::{FakeFs, Project}; use settings::SettingsStore; #[gpui::test] diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs new file mode 100644 index 0000000000..3e6727bbf4 --- /dev/null +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -0,0 +1,676 @@ +use editor::{scroll::autoscroll::Autoscroll, Anchor, Editor, ExcerptId}; +use gpui::{ + actions, + elements::{ + AnchorCorner, Empty, Flex, Label, MouseEventHandler, Overlay, OverlayFitMode, + ParentElement, ScrollTarget, Stack, UniformList, UniformListState, + }, + fonts::TextStyle, + platform::{CursorStyle, MouseButton}, + AppContext, Element, Entity, ModelHandle, View, ViewContext, ViewHandle, WeakViewHandle, +}; +use language::{Buffer, OwnedSyntaxLayerInfo, SyntaxLayerInfo}; +use std::{mem, ops::Range, sync::Arc}; +use theme::{Theme, ThemeSettings}; +use tree_sitter::{Node, TreeCursor}; +use workspace::{ + item::{Item, ItemHandle}, + ToolbarItemLocation, ToolbarItemView, Workspace, +}; + +actions!(debug, [OpenSyntaxTreeView]); + +pub fn init(cx: &mut AppContext) { + cx.add_action( + move |workspace: &mut Workspace, _: &OpenSyntaxTreeView, cx: _| { + let active_item = workspace.active_item(cx); + let workspace_handle = workspace.weak_handle(); + let syntax_tree_view = + cx.add_view(|cx| SyntaxTreeView::new(workspace_handle, active_item, cx)); + workspace.add_item(Box::new(syntax_tree_view), cx); + }, + ); +} + +pub struct SyntaxTreeView { + workspace_handle: WeakViewHandle, + editor: Option, + mouse_y: Option, + line_height: Option, + list_state: UniformListState, + selected_descendant_ix: Option, + hovered_descendant_ix: Option, +} + +pub struct SyntaxTreeToolbarItemView { + tree_view: Option>, + subscription: Option, + menu_open: bool, +} + +struct EditorState { + editor: ViewHandle, + active_buffer: Option, + _subscription: gpui::Subscription, +} + +#[derive(Clone)] +struct BufferState { + buffer: ModelHandle, + excerpt_id: ExcerptId, + active_layer: Option, +} + +impl SyntaxTreeView { + pub fn new( + workspace_handle: WeakViewHandle, + active_item: Option>, + cx: &mut ViewContext, + ) -> Self { + let mut this = Self { + workspace_handle: workspace_handle.clone(), + list_state: UniformListState::default(), + editor: None, + mouse_y: None, + line_height: None, + hovered_descendant_ix: None, + selected_descendant_ix: None, + }; + + this.workspace_updated(active_item, cx); + cx.observe( + &workspace_handle.upgrade(cx).unwrap(), + |this, workspace, cx| { + this.workspace_updated(workspace.read(cx).active_item(cx), cx); + }, + ) + .detach(); + + this + } + + fn workspace_updated( + &mut self, + active_item: Option>, + cx: &mut ViewContext, + ) { + if let Some(item) = active_item { + if item.id() != cx.view_id() { + if let Some(editor) = item.act_as::(cx) { + self.set_editor(editor, cx); + } + } + } + } + + fn set_editor(&mut self, editor: ViewHandle, cx: &mut ViewContext) { + if let Some(state) = &self.editor { + if state.editor == editor { + return; + } + editor.update(cx, |editor, cx| { + editor.clear_background_highlights::(cx) + }); + } + + let subscription = cx.subscribe(&editor, |this, _, event, cx| { + let did_reparse = match event { + editor::Event::Reparsed => true, + editor::Event::SelectionsChanged { .. } => false, + _ => return, + }; + this.editor_updated(did_reparse, cx); + }); + + self.editor = Some(EditorState { + editor, + _subscription: subscription, + active_buffer: None, + }); + self.editor_updated(true, cx); + } + + fn editor_updated(&mut self, did_reparse: bool, cx: &mut ViewContext) -> Option<()> { + // Find which excerpt the cursor is in, and the position within that excerpted buffer. + let editor_state = self.editor.as_mut()?; + let editor = &editor_state.editor.read(cx); + let selection_range = editor.selections.last::(cx).range(); + let multibuffer = editor.buffer().read(cx); + let (buffer, range, excerpt_id) = multibuffer + .range_to_buffer_ranges(selection_range, cx) + .pop()?; + + // If the cursor has moved into a different excerpt, retrieve a new syntax layer + // from that buffer. + let buffer_state = editor_state + .active_buffer + .get_or_insert_with(|| BufferState { + buffer: buffer.clone(), + excerpt_id, + active_layer: None, + }); + let mut prev_layer = None; + if did_reparse { + prev_layer = buffer_state.active_layer.take(); + } + if buffer_state.buffer != buffer || buffer_state.excerpt_id != buffer_state.excerpt_id { + buffer_state.buffer = buffer.clone(); + buffer_state.excerpt_id = excerpt_id; + buffer_state.active_layer = None; + } + + let layer = match &mut buffer_state.active_layer { + Some(layer) => layer, + None => { + let snapshot = buffer.read(cx).snapshot(); + let layer = if let Some(prev_layer) = prev_layer { + let prev_range = prev_layer.node().byte_range(); + snapshot + .syntax_layers() + .filter(|layer| layer.language == &prev_layer.language) + .min_by_key(|layer| { + let range = layer.node().byte_range(); + ((range.start as i64) - (prev_range.start as i64)).abs() + + ((range.end as i64) - (prev_range.end as i64)).abs() + })? + } else { + snapshot.syntax_layers().next()? + }; + buffer_state.active_layer.insert(layer.to_owned()) + } + }; + + // Within the active layer, find the syntax node under the cursor, + // and scroll to it. + let mut cursor = layer.node().walk(); + while cursor.goto_first_child_for_byte(range.start).is_some() { + if !range.is_empty() && cursor.node().end_byte() == range.start { + cursor.goto_next_sibling(); + } + } + + // Ascend to the smallest ancestor that contains the range. + loop { + let node_range = cursor.node().byte_range(); + if node_range.start <= range.start && node_range.end >= range.end { + break; + } + if !cursor.goto_parent() { + break; + } + } + + let descendant_ix = cursor.descendant_index(); + self.selected_descendant_ix = Some(descendant_ix); + self.list_state.scroll_to(ScrollTarget::Show(descendant_ix)); + + cx.notify(); + Some(()) + } + + fn handle_click(&mut self, y: f32, cx: &mut ViewContext) -> Option<()> { + let line_height = self.line_height?; + let ix = ((self.list_state.scroll_top() + y) / line_height) as usize; + + self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, mut range, cx| { + // Put the cursor at the beginning of the node. + mem::swap(&mut range.start, &mut range.end); + + editor.change_selections(Some(Autoscroll::newest()), cx, |selections| { + selections.select_ranges(vec![range]); + }); + }); + Some(()) + } + + fn hover_state_changed(&mut self, cx: &mut ViewContext) { + if let Some((y, line_height)) = self.mouse_y.zip(self.line_height) { + let ix = ((self.list_state.scroll_top() + y) / line_height) as usize; + if self.hovered_descendant_ix != Some(ix) { + self.hovered_descendant_ix = Some(ix); + self.update_editor_with_range_for_descendant_ix(ix, cx, |editor, range, cx| { + editor.clear_background_highlights::(cx); + editor.highlight_background::( + vec![range], + |theme| theme.editor.document_highlight_write_background, + cx, + ); + }); + cx.notify(); + } + } + } + + fn update_editor_with_range_for_descendant_ix( + &self, + descendant_ix: usize, + cx: &mut ViewContext, + mut f: impl FnMut(&mut Editor, Range, &mut ViewContext), + ) -> Option<()> { + let editor_state = self.editor.as_ref()?; + let buffer_state = editor_state.active_buffer.as_ref()?; + let layer = buffer_state.active_layer.as_ref()?; + + // Find the node. + let mut cursor = layer.node().walk(); + cursor.goto_descendant(descendant_ix); + let node = cursor.node(); + let range = node.byte_range(); + + // Build a text anchor range. + let buffer = buffer_state.buffer.read(cx); + let range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); + + // Build a multibuffer anchor range. + let multibuffer = editor_state.editor.read(cx).buffer(); + let multibuffer = multibuffer.read(cx).snapshot(cx); + let excerpt_id = buffer_state.excerpt_id; + let range = multibuffer.anchor_in_excerpt(excerpt_id, range.start) + ..multibuffer.anchor_in_excerpt(excerpt_id, range.end); + + // Update the editor with the anchor range. + editor_state.editor.update(cx, |editor, cx| { + f(editor, range, cx); + }); + Some(()) + } + + fn render_node( + cursor: &TreeCursor, + depth: u32, + selected: bool, + hovered: bool, + list_hovered: bool, + style: &TextStyle, + editor_theme: &theme::Editor, + cx: &AppContext, + ) -> gpui::AnyElement { + let node = cursor.node(); + let mut range_style = style.clone(); + let em_width = style.em_width(cx.font_cache()); + let gutter_padding = (em_width * editor_theme.gutter_padding_factor).round(); + + range_style.color = editor_theme.line_number; + + let mut anonymous_node_style = style.clone(); + let string_color = editor_theme + .syntax + .highlights + .iter() + .find_map(|(name, style)| (name == "string").then(|| style.color)?); + let property_color = editor_theme + .syntax + .highlights + .iter() + .find_map(|(name, style)| (name == "property").then(|| style.color)?); + if let Some(color) = string_color { + anonymous_node_style.color = color; + } + + let mut row = Flex::row(); + if let Some(field_name) = cursor.field_name() { + let mut field_style = style.clone(); + if let Some(color) = property_color { + field_style.color = color; + } + + row.add_children([ + Label::new(field_name, field_style), + Label::new(": ", style.clone()), + ]); + } + + return row + .with_child( + if node.is_named() { + Label::new(node.kind(), style.clone()) + } else { + Label::new(format!("\"{}\"", node.kind()), anonymous_node_style) + } + .contained() + .with_margin_right(em_width), + ) + .with_child(Label::new(format_node_range(node), range_style)) + .contained() + .with_background_color(if selected { + editor_theme.selection.selection + } else if hovered && list_hovered { + editor_theme.active_line_background + } else { + Default::default() + }) + .with_padding_left(gutter_padding + depth as f32 * 18.0) + .into_any(); + } +} + +impl Entity for SyntaxTreeView { + type Event = (); +} + +impl View for SyntaxTreeView { + fn ui_name() -> &'static str { + "SyntaxTreeView" + } + + fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { + let settings = settings::get::(cx); + let font_family_id = settings.buffer_font_family; + let font_family_name = cx.font_cache().family_name(font_family_id).unwrap(); + let font_properties = Default::default(); + let font_id = cx + .font_cache() + .select_font(font_family_id, &font_properties) + .unwrap(); + let font_size = settings.buffer_font_size(cx); + + let editor_theme = settings.theme.editor.clone(); + let style = TextStyle { + color: editor_theme.text_color, + font_family_name, + font_family_id, + font_id, + font_size, + font_properties: Default::default(), + underline: Default::default(), + }; + + let line_height = cx.font_cache().line_height(font_size); + if Some(line_height) != self.line_height { + self.line_height = Some(line_height); + self.hover_state_changed(cx); + } + + if let Some(layer) = self + .editor + .as_ref() + .and_then(|editor| editor.active_buffer.as_ref()) + .and_then(|buffer| buffer.active_layer.as_ref()) + { + let layer = layer.clone(); + let theme = editor_theme.clone(); + return MouseEventHandler::::new(0, cx, move |state, cx| { + let list_hovered = state.hovered(); + UniformList::new( + self.list_state.clone(), + layer.node().descendant_count(), + cx, + move |this, range, items, cx| { + let mut cursor = layer.node().walk(); + let mut descendant_ix = range.start as usize; + cursor.goto_descendant(descendant_ix); + let mut depth = cursor.depth(); + let mut visited_children = false; + while descendant_ix < range.end { + if visited_children { + if cursor.goto_next_sibling() { + visited_children = false; + } else if cursor.goto_parent() { + depth -= 1; + } else { + break; + } + } else { + items.push(Self::render_node( + &cursor, + depth, + Some(descendant_ix) == this.selected_descendant_ix, + Some(descendant_ix) == this.hovered_descendant_ix, + list_hovered, + &style, + &theme, + cx, + )); + descendant_ix += 1; + if cursor.goto_first_child() { + depth += 1; + } else { + visited_children = true; + } + } + } + }, + ) + }) + .on_move(move |event, this, cx| { + let y = event.position.y() - event.region.origin_y(); + this.mouse_y = Some(y); + this.hover_state_changed(cx); + }) + .on_click(MouseButton::Left, move |event, this, cx| { + let y = event.position.y() - event.region.origin_y(); + this.handle_click(y, cx); + }) + .contained() + .with_background_color(editor_theme.background) + .into_any(); + } + + Empty::new().into_any() + } +} + +impl Item for SyntaxTreeView { + fn tab_content( + &self, + _: Option, + style: &theme::Tab, + _: &AppContext, + ) -> gpui::AnyElement { + Label::new("Syntax Tree", style.label.clone()).into_any() + } + + fn clone_on_split( + &self, + _workspace_id: workspace::WorkspaceId, + cx: &mut ViewContext, + ) -> Option + where + Self: Sized, + { + let mut clone = Self::new(self.workspace_handle.clone(), None, cx); + if let Some(editor) = &self.editor { + clone.set_editor(editor.editor.clone(), cx) + } + Some(clone) + } +} + +impl SyntaxTreeToolbarItemView { + pub fn new() -> Self { + Self { + menu_open: false, + tree_view: None, + subscription: None, + } + } + + fn render_menu( + &mut self, + cx: &mut ViewContext<'_, '_, Self>, + ) -> Option> { + let theme = theme::current(cx).clone(); + let tree_view = self.tree_view.as_ref()?; + let tree_view = tree_view.read(cx); + + let editor_state = tree_view.editor.as_ref()?; + let buffer_state = editor_state.active_buffer.as_ref()?; + let active_layer = buffer_state.active_layer.clone()?; + let active_buffer = buffer_state.buffer.read(cx).snapshot(); + + enum Menu {} + + Some( + Stack::new() + .with_child(Self::render_header(&theme, &active_layer, cx)) + .with_children(self.menu_open.then(|| { + Overlay::new( + MouseEventHandler::::new(0, cx, move |_, cx| { + Flex::column() + .with_children(active_buffer.syntax_layers().enumerate().map( + |(ix, layer)| { + Self::render_menu_item(&theme, &active_layer, layer, ix, cx) + }, + )) + .contained() + .with_style(theme.toolbar_dropdown_menu.container) + .constrained() + .with_width(400.) + .with_height(400.) + }) + .on_down_out(MouseButton::Left, |_, this, cx| { + this.menu_open = false; + cx.notify() + }), + ) + .with_hoverable(true) + .with_fit_mode(OverlayFitMode::SwitchAnchor) + .with_anchor_corner(AnchorCorner::TopLeft) + .with_z_index(999) + .aligned() + .bottom() + .left() + })) + .aligned() + .left() + .clipped() + .into_any(), + ) + } + + fn toggle_menu(&mut self, cx: &mut ViewContext) { + self.menu_open = !self.menu_open; + cx.notify(); + } + + fn select_layer(&mut self, layer_ix: usize, cx: &mut ViewContext) -> Option<()> { + let tree_view = self.tree_view.as_ref()?; + tree_view.update(cx, |view, cx| { + let editor_state = view.editor.as_mut()?; + let buffer_state = editor_state.active_buffer.as_mut()?; + let snapshot = buffer_state.buffer.read(cx).snapshot(); + let layer = snapshot.syntax_layers().nth(layer_ix)?; + buffer_state.active_layer = Some(layer.to_owned()); + view.selected_descendant_ix = None; + self.menu_open = false; + cx.notify(); + Some(()) + }) + } + + fn render_header( + theme: &Arc, + active_layer: &OwnedSyntaxLayerInfo, + cx: &mut ViewContext, + ) -> impl Element { + enum ToggleMenu {} + MouseEventHandler::::new(0, cx, move |state, _| { + let style = theme.toolbar_dropdown_menu.header.style_for(state); + Flex::row() + .with_child( + Label::new(active_layer.language.name().to_string(), style.text.clone()) + .contained() + .with_margin_right(style.secondary_text_spacing), + ) + .with_child(Label::new( + format_node_range(active_layer.node()), + style + .secondary_text + .clone() + .unwrap_or_else(|| style.text.clone()), + )) + .contained() + .with_style(style.container) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, view, cx| { + view.toggle_menu(cx); + }) + } + + fn render_menu_item( + theme: &Arc, + active_layer: &OwnedSyntaxLayerInfo, + layer: SyntaxLayerInfo, + layer_ix: usize, + cx: &mut ViewContext, + ) -> impl Element { + enum ActivateLayer {} + MouseEventHandler::::new(layer_ix, cx, move |state, _| { + let is_selected = layer.node() == active_layer.node(); + let style = theme + .toolbar_dropdown_menu + .item + .in_state(is_selected) + .style_for(state); + Flex::row() + .with_child( + Label::new(layer.language.name().to_string(), style.text.clone()) + .contained() + .with_margin_right(style.secondary_text_spacing), + ) + .with_child(Label::new( + format_node_range(layer.node()), + style + .secondary_text + .clone() + .unwrap_or_else(|| style.text.clone()), + )) + .contained() + .with_style(style.container) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, view, cx| { + view.select_layer(layer_ix, cx); + }) + } +} + +fn format_node_range(node: Node) -> String { + let start = node.start_position(); + let end = node.end_position(); + format!( + "[{}:{} - {}:{}]", + start.row + 1, + start.column + 1, + end.row + 1, + end.column + 1, + ) +} + +impl Entity for SyntaxTreeToolbarItemView { + type Event = (); +} + +impl View for SyntaxTreeToolbarItemView { + fn ui_name() -> &'static str { + "SyntaxTreeToolbarItemView" + } + + fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> gpui::AnyElement { + self.render_menu(cx) + .unwrap_or_else(|| Empty::new().into_any()) + } +} + +impl ToolbarItemView for SyntaxTreeToolbarItemView { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) -> workspace::ToolbarItemLocation { + self.menu_open = false; + if let Some(item) = active_pane_item { + if let Some(view) = item.downcast::() { + self.tree_view = Some(view.clone()); + self.subscription = Some(cx.observe(&view, |_, _, cx| cx.notify())); + return ToolbarItemLocation::PrimaryLeft { + flex: Some((1., false)), + }; + } + } + self.tree_view = None; + self.subscription = None; + ToolbarItemLocation::Hidden + } +} diff --git a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift index a0326b24a1..40c1319e8f 100644 --- a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift +++ b/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift @@ -6,17 +6,23 @@ import ScreenCaptureKit class LKRoomDelegate: RoomDelegate { var data: UnsafeRawPointer var onDidDisconnect: @convention(c) (UnsafeRawPointer) -> Void + var onDidSubscribeToRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void + var onDidUnsubscribeFromRemoteAudioTrack: @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void var onDidSubscribeToRemoteVideoTrack: @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void var onDidUnsubscribeFromRemoteVideoTrack: @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void init( data: UnsafeRawPointer, onDidDisconnect: @escaping @convention(c) (UnsafeRawPointer) -> Void, + onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, + onDidUnsubscribeFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, onDidSubscribeToRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, onDidUnsubscribeFromRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void) { self.data = data self.onDidDisconnect = onDidDisconnect + self.onDidSubscribeToRemoteAudioTrack = onDidSubscribeToRemoteAudioTrack + self.onDidUnsubscribeFromRemoteAudioTrack = onDidUnsubscribeFromRemoteAudioTrack self.onDidSubscribeToRemoteVideoTrack = onDidSubscribeToRemoteVideoTrack self.onDidUnsubscribeFromRemoteVideoTrack = onDidUnsubscribeFromRemoteVideoTrack } @@ -30,12 +36,16 @@ class LKRoomDelegate: RoomDelegate { func room(_ room: Room, participant: RemoteParticipant, didSubscribe publication: RemoteTrackPublication, track: Track) { if track.kind == .video { self.onDidSubscribeToRemoteVideoTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque()) + } else if track.kind == .audio { + self.onDidSubscribeToRemoteAudioTrack(self.data, participant.identity as CFString, track.sid! as CFString, Unmanaged.passUnretained(track).toOpaque()) } } func room(_ room: Room, participant: RemoteParticipant, didUnsubscribe publication: RemoteTrackPublication, track: Track) { if track.kind == .video { self.onDidUnsubscribeFromRemoteVideoTrack(self.data, participant.identity as CFString, track.sid! as CFString) + } else if track.kind == .audio { + self.onDidUnsubscribeFromRemoteAudioTrack(self.data, participant.identity as CFString, track.sid! as CFString) } } } @@ -77,12 +87,16 @@ class LKVideoRenderer: NSObject, VideoRenderer { public func LKRoomDelegateCreate( data: UnsafeRawPointer, onDidDisconnect: @escaping @convention(c) (UnsafeRawPointer) -> Void, + onDidSubscribeToRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, + onDidUnsubscribeFromRemoteAudioTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void, onDidSubscribeToRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString, UnsafeRawPointer) -> Void, onDidUnsubscribeFromRemoteVideoTrack: @escaping @convention(c) (UnsafeRawPointer, CFString, CFString) -> Void ) -> UnsafeMutableRawPointer { let delegate = LKRoomDelegate( data: data, onDidDisconnect: onDidDisconnect, + onDidSubscribeToRemoteAudioTrack: onDidSubscribeToRemoteAudioTrack, + onDidUnsubscribeFromRemoteAudioTrack: onDidUnsubscribeFromRemoteAudioTrack, onDidSubscribeToRemoteVideoTrack: onDidSubscribeToRemoteVideoTrack, onDidUnsubscribeFromRemoteVideoTrack: onDidUnsubscribeFromRemoteVideoTrack ) @@ -123,6 +137,18 @@ public func LKRoomPublishVideoTrack(room: UnsafeRawPointer, track: UnsafeRawPoin } } +@_cdecl("LKRoomPublishAudioTrack") +public func LKRoomPublishAudioTrack(room: UnsafeRawPointer, track: UnsafeRawPointer, callback: @escaping @convention(c) (UnsafeRawPointer, UnsafeMutableRawPointer?, CFString?) -> Void, callback_data: UnsafeRawPointer) { + let room = Unmanaged.fromOpaque(room).takeUnretainedValue() + let track = Unmanaged.fromOpaque(track).takeUnretainedValue() + room.localParticipant?.publishAudioTrack(track: track).then { publication in + callback(callback_data, Unmanaged.passRetained(publication).toOpaque(), nil) + }.catch { error in + callback(callback_data, nil, error.localizedDescription as CFString) + } +} + + @_cdecl("LKRoomUnpublishTrack") public func LKRoomUnpublishTrack(room: UnsafeRawPointer, publication: UnsafeRawPointer) { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() @@ -130,6 +156,20 @@ public func LKRoomUnpublishTrack(room: UnsafeRawPointer, publication: UnsafeRawP let _ = room.localParticipant?.unpublish(publication: publication) } +@_cdecl("LKRoomAudioTracksForRemoteParticipant") +public func LKRoomAudioTracksForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { + let room = Unmanaged.fromOpaque(room).takeUnretainedValue() + + for (_, participant) in room.remoteParticipants { + if participant.identity == participantId as String { + return participant.audioTracks.compactMap { $0.track as? RemoteAudioTrack } as CFArray? + } + } + + return nil; +} + + @_cdecl("LKRoomVideoTracksForRemoteParticipant") public func LKRoomVideoTracksForRemoteParticipant(room: UnsafeRawPointer, participantId: CFString) -> CFArray? { let room = Unmanaged.fromOpaque(room).takeUnretainedValue() @@ -143,6 +183,17 @@ public func LKRoomVideoTracksForRemoteParticipant(room: UnsafeRawPointer, partic return nil; } +@_cdecl("LKLocalAudioTrackCreateTrack") +public func LKLocalAudioTrackCreateTrack() -> UnsafeMutableRawPointer { + let track = LocalAudioTrack.createTrack(options: AudioCaptureOptions( + echoCancellation: true, + noiseSuppression: true + )) + + return Unmanaged.passRetained(track).toOpaque() +} + + @_cdecl("LKCreateScreenShareTrackForDisplay") public func LKCreateScreenShareTrackForDisplay(display: UnsafeMutableRawPointer) -> UnsafeMutableRawPointer { let display = Unmanaged.fromOpaque(display).takeUnretainedValue() @@ -150,6 +201,19 @@ public func LKCreateScreenShareTrackForDisplay(display: UnsafeMutableRawPointer) return Unmanaged.passRetained(track).toOpaque() } +@_cdecl("LKRemoteAudioTrackStart") +public func LKRemoteAudioTrackStart(track: UnsafeRawPointer, onStart: @escaping @convention(c) (UnsafeRawPointer, Bool) -> Void, callbackData: UnsafeRawPointer) { + let track = Unmanaged.fromOpaque(track).takeUnretainedValue() as! RemoteAudioTrack + + track.start().then { success in + onStart(callbackData, success) + } + .catch { _ in + onStart(callbackData, false) + } +} + + @_cdecl("LKVideoRendererCreate") public func LKVideoRendererCreate(data: UnsafeRawPointer, onFrame: @escaping @convention(c) (UnsafeRawPointer, CVPixelBuffer) -> Bool, onDrop: @escaping @convention(c) (UnsafeRawPointer) -> Void) -> UnsafeMutableRawPointer { Unmanaged.passRetained(LKVideoRenderer(data: data, onFrame: onFrame, onDrop: onDrop)).toOpaque() @@ -169,6 +233,12 @@ public func LKRemoteVideoTrackGetSid(track: UnsafeRawPointer) -> CFString { return track.sid! as CFString } +@_cdecl("LKRemoteAudioTrackGetSid") +public func LKRemoteAudioTrackGetSid(track: UnsafeRawPointer) -> CFString { + let track = Unmanaged.fromOpaque(track).takeUnretainedValue() + return track.sid! as CFString +} + @_cdecl("LKDisplaySources") public func LKDisplaySources(data: UnsafeRawPointer, callback: @escaping @convention(c) (UnsafeRawPointer, CFArray?, CFString?) -> Void) { MacOSScreenCapturer.sources(for: .display, includeCurrentApplication: false, preferredMethod: .legacy).then { displaySources in diff --git a/crates/live_kit_client/examples/test_app.rs b/crates/live_kit_client/examples/test_app.rs index 96480e92bc..faf1b54798 100644 --- a/crates/live_kit_client/examples/test_app.rs +++ b/crates/live_kit_client/examples/test_app.rs @@ -1,6 +1,10 @@ +use std::time::Duration; + use futures::StreamExt; use gpui::{actions, keymap_matcher::Binding, Menu, MenuItem}; -use live_kit_client::{LocalVideoTrack, RemoteVideoTrackUpdate, Room}; +use live_kit_client::{ + LocalAudioTrack, LocalVideoTrack, RemoteAudioTrackUpdate, RemoteVideoTrackUpdate, Room, +}; use live_kit_server::token::{self, VideoGrant}; use log::LevelFilter; use simplelog::SimpleLogger; @@ -11,6 +15,12 @@ fn main() { SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger"); gpui::App::new(()).unwrap().run(|cx| { + #[cfg(any(test, feature = "test-support"))] + println!("USING TEST LIVEKIT"); + + #[cfg(not(any(test, feature = "test-support")))] + println!("USING REAL LIVEKIT"); + cx.platform().activate(true); cx.add_global_action(quit); @@ -49,16 +59,14 @@ fn main() { let room_b = Room::new(); room_b.connect(&live_kit_url, &user2_token).await.unwrap(); - let mut track_changes = room_b.remote_video_track_updates(); + let mut audio_track_updates = room_b.remote_audio_track_updates(); + let audio_track = LocalAudioTrack::create(); + let audio_track_publication = room_a.publish_audio_track(&audio_track).await.unwrap(); - let displays = room_a.display_sources().await.unwrap(); - let display = displays.into_iter().next().unwrap(); - - let track_a = LocalVideoTrack::screen_share_for_display(&display); - let track_a_publication = room_a.publish_video_track(&track_a).await.unwrap(); - - if let RemoteVideoTrackUpdate::Subscribed(track) = track_changes.next().await.unwrap() { - let remote_tracks = room_b.remote_video_tracks("test-participant-1"); + if let RemoteAudioTrackUpdate::Subscribed(track) = + audio_track_updates.next().await.unwrap() + { + let remote_tracks = room_b.remote_audio_tracks("test-participant-1"); assert_eq!(remote_tracks.len(), 1); assert_eq!(remote_tracks[0].publisher_id(), "test-participant-1"); assert_eq!(track.publisher_id(), "test-participant-1"); @@ -66,18 +74,60 @@ fn main() { panic!("unexpected message"); } - let remote_track = room_b + println!("Pausing for 5 seconds to test audio, make some noise!"); + let timer = cx.background().timer(Duration::from_secs(5)); + timer.await; + + let remote_audio_track = room_b + .remote_audio_tracks("test-participant-1") + .pop() + .unwrap(); + room_a.unpublish_track(audio_track_publication); + if let RemoteAudioTrackUpdate::Unsubscribed { + publisher_id, + track_id, + } = audio_track_updates.next().await.unwrap() + { + assert_eq!(publisher_id, "test-participant-1"); + assert_eq!(remote_audio_track.sid(), track_id); + assert_eq!(room_b.remote_audio_tracks("test-participant-1").len(), 0); + } else { + panic!("unexpected message"); + } + + let mut video_track_updates = room_b.remote_video_track_updates(); + let displays = room_a.display_sources().await.unwrap(); + let display = displays.into_iter().next().unwrap(); + + let local_video_track = LocalVideoTrack::screen_share_for_display(&display); + let local_video_track_publication = room_a + .publish_video_track(&local_video_track) + .await + .unwrap(); + + if let RemoteVideoTrackUpdate::Subscribed(track) = + video_track_updates.next().await.unwrap() + { + let remote_video_tracks = room_b.remote_video_tracks("test-participant-1"); + assert_eq!(remote_video_tracks.len(), 1); + assert_eq!(remote_video_tracks[0].publisher_id(), "test-participant-1"); + assert_eq!(track.publisher_id(), "test-participant-1"); + } else { + panic!("unexpected message"); + } + + let remote_video_track = room_b .remote_video_tracks("test-participant-1") .pop() .unwrap(); - room_a.unpublish_track(track_a_publication); + room_a.unpublish_track(local_video_track_publication); if let RemoteVideoTrackUpdate::Unsubscribed { publisher_id, track_id, - } = track_changes.next().await.unwrap() + } = video_track_updates.next().await.unwrap() { assert_eq!(publisher_id, "test-participant-1"); - assert_eq!(remote_track.sid(), track_id); + assert_eq!(remote_video_track.sid(), track_id); assert_eq!(room_b.remote_video_tracks("test-participant-1").len(), 0); } else { panic!("unexpected message"); diff --git a/crates/live_kit_client/src/live_kit_client.rs b/crates/live_kit_client/src/live_kit_client.rs index 2ded570828..0467018c00 100644 --- a/crates/live_kit_client/src/live_kit_client.rs +++ b/crates/live_kit_client/src/live_kit_client.rs @@ -4,7 +4,7 @@ pub mod prod; pub use prod::*; #[cfg(any(test, feature = "test-support"))] -mod test; +pub mod test; #[cfg(any(test, feature = "test-support"))] pub use test::*; diff --git a/crates/live_kit_client/src/prod.rs b/crates/live_kit_client/src/prod.rs index f45667e3c3..6ae493d306 100644 --- a/crates/live_kit_client/src/prod.rs +++ b/crates/live_kit_client/src/prod.rs @@ -21,6 +21,17 @@ extern "C" { fn LKRoomDelegateCreate( callback_data: *mut c_void, on_did_disconnect: extern "C" fn(callback_data: *mut c_void), + on_did_subscribe_to_remote_audio_track: extern "C" fn( + callback_data: *mut c_void, + publisher_id: CFStringRef, + track_id: CFStringRef, + remote_track: *const c_void, + ), + on_did_unsubscribe_from_remote_audio_track: extern "C" fn( + callback_data: *mut c_void, + publisher_id: CFStringRef, + track_id: CFStringRef, + ), on_did_subscribe_to_remote_video_track: extern "C" fn( callback_data: *mut c_void, publisher_id: CFStringRef, @@ -49,7 +60,18 @@ extern "C" { callback: extern "C" fn(*mut c_void, *mut c_void, CFStringRef), callback_data: *mut c_void, ); + fn LKRoomPublishAudioTrack( + room: *const c_void, + track: *const c_void, + callback: extern "C" fn(*mut c_void, *mut c_void, CFStringRef), + callback_data: *mut c_void, + ); fn LKRoomUnpublishTrack(room: *const c_void, publication: *const c_void); + fn LKRoomAudioTracksForRemoteParticipant( + room: *const c_void, + participant_id: CFStringRef, + ) -> CFArrayRef; + fn LKRoomVideoTracksForRemoteParticipant( room: *const c_void, participant_id: CFStringRef, @@ -61,6 +83,13 @@ extern "C" { on_drop: extern "C" fn(callback_data: *mut c_void), ) -> *const c_void; + fn LKRemoteAudioTrackGetSid(track: *const c_void) -> CFStringRef; + // fn LKRemoteAudioTrackStart( + // track: *const c_void, + // callback: extern "C" fn(*mut c_void, bool), + // callback_data: *mut c_void + // ); + fn LKVideoTrackAddRenderer(track: *const c_void, renderer: *const c_void); fn LKRemoteVideoTrackGetSid(track: *const c_void) -> CFStringRef; @@ -73,6 +102,7 @@ extern "C" { ), ); fn LKCreateScreenShareTrackForDisplay(display: *const c_void) -> *const c_void; + fn LKLocalAudioTrackCreateTrack() -> *const c_void; } pub type Sid = String; @@ -89,6 +119,7 @@ pub struct Room { watch::Sender, watch::Receiver, )>, + remote_audio_track_subscribers: Mutex>>, remote_video_track_subscribers: Mutex>>, _delegate: RoomDelegate, } @@ -100,6 +131,7 @@ impl Room { Self { native_room: unsafe { LKRoomCreate(delegate.native_delegate) }, connection: Mutex::new(watch::channel_with(ConnectionState::Disconnected)), + remote_audio_track_subscribers: Default::default(), remote_video_track_subscribers: Default::default(), _delegate: delegate, } @@ -191,6 +223,32 @@ impl Room { async { rx.await.unwrap().context("error publishing video track") } } + pub fn publish_audio_track( + self: &Arc, + track: &LocalAudioTrack, + ) -> impl Future> { + let (tx, rx) = oneshot::channel::>(); + extern "C" fn callback(tx: *mut c_void, publication: *mut c_void, error: CFStringRef) { + let tx = + unsafe { Box::from_raw(tx as *mut oneshot::Sender>) }; + if error.is_null() { + let _ = tx.send(Ok(LocalTrackPublication(publication))); + } else { + let error = unsafe { CFString::wrap_under_get_rule(error).to_string() }; + let _ = tx.send(Err(anyhow!(error))); + } + } + unsafe { + LKRoomPublishAudioTrack( + self.native_room, + track.0, + callback, + Box::into_raw(Box::new(tx)) as *mut c_void, + ); + } + async { rx.await.unwrap().context("error publishing video track") } + } + pub fn unpublish_track(&self, publication: LocalTrackPublication) { unsafe { LKRoomUnpublishTrack(self.native_room, publication.0); @@ -226,12 +284,65 @@ impl Room { } } + pub fn remote_audio_tracks(&self, participant_id: &str) -> Vec> { + unsafe { + let tracks = LKRoomAudioTracksForRemoteParticipant( + self.native_room, + CFString::new(participant_id).as_concrete_TypeRef(), + ); + + if tracks.is_null() { + Vec::new() + } else { + let tracks = CFArray::wrap_under_get_rule(tracks); + tracks + .into_iter() + .map(|native_track| { + let native_track = *native_track; + let id = + CFString::wrap_under_get_rule(LKRemoteAudioTrackGetSid(native_track)) + .to_string(); + Arc::new(RemoteAudioTrack::new( + native_track, + id, + participant_id.into(), + )) + }) + .collect() + } + } + } + + pub fn remote_audio_track_updates(&self) -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded(); + self.remote_audio_track_subscribers.lock().push(tx); + rx + } + pub fn remote_video_track_updates(&self) -> mpsc::UnboundedReceiver { let (tx, rx) = mpsc::unbounded(); self.remote_video_track_subscribers.lock().push(tx); rx } + fn did_subscribe_to_remote_audio_track(&self, track: RemoteAudioTrack) { + let track = Arc::new(track); + self.remote_audio_track_subscribers.lock().retain(|tx| { + tx.unbounded_send(RemoteAudioTrackUpdate::Subscribed(track.clone())) + .is_ok() + }); + } + + fn did_unsubscribe_from_remote_audio_track(&self, publisher_id: String, track_id: String) { + self.remote_audio_track_subscribers.lock().retain(|tx| { + tx.unbounded_send(RemoteAudioTrackUpdate::Unsubscribed { + publisher_id: publisher_id.clone(), + track_id: track_id.clone(), + }) + .is_ok() + }); + } + fn did_subscribe_to_remote_video_track(&self, track: RemoteVideoTrack) { let track = Arc::new(track); self.remote_video_track_subscribers.lock().retain(|tx| { @@ -294,6 +405,8 @@ impl RoomDelegate { LKRoomDelegateCreate( weak_room as *mut c_void, Self::on_did_disconnect, + Self::on_did_subscribe_to_remote_audio_track, + Self::on_did_unsubscribe_from_remote_audio_track, Self::on_did_subscribe_to_remote_video_track, Self::on_did_unsubscribe_from_remote_video_track, ) @@ -312,6 +425,36 @@ impl RoomDelegate { let _ = Weak::into_raw(room); } + extern "C" fn on_did_subscribe_to_remote_audio_track( + room: *mut c_void, + publisher_id: CFStringRef, + track_id: CFStringRef, + track: *const c_void, + ) { + let room = unsafe { Weak::from_raw(room as *mut Room) }; + let publisher_id = unsafe { CFString::wrap_under_get_rule(publisher_id).to_string() }; + let track_id = unsafe { CFString::wrap_under_get_rule(track_id).to_string() }; + let track = RemoteAudioTrack::new(track, track_id, publisher_id); + if let Some(room) = room.upgrade() { + room.did_subscribe_to_remote_audio_track(track); + } + let _ = Weak::into_raw(room); + } + + extern "C" fn on_did_unsubscribe_from_remote_audio_track( + room: *mut c_void, + publisher_id: CFStringRef, + track_id: CFStringRef, + ) { + let room = unsafe { Weak::from_raw(room as *mut Room) }; + let publisher_id = unsafe { CFString::wrap_under_get_rule(publisher_id).to_string() }; + let track_id = unsafe { CFString::wrap_under_get_rule(track_id).to_string() }; + if let Some(room) = room.upgrade() { + room.did_unsubscribe_from_remote_audio_track(publisher_id, track_id); + } + let _ = Weak::into_raw(room); + } + extern "C" fn on_did_subscribe_to_remote_video_track( room: *mut c_void, publisher_id: CFStringRef, @@ -352,6 +495,20 @@ impl Drop for RoomDelegate { } } +pub struct LocalAudioTrack(*const c_void); + +impl LocalAudioTrack { + pub fn create() -> Self { + Self(unsafe { LKLocalAudioTrackCreateTrack() }) + } +} + +impl Drop for LocalAudioTrack { + fn drop(&mut self) { + unsafe { CFRelease(self.0) } + } +} + pub struct LocalVideoTrack(*const c_void); impl LocalVideoTrack { @@ -374,6 +531,34 @@ impl Drop for LocalTrackPublication { } } +#[derive(Debug)] +pub struct RemoteAudioTrack { + _native_track: *const c_void, + sid: Sid, + publisher_id: String, +} + +impl RemoteAudioTrack { + fn new(native_track: *const c_void, sid: Sid, publisher_id: String) -> Self { + unsafe { + CFRetain(native_track); + } + Self { + _native_track: native_track, + sid, + publisher_id, + } + } + + pub fn sid(&self) -> &str { + &self.sid + } + + pub fn publisher_id(&self) -> &str { + &self.publisher_id + } +} + #[derive(Debug)] pub struct RemoteVideoTrack { native_track: *const c_void, @@ -453,6 +638,11 @@ pub enum RemoteVideoTrackUpdate { Unsubscribed { publisher_id: Sid, track_id: Sid }, } +pub enum RemoteAudioTrackUpdate { + Subscribed(Arc), + Unsubscribed { publisher_id: Sid, track_id: Sid }, +} + pub struct MacOSDisplay(*const c_void); impl MacOSDisplay { diff --git a/crates/live_kit_client/src/test.rs b/crates/live_kit_client/src/test.rs index 8d1e4fa16a..2f89617363 100644 --- a/crates/live_kit_client/src/test.rs +++ b/crates/live_kit_client/src/test.rs @@ -67,7 +67,7 @@ impl TestServer { } } - async fn create_room(&self, room: String) -> Result<()> { + pub async fn create_room(&self, room: String) -> Result<()> { self.background.simulate_random_delay().await; let mut server_rooms = self.rooms.lock(); if server_rooms.contains_key(&room) { @@ -104,7 +104,7 @@ impl TestServer { room_name )) } else { - for track in &room.tracks { + for track in &room.video_tracks { client_room .0 .lock() @@ -182,7 +182,7 @@ impl TestServer { frames_rx: local_track.frames_rx.clone(), }); - room.tracks.push(track.clone()); + room.video_tracks.push(track.clone()); for (id, client_room) in &room.client_rooms { if *id != identity { @@ -199,6 +199,43 @@ impl TestServer { Ok(()) } + async fn publish_audio_track( + &self, + token: String, + _local_track: &LocalAudioTrack, + ) -> Result<()> { + self.background.simulate_random_delay().await; + let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let identity = claims.sub.unwrap().to_string(); + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + + let track = Arc::new(RemoteAudioTrack { + sid: nanoid::nanoid!(17), + publisher_id: identity.clone(), + }); + + room.audio_tracks.push(track.clone()); + + for (id, client_room) in &room.client_rooms { + if *id != identity { + let _ = client_room + .0 + .lock() + .audio_track_updates + .0 + .try_broadcast(RemoteAudioTrackUpdate::Subscribed(track.clone())) + .unwrap(); + } + } + + Ok(()) + } + fn video_tracks(&self, token: String) -> Result>> { let claims = live_kit_server::token::validate(&token, &self.secret_key)?; let room_name = claims.video.room.unwrap(); @@ -207,14 +244,26 @@ impl TestServer { let room = server_rooms .get_mut(&*room_name) .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; - Ok(room.tracks.clone()) + Ok(room.video_tracks.clone()) + } + + fn audio_tracks(&self, token: String) -> Result>> { + let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + Ok(room.audio_tracks.clone()) } } #[derive(Default)] struct TestServerRoom { client_rooms: HashMap>, - tracks: Vec>, + video_tracks: Vec>, + audio_tracks: Vec>, } impl TestServerRoom {} @@ -266,6 +315,10 @@ struct RoomState { watch::Receiver, ), display_sources: Vec, + audio_track_updates: ( + async_broadcast::Sender, + async_broadcast::Receiver, + ), video_track_updates: ( async_broadcast::Sender, async_broadcast::Receiver, @@ -286,6 +339,7 @@ impl Room { connection: watch::channel_with(ConnectionState::Disconnected), display_sources: Default::default(), video_track_updates: async_broadcast::broadcast(128), + audio_track_updates: async_broadcast::broadcast(128), }))) } @@ -327,8 +381,34 @@ impl Room { Ok(LocalTrackPublication) } } + pub fn publish_audio_track( + self: &Arc, + track: &LocalAudioTrack, + ) -> impl Future> { + let this = self.clone(); + let track = track.clone(); + async move { + this.test_server() + .publish_audio_track(this.token(), &track) + .await?; + Ok(LocalTrackPublication) + } + } - pub fn unpublish_track(&self, _: LocalTrackPublication) {} + pub fn unpublish_track(&self, _publication: LocalTrackPublication) {} + + pub fn remote_audio_tracks(&self, publisher_id: &str) -> Vec> { + if !self.is_connected() { + return Vec::new(); + } + + self.test_server() + .audio_tracks(self.token()) + .unwrap() + .into_iter() + .filter(|track| track.publisher_id() == publisher_id) + .collect() + } pub fn remote_video_tracks(&self, publisher_id: &str) -> Vec> { if !self.is_connected() { @@ -343,6 +423,10 @@ impl Room { .collect() } + pub fn remote_audio_track_updates(&self) -> impl Stream { + self.0.lock().audio_track_updates.1.clone() + } + pub fn remote_video_track_updates(&self) -> impl Stream { self.0.lock().video_track_updates.1.clone() } @@ -404,6 +488,15 @@ impl LocalVideoTrack { } } +#[derive(Clone)] +pub struct LocalAudioTrack; + +impl LocalAudioTrack { + pub fn create() -> Self { + Self + } +} + pub struct RemoteVideoTrack { sid: Sid, publisher_id: Sid, @@ -424,12 +517,33 @@ impl RemoteVideoTrack { } } +pub struct RemoteAudioTrack { + sid: Sid, + publisher_id: Sid, +} + +impl RemoteAudioTrack { + pub fn sid(&self) -> &str { + &self.sid + } + + pub fn publisher_id(&self) -> &str { + &self.publisher_id + } +} + #[derive(Clone)] pub enum RemoteVideoTrackUpdate { Subscribed(Arc), Unsubscribed { publisher_id: Sid, track_id: Sid }, } +#[derive(Clone)] +pub enum RemoteAudioTrackUpdate { + Subscribed(Arc), + Unsubscribed { publisher_id: Sid, track_id: Sid }, +} + #[derive(Clone)] pub struct MacOSDisplay { frames: ( diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 96d4382075..5074989277 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -33,7 +33,7 @@ const JSON_RPC_VERSION: &str = "2.0"; const CONTENT_LEN_HEADER: &str = "Content-Length: "; type NotificationHandler = Box, &str, AsyncAppContext)>; -type ResponseHandler = Box)>; +type ResponseHandler = Box)>; type IoHandler = Box; pub struct LanguageServer { @@ -103,14 +103,14 @@ struct Notification<'a, T> { params: T, } -#[derive(Deserialize)] +#[derive(Debug, Clone, Deserialize)] struct AnyNotification<'a> { #[serde(default)] id: Option, #[serde(borrow)] method: &'a str, - #[serde(borrow)] - params: &'a RawValue, + #[serde(borrow, default)] + params: Option<&'a RawValue>, } #[derive(Debug, Serialize, Deserialize)] @@ -157,9 +157,12 @@ impl LanguageServer { "unhandled notification {}:\n{}", notification.method, serde_json::to_string_pretty( - &Value::from_str(notification.params.get()).unwrap() + ¬ification + .params + .and_then(|params| Value::from_str(params.get()).ok()) + .unwrap_or(Value::Null) ) - .unwrap() + .unwrap(), ); }, ); @@ -279,7 +282,11 @@ impl LanguageServer { if let Ok(msg) = serde_json::from_slice::(&buffer) { if let Some(handler) = notification_handlers.lock().get_mut(msg.method) { - handler(msg.id, msg.params.get(), cx.clone()); + handler( + msg.id, + &msg.params.map(|params| params.get()).unwrap_or("null"), + cx.clone(), + ); } else { on_unhandled_notification(msg); } @@ -295,9 +302,9 @@ impl LanguageServer { if let Some(error) = error { handler(Err(error)); } else if let Some(result) = result { - handler(Ok(result.get())); + handler(Ok(result.get().into())); } else { - handler(Ok("null")); + handler(Ok("null".into())); } } } else { @@ -450,11 +457,13 @@ impl LanguageServer { let response_handlers = self.response_handlers.clone(); let next_id = AtomicUsize::new(self.next_id.load(SeqCst)); let outbound_tx = self.outbound_tx.clone(); + let executor = self.executor.clone(); let mut output_done = self.output_done_rx.lock().take().unwrap(); let shutdown_request = Self::request_internal::( &next_id, &response_handlers, &outbound_tx, + &executor, (), ); let exit = Self::notify_internal::(&outbound_tx, ()); @@ -651,6 +660,7 @@ impl LanguageServer { &self.next_id, &self.response_handlers, &self.outbound_tx, + &self.executor, params, ) } @@ -659,6 +669,7 @@ impl LanguageServer { next_id: &AtomicUsize, response_handlers: &Mutex>>, outbound_tx: &channel::Sender, + executor: &Arc, params: T::Params, ) -> impl 'static + Future> where @@ -679,15 +690,20 @@ impl LanguageServer { .as_mut() .ok_or_else(|| anyhow!("server shut down")) .map(|handlers| { + let executor = executor.clone(); handlers.insert( id, Box::new(move |result| { - let response = match result { - Ok(response) => serde_json::from_str(response) - .context("failed to deserialize response"), - Err(error) => Err(anyhow!("{}", error.message)), - }; - let _ = tx.send(response); + executor + .spawn(async move { + let response = match result { + Ok(response) => serde_json::from_str(&response) + .context("failed to deserialize response"), + Err(error) => Err(anyhow!("{}", error.message)), + }; + let _ = tx.send(response); + }) + .detach(); }), ); }); @@ -828,7 +844,13 @@ impl LanguageServer { cx, move |msg| { notifications_tx - .try_send((msg.method.to_string(), msg.params.get().to_string())) + .try_send(( + msg.method.to_string(), + msg.params + .map(|raw_value| raw_value.get()) + .unwrap_or("null") + .to_string(), + )) .ok(); }, )), diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 1e364f5fc8..f93fa10052 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -204,7 +204,7 @@ impl PickerDelegate for OutlineViewDelegate { cx: &AppContext, ) -> AnyElement> { let theme = theme::current(cx); - let style = theme.picker.item.style_for(mouse_state, selected); + let style = theme.picker.item.in_state(selected).style_for(mouse_state); let string_match = &self.matches[ix]; let outline_item = &self.outline.items[string_match.candidate_id]; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0ac50e7106..27d424879f 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -7,6 +7,8 @@ pub mod worktree; #[cfg(test)] mod project_tests; +#[cfg(test)] +mod worktree_tests; use anyhow::{anyhow, Context, Result}; use client::{proto, Client, TypedEnvelope, UserStore}; @@ -36,9 +38,9 @@ use language::{ }, range_from_lsp, range_to_lsp, Anchor, Bias, Buffer, CachedLspAdapter, CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, Event as BufferEvent, File as _, - Language, LanguageRegistry, LanguageServerName, LocalFile, OffsetRangeExt, Operation, Patch, - PendingLanguageServer, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, - Unclipped, + Language, LanguageRegistry, LanguageServerName, LocalFile, LspAdapterDelegate, OffsetRangeExt, + Operation, Patch, PendingLanguageServer, PointUtf16, TextBufferSnapshot, ToOffset, + ToPointUtf16, Transaction, Unclipped, }; use log::error; use lsp::{ @@ -73,8 +75,8 @@ use std::{ }; use terminals::Terminals; use util::{ - debug_panic, defer, merge_json_value_into, paths::LOCAL_SETTINGS_RELATIVE_PATH, post_inc, - ResultExt, TryFutureExt as _, + debug_panic, defer, http::HttpClient, merge_json_value_into, + paths::LOCAL_SETTINGS_RELATIVE_PATH, post_inc, ResultExt, TryFutureExt as _, }; pub use fs::*; @@ -250,6 +252,7 @@ pub enum Event { LanguageServerAdded(LanguageServerId), LanguageServerRemoved(LanguageServerId), LanguageServerLog(LanguageServerId, String), + Notification(String), ActiveEntryChanged(Option), WorktreeAdded, WorktreeRemoved(WorktreeId), @@ -433,6 +436,11 @@ pub enum FormatTrigger { Manual, } +struct ProjectLspAdapterDelegate { + project: ModelHandle, + http_client: Arc, +} + impl FormatTrigger { fn from_proto(value: i32) -> FormatTrigger { match value { @@ -2405,7 +2413,7 @@ impl Project { language.clone(), adapter.clone(), worktree_path.clone(), - self.client.http_client(), + ProjectLspAdapterDelegate::new(self, cx), cx, ) { Some(pending_server) => pending_server, @@ -7186,6 +7194,26 @@ impl> From<(WorktreeId, P)> for ProjectPath { } } +impl ProjectLspAdapterDelegate { + fn new(project: &Project, cx: &ModelContext) -> Arc { + Arc::new(Self { + project: cx.handle(), + http_client: project.client.http_client(), + }) + } +} + +impl LspAdapterDelegate for ProjectLspAdapterDelegate { + fn show_notification(&self, message: &str, cx: &mut AppContext) { + self.project + .update(cx, |_, cx| cx.emit(Event::Notification(message.to_owned()))); + } + + fn http_client(&self) -> Arc { + self.http_client.clone() + } +} + fn split_operations( mut operations: Vec, ) -> impl Iterator> { diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index ee190e1a31..2b0ba3d521 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -160,7 +160,7 @@ impl From<&RepositoryEntry> for proto::RepositoryEntry { /// This path corresponds to the 'content path' (the folder that contains the .git) #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)] -pub struct RepositoryWorkDirectory(Arc); +pub struct RepositoryWorkDirectory(pub(crate) Arc); impl Default for RepositoryWorkDirectory { fn default() -> Self { @@ -212,7 +212,7 @@ pub struct LocalSnapshot { git_repositories: TreeMap, } -pub struct BackgroundScannerState { +struct BackgroundScannerState { snapshot: LocalSnapshot, /// The ids of all of the entries that were removed from the snapshot /// as part of the current update. These entry ids may be re-used @@ -1470,7 +1470,7 @@ impl Snapshot { break; } } - new_entries_by_path.push_tree(cursor.suffix(&()), &()); + new_entries_by_path.append(cursor.suffix(&()), &()); new_entries_by_path }; @@ -1755,7 +1755,7 @@ impl Snapshot { } } - fn descendent_entries<'a>( + pub fn descendent_entries<'a>( &'a self, include_dirs: bool, include_ignored: bool, @@ -2100,6 +2100,82 @@ impl LocalSnapshot { } } +impl LocalSnapshot { + #[cfg(test)] + pub fn check_invariants(&self) { + assert_eq!( + self.entries_by_path + .cursor::<()>() + .map(|e| (&e.path, e.id)) + .collect::>(), + self.entries_by_id + .cursor::<()>() + .map(|e| (&e.path, e.id)) + .collect::>() + .into_iter() + .collect::>(), + "entries_by_path and entries_by_id are inconsistent" + ); + + let mut files = self.files(true, 0); + let mut visible_files = self.files(false, 0); + for entry in self.entries_by_path.cursor::<()>() { + if entry.is_file() { + assert_eq!(files.next().unwrap().inode, entry.inode); + if !entry.is_ignored { + assert_eq!(visible_files.next().unwrap().inode, entry.inode); + } + } + } + + assert!(files.next().is_none()); + assert!(visible_files.next().is_none()); + + let mut bfs_paths = Vec::new(); + let mut stack = vec![Path::new("")]; + while let Some(path) = stack.pop() { + bfs_paths.push(path); + let ix = stack.len(); + for child_entry in self.child_entries(path) { + stack.insert(ix, &child_entry.path); + } + } + + let dfs_paths_via_iter = self + .entries_by_path + .cursor::<()>() + .map(|e| e.path.as_ref()) + .collect::>(); + assert_eq!(bfs_paths, dfs_paths_via_iter); + + let dfs_paths_via_traversal = self + .entries(true) + .map(|e| e.path.as_ref()) + .collect::>(); + assert_eq!(dfs_paths_via_traversal, dfs_paths_via_iter); + + for ignore_parent_abs_path in self.ignores_by_parent_abs_path.keys() { + let ignore_parent_path = ignore_parent_abs_path.strip_prefix(&self.abs_path).unwrap(); + assert!(self.entry_for_path(&ignore_parent_path).is_some()); + assert!(self + .entry_for_path(ignore_parent_path.join(&*GITIGNORE)) + .is_some()); + } + } + + #[cfg(test)] + pub fn entries_without_ids(&self, include_ignored: bool) -> Vec<(&Path, u64, bool)> { + let mut paths = Vec::new(); + for entry in self.entries_by_path.cursor::<()>() { + if include_ignored || !entry.is_ignored { + paths.push((entry.path.as_ref(), entry.inode, entry.is_ignored)); + } + } + paths.sort_by(|a, b| a.0.cmp(b.0)); + paths + } +} + impl BackgroundScannerState { fn reuse_entry_id(&mut self, entry: &mut Entry) { if let Some(removed_entry_id) = self.removed_entry_ids.remove(&entry.inode) { @@ -2183,7 +2259,7 @@ impl BackgroundScannerState { let mut cursor = self.snapshot.entries_by_path.cursor::(); new_entries = cursor.slice(&TraversalTarget::Path(path), Bias::Left, &()); removed_entries = cursor.slice(&TraversalTarget::PathSuccessor(path), Bias::Left, &()); - new_entries.push_tree(cursor.suffix(&()), &()); + new_entries.append(cursor.suffix(&()), &()); } self.snapshot.entries_by_path = new_entries; @@ -3877,7 +3953,7 @@ impl<'a> Iterator for ChildEntriesIter<'a> { } } -struct DescendentEntriesIter<'a> { +pub struct DescendentEntriesIter<'a> { parent_path: &'a Path, traversal: Traversal<'a>, } @@ -3942,1601 +4018,3 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry { } } } - -#[cfg(test)] -mod tests { - use super::*; - use fs::{FakeFs, RealFs}; - use gpui::{executor::Deterministic, TestAppContext}; - use pretty_assertions::assert_eq; - use rand::prelude::*; - use serde_json::json; - use std::{env, fmt::Write}; - use util::{http::FakeHttpClient, test::temp_tree}; - - #[gpui::test] - async fn test_traversal(cx: &mut TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/root", - json!({ - ".gitignore": "a/b\n", - "a": { - "b": "", - "c": "", - } - }), - ) - .await; - - let http_client = FakeHttpClient::with_404_response(); - let client = cx.read(|cx| Client::new(http_client, cx)); - - let tree = Worktree::local( - client, - Path::new("/root"), - true, - fs, - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - - tree.read_with(cx, |tree, _| { - assert_eq!( - tree.entries(false) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![ - Path::new(""), - Path::new(".gitignore"), - Path::new("a"), - Path::new("a/c"), - ] - ); - assert_eq!( - tree.entries(true) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![ - Path::new(""), - Path::new(".gitignore"), - Path::new("a"), - Path::new("a/b"), - Path::new("a/c"), - ] - ); - }) - } - - #[gpui::test] - async fn test_descendent_entries(cx: &mut TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/root", - json!({ - "a": "", - "b": { - "c": { - "d": "" - }, - "e": {} - }, - "f": "", - "g": { - "h": {} - }, - "i": { - "j": { - "k": "" - }, - "l": { - - } - }, - ".gitignore": "i/j\n", - }), - ) - .await; - - let http_client = FakeHttpClient::with_404_response(); - let client = cx.read(|cx| Client::new(http_client, cx)); - - let tree = Worktree::local( - client, - Path::new("/root"), - true, - fs, - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - - tree.read_with(cx, |tree, _| { - assert_eq!( - tree.descendent_entries(false, false, Path::new("b")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![Path::new("b/c/d"),] - ); - assert_eq!( - tree.descendent_entries(true, false, Path::new("b")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![ - Path::new("b"), - Path::new("b/c"), - Path::new("b/c/d"), - Path::new("b/e"), - ] - ); - - assert_eq!( - tree.descendent_entries(false, false, Path::new("g")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - Vec::::new() - ); - assert_eq!( - tree.descendent_entries(true, false, Path::new("g")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![Path::new("g"), Path::new("g/h"),] - ); - - assert_eq!( - tree.descendent_entries(false, false, Path::new("i")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - Vec::::new() - ); - assert_eq!( - tree.descendent_entries(false, true, Path::new("i")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![Path::new("i/j/k")] - ); - assert_eq!( - tree.descendent_entries(true, false, Path::new("i")) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![Path::new("i"), Path::new("i/l"),] - ); - }) - } - - #[gpui::test(iterations = 10)] - async fn test_circular_symlinks(executor: Arc, cx: &mut TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/root", - json!({ - "lib": { - "a": { - "a.txt": "" - }, - "b": { - "b.txt": "" - } - } - }), - ) - .await; - fs.insert_symlink("/root/lib/a/lib", "..".into()).await; - fs.insert_symlink("/root/lib/b/lib", "..".into()).await; - - let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - let tree = Worktree::local( - client, - Path::new("/root"), - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - - tree.read_with(cx, |tree, _| { - assert_eq!( - tree.entries(false) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![ - Path::new(""), - Path::new("lib"), - Path::new("lib/a"), - Path::new("lib/a/a.txt"), - Path::new("lib/a/lib"), - Path::new("lib/b"), - Path::new("lib/b/b.txt"), - Path::new("lib/b/lib"), - ] - ); - }); - - fs.rename( - Path::new("/root/lib/a/lib"), - Path::new("/root/lib/a/lib-2"), - Default::default(), - ) - .await - .unwrap(); - executor.run_until_parked(); - tree.read_with(cx, |tree, _| { - assert_eq!( - tree.entries(false) - .map(|entry| entry.path.as_ref()) - .collect::>(), - vec![ - Path::new(""), - Path::new("lib"), - Path::new("lib/a"), - Path::new("lib/a/a.txt"), - Path::new("lib/a/lib-2"), - Path::new("lib/b"), - Path::new("lib/b/b.txt"), - Path::new("lib/b/lib"), - ] - ); - }); - } - - #[gpui::test] - async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { - // .gitignores are handled explicitly by Zed and do not use the git - // machinery that the git_tests module checks - let parent_dir = temp_tree(json!({ - ".gitignore": "ancestor-ignored-file1\nancestor-ignored-file2\n", - "tree": { - ".git": {}, - ".gitignore": "ignored-dir\n", - "tracked-dir": { - "tracked-file1": "", - "ancestor-ignored-file1": "", - }, - "ignored-dir": { - "ignored-file1": "" - } - } - })); - let dir = parent_dir.path().join("tree"); - - let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - - let tree = Worktree::local( - client, - dir.as_path(), - true, - Arc::new(RealFs), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - tree.flush_fs_events(cx).await; - cx.read(|cx| { - let tree = tree.read(cx); - assert!( - !tree - .entry_for_path("tracked-dir/tracked-file1") - .unwrap() - .is_ignored - ); - assert!( - tree.entry_for_path("tracked-dir/ancestor-ignored-file1") - .unwrap() - .is_ignored - ); - assert!( - tree.entry_for_path("ignored-dir/ignored-file1") - .unwrap() - .is_ignored - ); - }); - - std::fs::write(dir.join("tracked-dir/tracked-file2"), "").unwrap(); - std::fs::write(dir.join("tracked-dir/ancestor-ignored-file2"), "").unwrap(); - std::fs::write(dir.join("ignored-dir/ignored-file2"), "").unwrap(); - tree.flush_fs_events(cx).await; - cx.read(|cx| { - let tree = tree.read(cx); - assert!( - !tree - .entry_for_path("tracked-dir/tracked-file2") - .unwrap() - .is_ignored - ); - assert!( - tree.entry_for_path("tracked-dir/ancestor-ignored-file2") - .unwrap() - .is_ignored - ); - assert!( - tree.entry_for_path("ignored-dir/ignored-file2") - .unwrap() - .is_ignored - ); - assert!(tree.entry_for_path(".git").unwrap().is_ignored); - }); - } - - #[gpui::test] - async fn test_write_file(cx: &mut TestAppContext) { - let dir = temp_tree(json!({ - ".git": {}, - ".gitignore": "ignored-dir\n", - "tracked-dir": {}, - "ignored-dir": {} - })); - - let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - - let tree = Worktree::local( - client, - dir.path(), - true, - Arc::new(RealFs), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - tree.flush_fs_events(cx).await; - - tree.update(cx, |tree, cx| { - tree.as_local().unwrap().write_file( - Path::new("tracked-dir/file.txt"), - "hello".into(), - Default::default(), - cx, - ) - }) - .await - .unwrap(); - tree.update(cx, |tree, cx| { - tree.as_local().unwrap().write_file( - Path::new("ignored-dir/file.txt"), - "world".into(), - Default::default(), - cx, - ) - }) - .await - .unwrap(); - - tree.read_with(cx, |tree, _| { - let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap(); - let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap(); - assert!(!tracked.is_ignored); - assert!(ignored.is_ignored); - }); - } - - #[gpui::test(iterations = 30)] - async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { - let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/root", - json!({ - "b": {}, - "c": {}, - "d": {}, - }), - ) - .await; - - let tree = Worktree::local( - client, - "/root".as_ref(), - true, - fs, - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - let snapshot1 = tree.update(cx, |tree, cx| { - let tree = tree.as_local_mut().unwrap(); - let snapshot = Arc::new(Mutex::new(tree.snapshot())); - let _ = tree.observe_updates(0, cx, { - let snapshot = snapshot.clone(); - move |update| { - snapshot.lock().apply_remote_update(update).unwrap(); - async { true } - } - }); - snapshot - }); - - let entry = tree - .update(cx, |tree, cx| { - tree.as_local_mut() - .unwrap() - .create_entry("a/e".as_ref(), true, cx) - }) - .await - .unwrap(); - assert!(entry.is_dir()); - - cx.foreground().run_until_parked(); - tree.read_with(cx, |tree, _| { - assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir); - }); - - let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot()); - assert_eq!( - snapshot1.lock().entries(true).collect::>(), - snapshot2.entries(true).collect::>() - ); - } - - #[gpui::test(iterations = 100)] - async fn test_random_worktree_operations_during_initial_scan( - cx: &mut TestAppContext, - mut rng: StdRng, - ) { - let operations = env::var("OPERATIONS") - .map(|o| o.parse().unwrap()) - .unwrap_or(5); - let initial_entries = env::var("INITIAL_ENTRIES") - .map(|o| o.parse().unwrap()) - .unwrap_or(20); - - let root_dir = Path::new("/test"); - let fs = FakeFs::new(cx.background()) as Arc; - fs.as_fake().insert_tree(root_dir, json!({})).await; - for _ in 0..initial_entries { - randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; - } - log::info!("generated initial tree"); - - let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - let worktree = Worktree::local( - client.clone(), - root_dir, - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - let mut snapshots = - vec![worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot())]; - let updates = Arc::new(Mutex::new(Vec::new())); - worktree.update(cx, |tree, cx| { - check_worktree_change_events(tree, cx); - - let _ = tree.as_local_mut().unwrap().observe_updates(0, cx, { - let updates = updates.clone(); - move |update| { - updates.lock().push(update); - async { true } - } - }); - }); - - for _ in 0..operations { - worktree - .update(cx, |worktree, cx| { - randomly_mutate_worktree(worktree, &mut rng, cx) - }) - .await - .log_err(); - worktree.read_with(cx, |tree, _| { - tree.as_local().unwrap().snapshot.check_invariants() - }); - - if rng.gen_bool(0.6) { - snapshots - .push(worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot())); - } - } - - worktree - .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete()) - .await; - - cx.foreground().run_until_parked(); - - let final_snapshot = worktree.read_with(cx, |tree, _| { - let tree = tree.as_local().unwrap(); - tree.snapshot.check_invariants(); - tree.snapshot() - }); - - for (i, snapshot) in snapshots.into_iter().enumerate().rev() { - let mut updated_snapshot = snapshot.clone(); - for update in updates.lock().iter() { - if update.scan_id >= updated_snapshot.scan_id() as u64 { - updated_snapshot - .apply_remote_update(update.clone()) - .unwrap(); - } - } - - assert_eq!( - updated_snapshot.entries(true).collect::>(), - final_snapshot.entries(true).collect::>(), - "wrong updates after snapshot {i}: {snapshot:#?} {updates:#?}", - ); - } - } - - #[gpui::test(iterations = 100)] - async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) { - let operations = env::var("OPERATIONS") - .map(|o| o.parse().unwrap()) - .unwrap_or(40); - let initial_entries = env::var("INITIAL_ENTRIES") - .map(|o| o.parse().unwrap()) - .unwrap_or(20); - - let root_dir = Path::new("/test"); - let fs = FakeFs::new(cx.background()) as Arc; - fs.as_fake().insert_tree(root_dir, json!({})).await; - for _ in 0..initial_entries { - randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; - } - log::info!("generated initial tree"); - - let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - let worktree = Worktree::local( - client.clone(), - root_dir, - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - let updates = Arc::new(Mutex::new(Vec::new())); - worktree.update(cx, |tree, cx| { - check_worktree_change_events(tree, cx); - - let _ = tree.as_local_mut().unwrap().observe_updates(0, cx, { - let updates = updates.clone(); - move |update| { - updates.lock().push(update); - async { true } - } - }); - }); - - worktree - .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete()) - .await; - - fs.as_fake().pause_events(); - let mut snapshots = Vec::new(); - let mut mutations_len = operations; - while mutations_len > 1 { - if rng.gen_bool(0.2) { - worktree - .update(cx, |worktree, cx| { - randomly_mutate_worktree(worktree, &mut rng, cx) - }) - .await - .log_err(); - } else { - randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; - } - - let buffered_event_count = fs.as_fake().buffered_event_count(); - if buffered_event_count > 0 && rng.gen_bool(0.3) { - let len = rng.gen_range(0..=buffered_event_count); - log::info!("flushing {} events", len); - fs.as_fake().flush_events(len); - } else { - randomly_mutate_fs(&fs, root_dir, 0.6, &mut rng).await; - mutations_len -= 1; - } - - cx.foreground().run_until_parked(); - if rng.gen_bool(0.2) { - log::info!("storing snapshot {}", snapshots.len()); - let snapshot = - worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); - snapshots.push(snapshot); - } - } - - log::info!("quiescing"); - fs.as_fake().flush_events(usize::MAX); - cx.foreground().run_until_parked(); - let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); - snapshot.check_invariants(); - - { - let new_worktree = Worktree::local( - client.clone(), - root_dir, - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - new_worktree - .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete()) - .await; - let new_snapshot = - new_worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); - assert_eq!( - snapshot.entries_without_ids(true), - new_snapshot.entries_without_ids(true) - ); - } - - for (i, mut prev_snapshot) in snapshots.into_iter().enumerate().rev() { - for update in updates.lock().iter() { - if update.scan_id >= prev_snapshot.scan_id() as u64 { - prev_snapshot.apply_remote_update(update.clone()).unwrap(); - } - } - - assert_eq!( - prev_snapshot.entries(true).collect::>(), - snapshot.entries(true).collect::>(), - "wrong updates after snapshot {i}: {updates:#?}", - ); - } - } - - // The worktree's `UpdatedEntries` event can be used to follow along with - // all changes to the worktree's snapshot. - fn check_worktree_change_events(tree: &mut Worktree, cx: &mut ModelContext) { - let mut entries = tree.entries(true).cloned().collect::>(); - cx.subscribe(&cx.handle(), move |tree, _, event, _| { - if let Event::UpdatedEntries(changes) = event { - for (path, _, change_type) in changes.iter() { - let entry = tree.entry_for_path(&path).cloned(); - let ix = match entries.binary_search_by_key(&path, |e| &e.path) { - Ok(ix) | Err(ix) => ix, - }; - match change_type { - PathChange::Loaded => entries.insert(ix, entry.unwrap()), - PathChange::Added => entries.insert(ix, entry.unwrap()), - PathChange::Removed => drop(entries.remove(ix)), - PathChange::Updated => { - let entry = entry.unwrap(); - let existing_entry = entries.get_mut(ix).unwrap(); - assert_eq!(existing_entry.path, entry.path); - *existing_entry = entry; - } - PathChange::AddedOrUpdated => { - let entry = entry.unwrap(); - if entries.get(ix).map(|e| &e.path) == Some(&entry.path) { - *entries.get_mut(ix).unwrap() = entry; - } else { - entries.insert(ix, entry); - } - } - } - } - - let new_entries = tree.entries(true).cloned().collect::>(); - assert_eq!(entries, new_entries, "incorrect changes: {:?}", changes); - } - }) - .detach(); - } - - fn randomly_mutate_worktree( - worktree: &mut Worktree, - rng: &mut impl Rng, - cx: &mut ModelContext, - ) -> Task> { - log::info!("mutating worktree"); - let worktree = worktree.as_local_mut().unwrap(); - let snapshot = worktree.snapshot(); - let entry = snapshot.entries(false).choose(rng).unwrap(); - - match rng.gen_range(0_u32..100) { - 0..=33 if entry.path.as_ref() != Path::new("") => { - log::info!("deleting entry {:?} ({})", entry.path, entry.id.0); - worktree.delete_entry(entry.id, cx).unwrap() - } - ..=66 if entry.path.as_ref() != Path::new("") => { - let other_entry = snapshot.entries(false).choose(rng).unwrap(); - let new_parent_path = if other_entry.is_dir() { - other_entry.path.clone() - } else { - other_entry.path.parent().unwrap().into() - }; - let mut new_path = new_parent_path.join(gen_name(rng)); - if new_path.starts_with(&entry.path) { - new_path = gen_name(rng).into(); - } - - log::info!( - "renaming entry {:?} ({}) to {:?}", - entry.path, - entry.id.0, - new_path - ); - let task = worktree.rename_entry(entry.id, new_path, cx).unwrap(); - cx.foreground().spawn(async move { - task.await?; - Ok(()) - }) - } - _ => { - let task = if entry.is_dir() { - let child_path = entry.path.join(gen_name(rng)); - let is_dir = rng.gen_bool(0.3); - log::info!( - "creating {} at {:?}", - if is_dir { "dir" } else { "file" }, - child_path, - ); - worktree.create_entry(child_path, is_dir, cx) - } else { - log::info!("overwriting file {:?} ({})", entry.path, entry.id.0); - worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx) - }; - cx.foreground().spawn(async move { - task.await?; - Ok(()) - }) - } - } - } - - async fn randomly_mutate_fs( - fs: &Arc, - root_path: &Path, - insertion_probability: f64, - rng: &mut impl Rng, - ) { - log::info!("mutating fs"); - let mut files = Vec::new(); - let mut dirs = Vec::new(); - for path in fs.as_fake().paths(false) { - if path.starts_with(root_path) { - if fs.is_file(&path).await { - files.push(path); - } else { - dirs.push(path); - } - } - } - - if (files.is_empty() && dirs.len() == 1) || rng.gen_bool(insertion_probability) { - let path = dirs.choose(rng).unwrap(); - let new_path = path.join(gen_name(rng)); - - if rng.gen() { - log::info!( - "creating dir {:?}", - new_path.strip_prefix(root_path).unwrap() - ); - fs.create_dir(&new_path).await.unwrap(); - } else { - log::info!( - "creating file {:?}", - new_path.strip_prefix(root_path).unwrap() - ); - fs.create_file(&new_path, Default::default()).await.unwrap(); - } - } else if rng.gen_bool(0.05) { - let ignore_dir_path = dirs.choose(rng).unwrap(); - let ignore_path = ignore_dir_path.join(&*GITIGNORE); - - let subdirs = dirs - .iter() - .filter(|d| d.starts_with(&ignore_dir_path)) - .cloned() - .collect::>(); - let subfiles = files - .iter() - .filter(|d| d.starts_with(&ignore_dir_path)) - .cloned() - .collect::>(); - let files_to_ignore = { - let len = rng.gen_range(0..=subfiles.len()); - subfiles.choose_multiple(rng, len) - }; - let dirs_to_ignore = { - let len = rng.gen_range(0..subdirs.len()); - subdirs.choose_multiple(rng, len) - }; - - let mut ignore_contents = String::new(); - for path_to_ignore in files_to_ignore.chain(dirs_to_ignore) { - writeln!( - ignore_contents, - "{}", - path_to_ignore - .strip_prefix(&ignore_dir_path) - .unwrap() - .to_str() - .unwrap() - ) - .unwrap(); - } - log::info!( - "creating gitignore {:?} with contents:\n{}", - ignore_path.strip_prefix(&root_path).unwrap(), - ignore_contents - ); - fs.save( - &ignore_path, - &ignore_contents.as_str().into(), - Default::default(), - ) - .await - .unwrap(); - } else { - let old_path = { - let file_path = files.choose(rng); - let dir_path = dirs[1..].choose(rng); - file_path.into_iter().chain(dir_path).choose(rng).unwrap() - }; - - let is_rename = rng.gen(); - if is_rename { - let new_path_parent = dirs - .iter() - .filter(|d| !d.starts_with(old_path)) - .choose(rng) - .unwrap(); - - let overwrite_existing_dir = - !old_path.starts_with(&new_path_parent) && rng.gen_bool(0.3); - let new_path = if overwrite_existing_dir { - fs.remove_dir( - &new_path_parent, - RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) - .await - .unwrap(); - new_path_parent.to_path_buf() - } else { - new_path_parent.join(gen_name(rng)) - }; - - log::info!( - "renaming {:?} to {}{:?}", - old_path.strip_prefix(&root_path).unwrap(), - if overwrite_existing_dir { - "overwrite " - } else { - "" - }, - new_path.strip_prefix(&root_path).unwrap() - ); - fs.rename( - &old_path, - &new_path, - fs::RenameOptions { - overwrite: true, - ignore_if_exists: true, - }, - ) - .await - .unwrap(); - } else if fs.is_file(&old_path).await { - log::info!( - "deleting file {:?}", - old_path.strip_prefix(&root_path).unwrap() - ); - fs.remove_file(old_path, Default::default()).await.unwrap(); - } else { - log::info!( - "deleting dir {:?}", - old_path.strip_prefix(&root_path).unwrap() - ); - fs.remove_dir( - &old_path, - RemoveOptions { - recursive: true, - ignore_if_not_exists: true, - }, - ) - .await - .unwrap(); - } - } - } - - fn gen_name(rng: &mut impl Rng) -> String { - (0..6) - .map(|_| rng.sample(rand::distributions::Alphanumeric)) - .map(char::from) - .collect() - } - - impl LocalSnapshot { - fn check_invariants(&self) { - assert_eq!( - self.entries_by_path - .cursor::<()>() - .map(|e| (&e.path, e.id)) - .collect::>(), - self.entries_by_id - .cursor::<()>() - .map(|e| (&e.path, e.id)) - .collect::>() - .into_iter() - .collect::>(), - "entries_by_path and entries_by_id are inconsistent" - ); - - let mut files = self.files(true, 0); - let mut visible_files = self.files(false, 0); - for entry in self.entries_by_path.cursor::<()>() { - if entry.is_file() { - assert_eq!(files.next().unwrap().inode, entry.inode); - if !entry.is_ignored { - assert_eq!(visible_files.next().unwrap().inode, entry.inode); - } - } - } - - assert!(files.next().is_none()); - assert!(visible_files.next().is_none()); - - let mut bfs_paths = Vec::new(); - let mut stack = vec![Path::new("")]; - while let Some(path) = stack.pop() { - bfs_paths.push(path); - let ix = stack.len(); - for child_entry in self.child_entries(path) { - stack.insert(ix, &child_entry.path); - } - } - - let dfs_paths_via_iter = self - .entries_by_path - .cursor::<()>() - .map(|e| e.path.as_ref()) - .collect::>(); - assert_eq!(bfs_paths, dfs_paths_via_iter); - - let dfs_paths_via_traversal = self - .entries(true) - .map(|e| e.path.as_ref()) - .collect::>(); - assert_eq!(dfs_paths_via_traversal, dfs_paths_via_iter); - - for ignore_parent_abs_path in self.ignores_by_parent_abs_path.keys() { - let ignore_parent_path = - ignore_parent_abs_path.strip_prefix(&self.abs_path).unwrap(); - assert!(self.entry_for_path(&ignore_parent_path).is_some()); - assert!(self - .entry_for_path(ignore_parent_path.join(&*GITIGNORE)) - .is_some()); - } - } - - fn entries_without_ids(&self, include_ignored: bool) -> Vec<(&Path, u64, bool)> { - let mut paths = Vec::new(); - for entry in self.entries_by_path.cursor::<()>() { - if include_ignored || !entry.is_ignored { - paths.push((entry.path.as_ref(), entry.inode, entry.is_ignored)); - } - } - paths.sort_by(|a, b| a.0.cmp(b.0)); - paths - } - } - - mod git_tests { - use super::*; - use pretty_assertions::assert_eq; - - #[gpui::test] - async fn test_rename_work_directory(cx: &mut TestAppContext) { - let root = temp_tree(json!({ - "projects": { - "project1": { - "a": "", - "b": "", - } - }, - - })); - let root_path = root.path(); - - let http_client = FakeHttpClient::with_404_response(); - let client = cx.read(|cx| Client::new(http_client, cx)); - let tree = Worktree::local( - client, - root_path, - true, - Arc::new(RealFs), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - let repo = git_init(&root_path.join("projects/project1")); - git_add("a", &repo); - git_commit("init", &repo); - std::fs::write(root_path.join("projects/project1/a"), "aa").ok(); - - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - - tree.flush_fs_events(cx).await; - - cx.read(|cx| { - let tree = tree.read(cx); - let (work_dir, _) = tree.repositories().next().unwrap(); - assert_eq!(work_dir.as_ref(), Path::new("projects/project1")); - assert_eq!( - tree.status_for_file(Path::new("projects/project1/a")), - Some(GitFileStatus::Modified) - ); - assert_eq!( - tree.status_for_file(Path::new("projects/project1/b")), - Some(GitFileStatus::Added) - ); - }); - - std::fs::rename( - root_path.join("projects/project1"), - root_path.join("projects/project2"), - ) - .ok(); - tree.flush_fs_events(cx).await; - - cx.read(|cx| { - let tree = tree.read(cx); - let (work_dir, _) = tree.repositories().next().unwrap(); - assert_eq!(work_dir.as_ref(), Path::new("projects/project2")); - assert_eq!( - tree.status_for_file(Path::new("projects/project2/a")), - Some(GitFileStatus::Modified) - ); - assert_eq!( - tree.status_for_file(Path::new("projects/project2/b")), - Some(GitFileStatus::Added) - ); - }); - } - - #[gpui::test] - async fn test_git_repository_for_path(cx: &mut TestAppContext) { - let root = temp_tree(json!({ - "c.txt": "", - "dir1": { - ".git": {}, - "deps": { - "dep1": { - ".git": {}, - "src": { - "a.txt": "" - } - } - }, - "src": { - "b.txt": "" - } - }, - })); - - let http_client = FakeHttpClient::with_404_response(); - let client = cx.read(|cx| Client::new(http_client, cx)); - let tree = Worktree::local( - client, - root.path(), - true, - Arc::new(RealFs), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - tree.flush_fs_events(cx).await; - - tree.read_with(cx, |tree, _cx| { - let tree = tree.as_local().unwrap(); - - assert!(tree.repository_for_path("c.txt".as_ref()).is_none()); - - let entry = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap(); - assert_eq!( - entry - .work_directory(tree) - .map(|directory| directory.as_ref().to_owned()), - Some(Path::new("dir1").to_owned()) - ); - - let entry = tree - .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref()) - .unwrap(); - assert_eq!( - entry - .work_directory(tree) - .map(|directory| directory.as_ref().to_owned()), - Some(Path::new("dir1/deps/dep1").to_owned()) - ); - - let entries = tree.files(false, 0); - - let paths_with_repos = tree - .entries_with_repositories(entries) - .map(|(entry, repo)| { - ( - entry.path.as_ref(), - repo.and_then(|repo| { - repo.work_directory(&tree) - .map(|work_directory| work_directory.0.to_path_buf()) - }), - ) - }) - .collect::>(); - - assert_eq!( - paths_with_repos, - &[ - (Path::new("c.txt"), None), - ( - Path::new("dir1/deps/dep1/src/a.txt"), - Some(Path::new("dir1/deps/dep1").into()) - ), - (Path::new("dir1/src/b.txt"), Some(Path::new("dir1").into())), - ] - ); - }); - - let repo_update_events = Arc::new(Mutex::new(vec![])); - tree.update(cx, |_, cx| { - let repo_update_events = repo_update_events.clone(); - cx.subscribe(&tree, move |_, _, event, _| { - if let Event::UpdatedGitRepositories(update) = event { - repo_update_events.lock().push(update.clone()); - } - }) - .detach(); - }); - - std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap(); - tree.flush_fs_events(cx).await; - - assert_eq!( - repo_update_events.lock()[0] - .iter() - .map(|e| e.0.clone()) - .collect::>>(), - vec![Path::new("dir1").into()] - ); - - std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap(); - tree.flush_fs_events(cx).await; - - tree.read_with(cx, |tree, _cx| { - let tree = tree.as_local().unwrap(); - - assert!(tree - .repository_for_path("dir1/src/b.txt".as_ref()) - .is_none()); - }); - } - - #[gpui::test] - async fn test_git_status(deterministic: Arc, cx: &mut TestAppContext) { - const IGNORE_RULE: &'static str = "**/target"; - - let root = temp_tree(json!({ - "project": { - "a.txt": "a", - "b.txt": "bb", - "c": { - "d": { - "e.txt": "eee" - } - }, - "f.txt": "ffff", - "target": { - "build_file": "???" - }, - ".gitignore": IGNORE_RULE - }, - - })); - - let http_client = FakeHttpClient::with_404_response(); - let client = cx.read(|cx| Client::new(http_client, cx)); - let tree = Worktree::local( - client, - root.path(), - true, - Arc::new(RealFs), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - - const A_TXT: &'static str = "a.txt"; - const B_TXT: &'static str = "b.txt"; - const E_TXT: &'static str = "c/d/e.txt"; - const F_TXT: &'static str = "f.txt"; - const DOTGITIGNORE: &'static str = ".gitignore"; - const BUILD_FILE: &'static str = "target/build_file"; - let project_path: &Path = &Path::new("project"); - - let work_dir = root.path().join("project"); - let mut repo = git_init(work_dir.as_path()); - repo.add_ignore_rule(IGNORE_RULE).unwrap(); - git_add(Path::new(A_TXT), &repo); - git_add(Path::new(E_TXT), &repo); - git_add(Path::new(DOTGITIGNORE), &repo); - git_commit("Initial commit", &repo); - - tree.flush_fs_events(cx).await; - deterministic.run_until_parked(); - - // Check that the right git state is observed on startup - tree.read_with(cx, |tree, _cx| { - let snapshot = tree.snapshot(); - assert_eq!(snapshot.repository_entries.iter().count(), 1); - let (dir, _) = snapshot.repository_entries.iter().next().unwrap(); - assert_eq!(dir.0.as_ref(), Path::new("project")); - - assert_eq!( - snapshot.status_for_file(project_path.join(B_TXT)), - Some(GitFileStatus::Added) - ); - assert_eq!( - snapshot.status_for_file(project_path.join(F_TXT)), - Some(GitFileStatus::Added) - ); - }); - - std::fs::write(work_dir.join(A_TXT), "aa").unwrap(); - - tree.flush_fs_events(cx).await; - deterministic.run_until_parked(); - - tree.read_with(cx, |tree, _cx| { - let snapshot = tree.snapshot(); - - assert_eq!( - snapshot.status_for_file(project_path.join(A_TXT)), - Some(GitFileStatus::Modified) - ); - }); - - git_add(Path::new(A_TXT), &repo); - git_add(Path::new(B_TXT), &repo); - git_commit("Committing modified and added", &repo); - tree.flush_fs_events(cx).await; - deterministic.run_until_parked(); - - // Check that repo only changes are tracked - tree.read_with(cx, |tree, _cx| { - let snapshot = tree.snapshot(); - - assert_eq!( - snapshot.status_for_file(project_path.join(F_TXT)), - Some(GitFileStatus::Added) - ); - - assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None); - assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None); - }); - - git_reset(0, &repo); - git_remove_index(Path::new(B_TXT), &repo); - git_stash(&mut repo); - std::fs::write(work_dir.join(E_TXT), "eeee").unwrap(); - std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap(); - tree.flush_fs_events(cx).await; - deterministic.run_until_parked(); - - // Check that more complex repo changes are tracked - tree.read_with(cx, |tree, _cx| { - let snapshot = tree.snapshot(); - - assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None); - assert_eq!( - snapshot.status_for_file(project_path.join(B_TXT)), - Some(GitFileStatus::Added) - ); - assert_eq!( - snapshot.status_for_file(project_path.join(E_TXT)), - Some(GitFileStatus::Modified) - ); - }); - - std::fs::remove_file(work_dir.join(B_TXT)).unwrap(); - std::fs::remove_dir_all(work_dir.join("c")).unwrap(); - std::fs::write( - work_dir.join(DOTGITIGNORE), - [IGNORE_RULE, "f.txt"].join("\n"), - ) - .unwrap(); - - git_add(Path::new(DOTGITIGNORE), &repo); - git_commit("Committing modified git ignore", &repo); - - tree.flush_fs_events(cx).await; - deterministic.run_until_parked(); - - let mut renamed_dir_name = "first_directory/second_directory"; - const RENAMED_FILE: &'static str = "rf.txt"; - - std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap(); - std::fs::write( - work_dir.join(renamed_dir_name).join(RENAMED_FILE), - "new-contents", - ) - .unwrap(); - - tree.flush_fs_events(cx).await; - deterministic.run_until_parked(); - - tree.read_with(cx, |tree, _cx| { - let snapshot = tree.snapshot(); - assert_eq!( - snapshot - .status_for_file(&project_path.join(renamed_dir_name).join(RENAMED_FILE)), - Some(GitFileStatus::Added) - ); - }); - - renamed_dir_name = "new_first_directory/second_directory"; - - std::fs::rename( - work_dir.join("first_directory"), - work_dir.join("new_first_directory"), - ) - .unwrap(); - - tree.flush_fs_events(cx).await; - deterministic.run_until_parked(); - - tree.read_with(cx, |tree, _cx| { - let snapshot = tree.snapshot(); - - assert_eq!( - snapshot.status_for_file( - project_path - .join(Path::new(renamed_dir_name)) - .join(RENAMED_FILE) - ), - Some(GitFileStatus::Added) - ); - }); - } - - #[gpui::test] - async fn test_propagate_git_statuses(cx: &mut TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/root", - json!({ - ".git": {}, - "a": { - "b": { - "c1.txt": "", - "c2.txt": "", - }, - "d": { - "e1.txt": "", - "e2.txt": "", - "e3.txt": "", - } - }, - "f": { - "no-status.txt": "" - }, - "g": { - "h1.txt": "", - "h2.txt": "" - }, - - }), - ) - .await; - - fs.set_status_for_repo_via_git_operation( - &Path::new("/root/.git"), - &[ - (Path::new("a/b/c1.txt"), GitFileStatus::Added), - (Path::new("a/d/e2.txt"), GitFileStatus::Modified), - (Path::new("g/h2.txt"), GitFileStatus::Conflict), - ], - ); - - let http_client = FakeHttpClient::with_404_response(); - let client = cx.read(|cx| Client::new(http_client, cx)); - let tree = Worktree::local( - client, - Path::new("/root"), - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - - cx.foreground().run_until_parked(); - let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); - - check_propagated_statuses( - &snapshot, - &[ - (Path::new(""), Some(GitFileStatus::Conflict)), - (Path::new("a"), Some(GitFileStatus::Modified)), - (Path::new("a/b"), Some(GitFileStatus::Added)), - (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), - (Path::new("a/b/c2.txt"), None), - (Path::new("a/d"), Some(GitFileStatus::Modified)), - (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), - (Path::new("f"), None), - (Path::new("f/no-status.txt"), None), - (Path::new("g"), Some(GitFileStatus::Conflict)), - (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)), - ], - ); - - check_propagated_statuses( - &snapshot, - &[ - (Path::new("a/b"), Some(GitFileStatus::Added)), - (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), - (Path::new("a/b/c2.txt"), None), - (Path::new("a/d"), Some(GitFileStatus::Modified)), - (Path::new("a/d/e1.txt"), None), - (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), - (Path::new("f"), None), - (Path::new("f/no-status.txt"), None), - (Path::new("g"), Some(GitFileStatus::Conflict)), - ], - ); - - check_propagated_statuses( - &snapshot, - &[ - (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), - (Path::new("a/b/c2.txt"), None), - (Path::new("a/d/e1.txt"), None), - (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), - (Path::new("f/no-status.txt"), None), - ], - ); - - #[track_caller] - fn check_propagated_statuses( - snapshot: &Snapshot, - expected_statuses: &[(&Path, Option)], - ) { - let mut entries = expected_statuses - .iter() - .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone()) - .collect::>(); - snapshot.propagate_git_statuses(&mut entries); - assert_eq!( - entries - .iter() - .map(|e| (e.path.as_ref(), e.git_status)) - .collect::>(), - expected_statuses - ); - } - } - - #[track_caller] - fn git_init(path: &Path) -> git2::Repository { - git2::Repository::init(path).expect("Failed to initialize git repository") - } - - #[track_caller] - fn git_add>(path: P, repo: &git2::Repository) { - let path = path.as_ref(); - let mut index = repo.index().expect("Failed to get index"); - index.add_path(path).expect("Failed to add a.txt"); - index.write().expect("Failed to write index"); - } - - #[track_caller] - fn git_remove_index(path: &Path, repo: &git2::Repository) { - let mut index = repo.index().expect("Failed to get index"); - index.remove_path(path).expect("Failed to add a.txt"); - index.write().expect("Failed to write index"); - } - - #[track_caller] - fn git_commit(msg: &'static str, repo: &git2::Repository) { - use git2::Signature; - - let signature = Signature::now("test", "test@zed.dev").unwrap(); - let oid = repo.index().unwrap().write_tree().unwrap(); - let tree = repo.find_tree(oid).unwrap(); - if let Some(head) = repo.head().ok() { - let parent_obj = head.peel(git2::ObjectType::Commit).unwrap(); - - let parent_commit = parent_obj.as_commit().unwrap(); - - repo.commit( - Some("HEAD"), - &signature, - &signature, - msg, - &tree, - &[parent_commit], - ) - .expect("Failed to commit with parent"); - } else { - repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[]) - .expect("Failed to commit"); - } - } - - #[track_caller] - fn git_stash(repo: &mut git2::Repository) { - use git2::Signature; - - let signature = Signature::now("test", "test@zed.dev").unwrap(); - repo.stash_save(&signature, "N/A", None) - .expect("Failed to stash"); - } - - #[track_caller] - fn git_reset(offset: usize, repo: &git2::Repository) { - let head = repo.head().expect("Couldn't get repo head"); - let object = head.peel(git2::ObjectType::Commit).unwrap(); - let commit = object.as_commit().unwrap(); - let new_head = commit - .parents() - .inspect(|parnet| { - parnet.message(); - }) - .skip(offset) - .next() - .expect("Not enough history"); - repo.reset(&new_head.as_object(), git2::ResetType::Soft, None) - .expect("Could not reset"); - } - - #[allow(dead_code)] - #[track_caller] - fn git_status(repo: &git2::Repository) -> HashMap { - repo.statuses(None) - .unwrap() - .iter() - .map(|status| (status.path().unwrap().to_string(), status.status())) - .collect() - } - } -} diff --git a/crates/project/src/worktree_tests.rs b/crates/project/src/worktree_tests.rs new file mode 100644 index 0000000000..3abf660282 --- /dev/null +++ b/crates/project/src/worktree_tests.rs @@ -0,0 +1,1523 @@ +use crate::{ + worktree::{Event, Snapshot, WorktreeHandle}, + EntryKind, PathChange, Worktree, +}; +use anyhow::Result; +use client::Client; +use fs::{repository::GitFileStatus, FakeFs, Fs, RealFs, RemoveOptions}; +use git::GITIGNORE; +use gpui::{executor::Deterministic, ModelContext, Task, TestAppContext}; +use parking_lot::Mutex; +use pretty_assertions::assert_eq; +use rand::prelude::*; +use serde_json::json; +use std::{ + env, + fmt::Write, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{http::FakeHttpClient, test::temp_tree, ResultExt}; + +#[gpui::test] +async fn test_traversal(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "a/b\n", + "a": { + "b": "", + "c": "", + } + }), + ) + .await; + + let http_client = FakeHttpClient::with_404_response(); + let client = cx.read(|cx| Client::new(http_client, cx)); + + let tree = Worktree::local( + client, + Path::new("/root"), + true, + fs, + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(false) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + Path::new(""), + Path::new(".gitignore"), + Path::new("a"), + Path::new("a/c"), + ] + ); + assert_eq!( + tree.entries(true) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + Path::new(""), + Path::new(".gitignore"), + Path::new("a"), + Path::new("a/b"), + Path::new("a/c"), + ] + ); + }) +} + +#[gpui::test] +async fn test_descendent_entries(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root", + json!({ + "a": "", + "b": { + "c": { + "d": "" + }, + "e": {} + }, + "f": "", + "g": { + "h": {} + }, + "i": { + "j": { + "k": "" + }, + "l": { + + } + }, + ".gitignore": "i/j\n", + }), + ) + .await; + + let http_client = FakeHttpClient::with_404_response(); + let client = cx.read(|cx| Client::new(http_client, cx)); + + let tree = Worktree::local( + client, + Path::new("/root"), + true, + fs, + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.descendent_entries(false, false, Path::new("b")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![Path::new("b/c/d"),] + ); + assert_eq!( + tree.descendent_entries(true, false, Path::new("b")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + Path::new("b"), + Path::new("b/c"), + Path::new("b/c/d"), + Path::new("b/e"), + ] + ); + + assert_eq!( + tree.descendent_entries(false, false, Path::new("g")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + Vec::::new() + ); + assert_eq!( + tree.descendent_entries(true, false, Path::new("g")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![Path::new("g"), Path::new("g/h"),] + ); + + assert_eq!( + tree.descendent_entries(false, false, Path::new("i")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + Vec::::new() + ); + assert_eq!( + tree.descendent_entries(false, true, Path::new("i")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![Path::new("i/j/k")] + ); + assert_eq!( + tree.descendent_entries(true, false, Path::new("i")) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![Path::new("i"), Path::new("i/l"),] + ); + }) +} + +#[gpui::test(iterations = 10)] +async fn test_circular_symlinks(executor: Arc, cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root", + json!({ + "lib": { + "a": { + "a.txt": "" + }, + "b": { + "b.txt": "" + } + } + }), + ) + .await; + fs.insert_symlink("/root/lib/a/lib", "..".into()).await; + fs.insert_symlink("/root/lib/b/lib", "..".into()).await; + + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + let tree = Worktree::local( + client, + Path::new("/root"), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(false) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + Path::new(""), + Path::new("lib"), + Path::new("lib/a"), + Path::new("lib/a/a.txt"), + Path::new("lib/a/lib"), + Path::new("lib/b"), + Path::new("lib/b/b.txt"), + Path::new("lib/b/lib"), + ] + ); + }); + + fs.rename( + Path::new("/root/lib/a/lib"), + Path::new("/root/lib/a/lib-2"), + Default::default(), + ) + .await + .unwrap(); + executor.run_until_parked(); + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(false) + .map(|entry| entry.path.as_ref()) + .collect::>(), + vec![ + Path::new(""), + Path::new("lib"), + Path::new("lib/a"), + Path::new("lib/a/a.txt"), + Path::new("lib/a/lib-2"), + Path::new("lib/b"), + Path::new("lib/b/b.txt"), + Path::new("lib/b/lib"), + ] + ); + }); +} + +#[gpui::test] +async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { + // .gitignores are handled explicitly by Zed and do not use the git + // machinery that the git_tests module checks + let parent_dir = temp_tree(json!({ + ".gitignore": "ancestor-ignored-file1\nancestor-ignored-file2\n", + "tree": { + ".git": {}, + ".gitignore": "ignored-dir\n", + "tracked-dir": { + "tracked-file1": "", + "ancestor-ignored-file1": "", + }, + "ignored-dir": { + "ignored-file1": "" + } + } + })); + let dir = parent_dir.path().join("tree"); + + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + + let tree = Worktree::local( + client, + dir.as_path(), + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(cx).await; + cx.read(|cx| { + let tree = tree.read(cx); + assert!( + !tree + .entry_for_path("tracked-dir/tracked-file1") + .unwrap() + .is_ignored + ); + assert!( + tree.entry_for_path("tracked-dir/ancestor-ignored-file1") + .unwrap() + .is_ignored + ); + assert!( + tree.entry_for_path("ignored-dir/ignored-file1") + .unwrap() + .is_ignored + ); + }); + + std::fs::write(dir.join("tracked-dir/tracked-file2"), "").unwrap(); + std::fs::write(dir.join("tracked-dir/ancestor-ignored-file2"), "").unwrap(); + std::fs::write(dir.join("ignored-dir/ignored-file2"), "").unwrap(); + tree.flush_fs_events(cx).await; + cx.read(|cx| { + let tree = tree.read(cx); + assert!( + !tree + .entry_for_path("tracked-dir/tracked-file2") + .unwrap() + .is_ignored + ); + assert!( + tree.entry_for_path("tracked-dir/ancestor-ignored-file2") + .unwrap() + .is_ignored + ); + assert!( + tree.entry_for_path("ignored-dir/ignored-file2") + .unwrap() + .is_ignored + ); + assert!(tree.entry_for_path(".git").unwrap().is_ignored); + }); +} + +#[gpui::test] +async fn test_write_file(cx: &mut TestAppContext) { + let dir = temp_tree(json!({ + ".git": {}, + ".gitignore": "ignored-dir\n", + "tracked-dir": {}, + "ignored-dir": {} + })); + + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + + let tree = Worktree::local( + client, + dir.path(), + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(cx).await; + + tree.update(cx, |tree, cx| { + tree.as_local().unwrap().write_file( + Path::new("tracked-dir/file.txt"), + "hello".into(), + Default::default(), + cx, + ) + }) + .await + .unwrap(); + tree.update(cx, |tree, cx| { + tree.as_local().unwrap().write_file( + Path::new("ignored-dir/file.txt"), + "world".into(), + Default::default(), + cx, + ) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, _| { + let tracked = tree.entry_for_path("tracked-dir/file.txt").unwrap(); + let ignored = tree.entry_for_path("ignored-dir/file.txt").unwrap(); + assert!(!tracked.is_ignored); + assert!(ignored.is_ignored); + }); +} + +#[gpui::test(iterations = 30)] +async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root", + json!({ + "b": {}, + "c": {}, + "d": {}, + }), + ) + .await; + + let tree = Worktree::local( + client, + "/root".as_ref(), + true, + fs, + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let snapshot1 = tree.update(cx, |tree, cx| { + let tree = tree.as_local_mut().unwrap(); + let snapshot = Arc::new(Mutex::new(tree.snapshot())); + let _ = tree.observe_updates(0, cx, { + let snapshot = snapshot.clone(); + move |update| { + snapshot.lock().apply_remote_update(update).unwrap(); + async { true } + } + }); + snapshot + }); + + let entry = tree + .update(cx, |tree, cx| { + tree.as_local_mut() + .unwrap() + .create_entry("a/e".as_ref(), true, cx) + }) + .await + .unwrap(); + assert!(entry.is_dir()); + + cx.foreground().run_until_parked(); + tree.read_with(cx, |tree, _| { + assert_eq!(tree.entry_for_path("a/e").unwrap().kind, EntryKind::Dir); + }); + + let snapshot2 = tree.update(cx, |tree, _| tree.as_local().unwrap().snapshot()); + assert_eq!( + snapshot1.lock().entries(true).collect::>(), + snapshot2.entries(true).collect::>() + ); +} + +#[gpui::test(iterations = 100)] +async fn test_random_worktree_operations_during_initial_scan( + cx: &mut TestAppContext, + mut rng: StdRng, +) { + let operations = env::var("OPERATIONS") + .map(|o| o.parse().unwrap()) + .unwrap_or(5); + let initial_entries = env::var("INITIAL_ENTRIES") + .map(|o| o.parse().unwrap()) + .unwrap_or(20); + + let root_dir = Path::new("/test"); + let fs = FakeFs::new(cx.background()) as Arc; + fs.as_fake().insert_tree(root_dir, json!({})).await; + for _ in 0..initial_entries { + randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; + } + log::info!("generated initial tree"); + + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + let worktree = Worktree::local( + client.clone(), + root_dir, + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let mut snapshots = vec![worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot())]; + let updates = Arc::new(Mutex::new(Vec::new())); + worktree.update(cx, |tree, cx| { + check_worktree_change_events(tree, cx); + + let _ = tree.as_local_mut().unwrap().observe_updates(0, cx, { + let updates = updates.clone(); + move |update| { + updates.lock().push(update); + async { true } + } + }); + }); + + for _ in 0..operations { + worktree + .update(cx, |worktree, cx| { + randomly_mutate_worktree(worktree, &mut rng, cx) + }) + .await + .log_err(); + worktree.read_with(cx, |tree, _| { + tree.as_local().unwrap().snapshot().check_invariants() + }); + + if rng.gen_bool(0.6) { + snapshots.push(worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot())); + } + } + + worktree + .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete()) + .await; + + cx.foreground().run_until_parked(); + + let final_snapshot = worktree.read_with(cx, |tree, _| { + let tree = tree.as_local().unwrap(); + let snapshot = tree.snapshot(); + snapshot.check_invariants(); + snapshot + }); + + for (i, snapshot) in snapshots.into_iter().enumerate().rev() { + let mut updated_snapshot = snapshot.clone(); + for update in updates.lock().iter() { + if update.scan_id >= updated_snapshot.scan_id() as u64 { + updated_snapshot + .apply_remote_update(update.clone()) + .unwrap(); + } + } + + assert_eq!( + updated_snapshot.entries(true).collect::>(), + final_snapshot.entries(true).collect::>(), + "wrong updates after snapshot {i}: {snapshot:#?} {updates:#?}", + ); + } +} + +#[gpui::test(iterations = 100)] +async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) { + let operations = env::var("OPERATIONS") + .map(|o| o.parse().unwrap()) + .unwrap_or(40); + let initial_entries = env::var("INITIAL_ENTRIES") + .map(|o| o.parse().unwrap()) + .unwrap_or(20); + + let root_dir = Path::new("/test"); + let fs = FakeFs::new(cx.background()) as Arc; + fs.as_fake().insert_tree(root_dir, json!({})).await; + for _ in 0..initial_entries { + randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; + } + log::info!("generated initial tree"); + + let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + let worktree = Worktree::local( + client.clone(), + root_dir, + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let updates = Arc::new(Mutex::new(Vec::new())); + worktree.update(cx, |tree, cx| { + check_worktree_change_events(tree, cx); + + let _ = tree.as_local_mut().unwrap().observe_updates(0, cx, { + let updates = updates.clone(); + move |update| { + updates.lock().push(update); + async { true } + } + }); + }); + + worktree + .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete()) + .await; + + fs.as_fake().pause_events(); + let mut snapshots = Vec::new(); + let mut mutations_len = operations; + while mutations_len > 1 { + if rng.gen_bool(0.2) { + worktree + .update(cx, |worktree, cx| { + randomly_mutate_worktree(worktree, &mut rng, cx) + }) + .await + .log_err(); + } else { + randomly_mutate_fs(&fs, root_dir, 1.0, &mut rng).await; + } + + let buffered_event_count = fs.as_fake().buffered_event_count(); + if buffered_event_count > 0 && rng.gen_bool(0.3) { + let len = rng.gen_range(0..=buffered_event_count); + log::info!("flushing {} events", len); + fs.as_fake().flush_events(len); + } else { + randomly_mutate_fs(&fs, root_dir, 0.6, &mut rng).await; + mutations_len -= 1; + } + + cx.foreground().run_until_parked(); + if rng.gen_bool(0.2) { + log::info!("storing snapshot {}", snapshots.len()); + let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); + snapshots.push(snapshot); + } + } + + log::info!("quiescing"); + fs.as_fake().flush_events(usize::MAX); + cx.foreground().run_until_parked(); + let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); + snapshot.check_invariants(); + + { + let new_worktree = Worktree::local( + client.clone(), + root_dir, + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + new_worktree + .update(cx, |tree, _| tree.as_local_mut().unwrap().scan_complete()) + .await; + let new_snapshot = + new_worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); + assert_eq!( + snapshot.entries_without_ids(true), + new_snapshot.entries_without_ids(true) + ); + } + + for (i, mut prev_snapshot) in snapshots.into_iter().enumerate().rev() { + for update in updates.lock().iter() { + if update.scan_id >= prev_snapshot.scan_id() as u64 { + prev_snapshot.apply_remote_update(update.clone()).unwrap(); + } + } + + assert_eq!( + prev_snapshot.entries(true).collect::>(), + snapshot.entries(true).collect::>(), + "wrong updates after snapshot {i}: {updates:#?}", + ); + } +} + +// The worktree's `UpdatedEntries` event can be used to follow along with +// all changes to the worktree's snapshot. +fn check_worktree_change_events(tree: &mut Worktree, cx: &mut ModelContext) { + let mut entries = tree.entries(true).cloned().collect::>(); + cx.subscribe(&cx.handle(), move |tree, _, event, _| { + if let Event::UpdatedEntries(changes) = event { + for (path, _, change_type) in changes.iter() { + let entry = tree.entry_for_path(&path).cloned(); + let ix = match entries.binary_search_by_key(&path, |e| &e.path) { + Ok(ix) | Err(ix) => ix, + }; + match change_type { + PathChange::Loaded => entries.insert(ix, entry.unwrap()), + PathChange::Added => entries.insert(ix, entry.unwrap()), + PathChange::Removed => drop(entries.remove(ix)), + PathChange::Updated => { + let entry = entry.unwrap(); + let existing_entry = entries.get_mut(ix).unwrap(); + assert_eq!(existing_entry.path, entry.path); + *existing_entry = entry; + } + PathChange::AddedOrUpdated => { + let entry = entry.unwrap(); + if entries.get(ix).map(|e| &e.path) == Some(&entry.path) { + *entries.get_mut(ix).unwrap() = entry; + } else { + entries.insert(ix, entry); + } + } + } + } + + let new_entries = tree.entries(true).cloned().collect::>(); + assert_eq!(entries, new_entries, "incorrect changes: {:?}", changes); + } + }) + .detach(); +} + +fn randomly_mutate_worktree( + worktree: &mut Worktree, + rng: &mut impl Rng, + cx: &mut ModelContext, +) -> Task> { + log::info!("mutating worktree"); + let worktree = worktree.as_local_mut().unwrap(); + let snapshot = worktree.snapshot(); + let entry = snapshot.entries(false).choose(rng).unwrap(); + + match rng.gen_range(0_u32..100) { + 0..=33 if entry.path.as_ref() != Path::new("") => { + log::info!("deleting entry {:?} ({})", entry.path, entry.id.0); + worktree.delete_entry(entry.id, cx).unwrap() + } + ..=66 if entry.path.as_ref() != Path::new("") => { + let other_entry = snapshot.entries(false).choose(rng).unwrap(); + let new_parent_path = if other_entry.is_dir() { + other_entry.path.clone() + } else { + other_entry.path.parent().unwrap().into() + }; + let mut new_path = new_parent_path.join(random_filename(rng)); + if new_path.starts_with(&entry.path) { + new_path = random_filename(rng).into(); + } + + log::info!( + "renaming entry {:?} ({}) to {:?}", + entry.path, + entry.id.0, + new_path + ); + let task = worktree.rename_entry(entry.id, new_path, cx).unwrap(); + cx.foreground().spawn(async move { + task.await?; + Ok(()) + }) + } + _ => { + let task = if entry.is_dir() { + let child_path = entry.path.join(random_filename(rng)); + let is_dir = rng.gen_bool(0.3); + log::info!( + "creating {} at {:?}", + if is_dir { "dir" } else { "file" }, + child_path, + ); + worktree.create_entry(child_path, is_dir, cx) + } else { + log::info!("overwriting file {:?} ({})", entry.path, entry.id.0); + worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx) + }; + cx.foreground().spawn(async move { + task.await?; + Ok(()) + }) + } + } +} + +async fn randomly_mutate_fs( + fs: &Arc, + root_path: &Path, + insertion_probability: f64, + rng: &mut impl Rng, +) { + log::info!("mutating fs"); + let mut files = Vec::new(); + let mut dirs = Vec::new(); + for path in fs.as_fake().paths(false) { + if path.starts_with(root_path) { + if fs.is_file(&path).await { + files.push(path); + } else { + dirs.push(path); + } + } + } + + if (files.is_empty() && dirs.len() == 1) || rng.gen_bool(insertion_probability) { + let path = dirs.choose(rng).unwrap(); + let new_path = path.join(random_filename(rng)); + + if rng.gen() { + log::info!( + "creating dir {:?}", + new_path.strip_prefix(root_path).unwrap() + ); + fs.create_dir(&new_path).await.unwrap(); + } else { + log::info!( + "creating file {:?}", + new_path.strip_prefix(root_path).unwrap() + ); + fs.create_file(&new_path, Default::default()).await.unwrap(); + } + } else if rng.gen_bool(0.05) { + let ignore_dir_path = dirs.choose(rng).unwrap(); + let ignore_path = ignore_dir_path.join(&*GITIGNORE); + + let subdirs = dirs + .iter() + .filter(|d| d.starts_with(&ignore_dir_path)) + .cloned() + .collect::>(); + let subfiles = files + .iter() + .filter(|d| d.starts_with(&ignore_dir_path)) + .cloned() + .collect::>(); + let files_to_ignore = { + let len = rng.gen_range(0..=subfiles.len()); + subfiles.choose_multiple(rng, len) + }; + let dirs_to_ignore = { + let len = rng.gen_range(0..subdirs.len()); + subdirs.choose_multiple(rng, len) + }; + + let mut ignore_contents = String::new(); + for path_to_ignore in files_to_ignore.chain(dirs_to_ignore) { + writeln!( + ignore_contents, + "{}", + path_to_ignore + .strip_prefix(&ignore_dir_path) + .unwrap() + .to_str() + .unwrap() + ) + .unwrap(); + } + log::info!( + "creating gitignore {:?} with contents:\n{}", + ignore_path.strip_prefix(&root_path).unwrap(), + ignore_contents + ); + fs.save( + &ignore_path, + &ignore_contents.as_str().into(), + Default::default(), + ) + .await + .unwrap(); + } else { + let old_path = { + let file_path = files.choose(rng); + let dir_path = dirs[1..].choose(rng); + file_path.into_iter().chain(dir_path).choose(rng).unwrap() + }; + + let is_rename = rng.gen(); + if is_rename { + let new_path_parent = dirs + .iter() + .filter(|d| !d.starts_with(old_path)) + .choose(rng) + .unwrap(); + + let overwrite_existing_dir = + !old_path.starts_with(&new_path_parent) && rng.gen_bool(0.3); + let new_path = if overwrite_existing_dir { + fs.remove_dir( + &new_path_parent, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .unwrap(); + new_path_parent.to_path_buf() + } else { + new_path_parent.join(random_filename(rng)) + }; + + log::info!( + "renaming {:?} to {}{:?}", + old_path.strip_prefix(&root_path).unwrap(), + if overwrite_existing_dir { + "overwrite " + } else { + "" + }, + new_path.strip_prefix(&root_path).unwrap() + ); + fs.rename( + &old_path, + &new_path, + fs::RenameOptions { + overwrite: true, + ignore_if_exists: true, + }, + ) + .await + .unwrap(); + } else if fs.is_file(&old_path).await { + log::info!( + "deleting file {:?}", + old_path.strip_prefix(&root_path).unwrap() + ); + fs.remove_file(old_path, Default::default()).await.unwrap(); + } else { + log::info!( + "deleting dir {:?}", + old_path.strip_prefix(&root_path).unwrap() + ); + fs.remove_dir( + &old_path, + RemoveOptions { + recursive: true, + ignore_if_not_exists: true, + }, + ) + .await + .unwrap(); + } + } +} + +fn random_filename(rng: &mut impl Rng) -> String { + (0..6) + .map(|_| rng.sample(rand::distributions::Alphanumeric)) + .map(char::from) + .collect() +} + +#[gpui::test] +async fn test_rename_work_directory(cx: &mut TestAppContext) { + let root = temp_tree(json!({ + "projects": { + "project1": { + "a": "", + "b": "", + } + }, + + })); + let root_path = root.path(); + + let http_client = FakeHttpClient::with_404_response(); + let client = cx.read(|cx| Client::new(http_client, cx)); + let tree = Worktree::local( + client, + root_path, + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let repo = git_init(&root_path.join("projects/project1")); + git_add("a", &repo); + git_commit("init", &repo); + std::fs::write(root_path.join("projects/project1/a"), "aa").ok(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.flush_fs_events(cx).await; + + cx.read(|cx| { + let tree = tree.read(cx); + let (work_dir, _) = tree.repositories().next().unwrap(); + assert_eq!(work_dir.as_ref(), Path::new("projects/project1")); + assert_eq!( + tree.status_for_file(Path::new("projects/project1/a")), + Some(GitFileStatus::Modified) + ); + assert_eq!( + tree.status_for_file(Path::new("projects/project1/b")), + Some(GitFileStatus::Added) + ); + }); + + std::fs::rename( + root_path.join("projects/project1"), + root_path.join("projects/project2"), + ) + .ok(); + tree.flush_fs_events(cx).await; + + cx.read(|cx| { + let tree = tree.read(cx); + let (work_dir, _) = tree.repositories().next().unwrap(); + assert_eq!(work_dir.as_ref(), Path::new("projects/project2")); + assert_eq!( + tree.status_for_file(Path::new("projects/project2/a")), + Some(GitFileStatus::Modified) + ); + assert_eq!( + tree.status_for_file(Path::new("projects/project2/b")), + Some(GitFileStatus::Added) + ); + }); +} + +#[gpui::test] +async fn test_git_repository_for_path(cx: &mut TestAppContext) { + let root = temp_tree(json!({ + "c.txt": "", + "dir1": { + ".git": {}, + "deps": { + "dep1": { + ".git": {}, + "src": { + "a.txt": "" + } + } + }, + "src": { + "b.txt": "" + } + }, + })); + + let http_client = FakeHttpClient::with_404_response(); + let client = cx.read(|cx| Client::new(http_client, cx)); + let tree = Worktree::local( + client, + root.path(), + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _cx| { + let tree = tree.as_local().unwrap(); + + assert!(tree.repository_for_path("c.txt".as_ref()).is_none()); + + let entry = tree.repository_for_path("dir1/src/b.txt".as_ref()).unwrap(); + assert_eq!( + entry + .work_directory(tree) + .map(|directory| directory.as_ref().to_owned()), + Some(Path::new("dir1").to_owned()) + ); + + let entry = tree + .repository_for_path("dir1/deps/dep1/src/a.txt".as_ref()) + .unwrap(); + assert_eq!( + entry + .work_directory(tree) + .map(|directory| directory.as_ref().to_owned()), + Some(Path::new("dir1/deps/dep1").to_owned()) + ); + + let entries = tree.files(false, 0); + + let paths_with_repos = tree + .entries_with_repositories(entries) + .map(|(entry, repo)| { + ( + entry.path.as_ref(), + repo.and_then(|repo| { + repo.work_directory(&tree) + .map(|work_directory| work_directory.0.to_path_buf()) + }), + ) + }) + .collect::>(); + + assert_eq!( + paths_with_repos, + &[ + (Path::new("c.txt"), None), + ( + Path::new("dir1/deps/dep1/src/a.txt"), + Some(Path::new("dir1/deps/dep1").into()) + ), + (Path::new("dir1/src/b.txt"), Some(Path::new("dir1").into())), + ] + ); + }); + + let repo_update_events = Arc::new(Mutex::new(vec![])); + tree.update(cx, |_, cx| { + let repo_update_events = repo_update_events.clone(); + cx.subscribe(&tree, move |_, _, event, _| { + if let Event::UpdatedGitRepositories(update) = event { + repo_update_events.lock().push(update.clone()); + } + }) + .detach(); + }); + + std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap(); + tree.flush_fs_events(cx).await; + + assert_eq!( + repo_update_events.lock()[0] + .iter() + .map(|e| e.0.clone()) + .collect::>>(), + vec![Path::new("dir1").into()] + ); + + std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap(); + tree.flush_fs_events(cx).await; + + tree.read_with(cx, |tree, _cx| { + let tree = tree.as_local().unwrap(); + + assert!(tree + .repository_for_path("dir1/src/b.txt".as_ref()) + .is_none()); + }); +} + +#[gpui::test] +async fn test_git_status(deterministic: Arc, cx: &mut TestAppContext) { + const IGNORE_RULE: &'static str = "**/target"; + + let root = temp_tree(json!({ + "project": { + "a.txt": "a", + "b.txt": "bb", + "c": { + "d": { + "e.txt": "eee" + } + }, + "f.txt": "ffff", + "target": { + "build_file": "???" + }, + ".gitignore": IGNORE_RULE + }, + + })); + + let http_client = FakeHttpClient::with_404_response(); + let client = cx.read(|cx| Client::new(http_client, cx)); + let tree = Worktree::local( + client, + root.path(), + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + const A_TXT: &'static str = "a.txt"; + const B_TXT: &'static str = "b.txt"; + const E_TXT: &'static str = "c/d/e.txt"; + const F_TXT: &'static str = "f.txt"; + const DOTGITIGNORE: &'static str = ".gitignore"; + const BUILD_FILE: &'static str = "target/build_file"; + let project_path: &Path = &Path::new("project"); + + let work_dir = root.path().join("project"); + let mut repo = git_init(work_dir.as_path()); + repo.add_ignore_rule(IGNORE_RULE).unwrap(); + git_add(Path::new(A_TXT), &repo); + git_add(Path::new(E_TXT), &repo); + git_add(Path::new(DOTGITIGNORE), &repo); + git_commit("Initial commit", &repo); + + tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); + + // Check that the right git state is observed on startup + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + assert_eq!(snapshot.repositories().count(), 1); + let (dir, _) = snapshot.repositories().next().unwrap(); + assert_eq!(dir.as_ref(), Path::new("project")); + + assert_eq!( + snapshot.status_for_file(project_path.join(B_TXT)), + Some(GitFileStatus::Added) + ); + assert_eq!( + snapshot.status_for_file(project_path.join(F_TXT)), + Some(GitFileStatus::Added) + ); + }); + + std::fs::write(work_dir.join(A_TXT), "aa").unwrap(); + + tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); + + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + + assert_eq!( + snapshot.status_for_file(project_path.join(A_TXT)), + Some(GitFileStatus::Modified) + ); + }); + + git_add(Path::new(A_TXT), &repo); + git_add(Path::new(B_TXT), &repo); + git_commit("Committing modified and added", &repo); + tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); + + // Check that repo only changes are tracked + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + + assert_eq!( + snapshot.status_for_file(project_path.join(F_TXT)), + Some(GitFileStatus::Added) + ); + + assert_eq!(snapshot.status_for_file(project_path.join(B_TXT)), None); + assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None); + }); + + git_reset(0, &repo); + git_remove_index(Path::new(B_TXT), &repo); + git_stash(&mut repo); + std::fs::write(work_dir.join(E_TXT), "eeee").unwrap(); + std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap(); + tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); + + // Check that more complex repo changes are tracked + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + + assert_eq!(snapshot.status_for_file(project_path.join(A_TXT)), None); + assert_eq!( + snapshot.status_for_file(project_path.join(B_TXT)), + Some(GitFileStatus::Added) + ); + assert_eq!( + snapshot.status_for_file(project_path.join(E_TXT)), + Some(GitFileStatus::Modified) + ); + }); + + std::fs::remove_file(work_dir.join(B_TXT)).unwrap(); + std::fs::remove_dir_all(work_dir.join("c")).unwrap(); + std::fs::write( + work_dir.join(DOTGITIGNORE), + [IGNORE_RULE, "f.txt"].join("\n"), + ) + .unwrap(); + + git_add(Path::new(DOTGITIGNORE), &repo); + git_commit("Committing modified git ignore", &repo); + + tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); + + let mut renamed_dir_name = "first_directory/second_directory"; + const RENAMED_FILE: &'static str = "rf.txt"; + + std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap(); + std::fs::write( + work_dir.join(renamed_dir_name).join(RENAMED_FILE), + "new-contents", + ) + .unwrap(); + + tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); + + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + assert_eq!( + snapshot.status_for_file(&project_path.join(renamed_dir_name).join(RENAMED_FILE)), + Some(GitFileStatus::Added) + ); + }); + + renamed_dir_name = "new_first_directory/second_directory"; + + std::fs::rename( + work_dir.join("first_directory"), + work_dir.join("new_first_directory"), + ) + .unwrap(); + + tree.flush_fs_events(cx).await; + deterministic.run_until_parked(); + + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + + assert_eq!( + snapshot.status_for_file( + project_path + .join(Path::new(renamed_dir_name)) + .join(RENAMED_FILE) + ), + Some(GitFileStatus::Added) + ); + }); +} + +#[gpui::test] +async fn test_propagate_git_statuses(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root", + json!({ + ".git": {}, + "a": { + "b": { + "c1.txt": "", + "c2.txt": "", + }, + "d": { + "e1.txt": "", + "e2.txt": "", + "e3.txt": "", + } + }, + "f": { + "no-status.txt": "" + }, + "g": { + "h1.txt": "", + "h2.txt": "" + }, + + }), + ) + .await; + + fs.set_status_for_repo_via_git_operation( + &Path::new("/root/.git"), + &[ + (Path::new("a/b/c1.txt"), GitFileStatus::Added), + (Path::new("a/d/e2.txt"), GitFileStatus::Modified), + (Path::new("g/h2.txt"), GitFileStatus::Conflict), + ], + ); + + let http_client = FakeHttpClient::with_404_response(); + let client = cx.read(|cx| Client::new(http_client, cx)); + let tree = Worktree::local( + client, + Path::new("/root"), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + cx.foreground().run_until_parked(); + let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); + + check_propagated_statuses( + &snapshot, + &[ + (Path::new(""), Some(GitFileStatus::Conflict)), + (Path::new("a"), Some(GitFileStatus::Modified)), + (Path::new("a/b"), Some(GitFileStatus::Added)), + (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), + (Path::new("a/b/c2.txt"), None), + (Path::new("a/d"), Some(GitFileStatus::Modified)), + (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), + (Path::new("f"), None), + (Path::new("f/no-status.txt"), None), + (Path::new("g"), Some(GitFileStatus::Conflict)), + (Path::new("g/h2.txt"), Some(GitFileStatus::Conflict)), + ], + ); + + check_propagated_statuses( + &snapshot, + &[ + (Path::new("a/b"), Some(GitFileStatus::Added)), + (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), + (Path::new("a/b/c2.txt"), None), + (Path::new("a/d"), Some(GitFileStatus::Modified)), + (Path::new("a/d/e1.txt"), None), + (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), + (Path::new("f"), None), + (Path::new("f/no-status.txt"), None), + (Path::new("g"), Some(GitFileStatus::Conflict)), + ], + ); + + check_propagated_statuses( + &snapshot, + &[ + (Path::new("a/b/c1.txt"), Some(GitFileStatus::Added)), + (Path::new("a/b/c2.txt"), None), + (Path::new("a/d/e1.txt"), None), + (Path::new("a/d/e2.txt"), Some(GitFileStatus::Modified)), + (Path::new("f/no-status.txt"), None), + ], + ); + + #[track_caller] + fn check_propagated_statuses( + snapshot: &Snapshot, + expected_statuses: &[(&Path, Option)], + ) { + let mut entries = expected_statuses + .iter() + .map(|(path, _)| snapshot.entry_for_path(path).unwrap().clone()) + .collect::>(); + snapshot.propagate_git_statuses(&mut entries); + assert_eq!( + entries + .iter() + .map(|e| (e.path.as_ref(), e.git_status)) + .collect::>(), + expected_statuses + ); + } +} + +#[track_caller] +fn git_init(path: &Path) -> git2::Repository { + git2::Repository::init(path).expect("Failed to initialize git repository") +} + +#[track_caller] +fn git_add>(path: P, repo: &git2::Repository) { + let path = path.as_ref(); + let mut index = repo.index().expect("Failed to get index"); + index.add_path(path).expect("Failed to add a.txt"); + index.write().expect("Failed to write index"); +} + +#[track_caller] +fn git_remove_index(path: &Path, repo: &git2::Repository) { + let mut index = repo.index().expect("Failed to get index"); + index.remove_path(path).expect("Failed to add a.txt"); + index.write().expect("Failed to write index"); +} + +#[track_caller] +fn git_commit(msg: &'static str, repo: &git2::Repository) { + use git2::Signature; + + let signature = Signature::now("test", "test@zed.dev").unwrap(); + let oid = repo.index().unwrap().write_tree().unwrap(); + let tree = repo.find_tree(oid).unwrap(); + if let Some(head) = repo.head().ok() { + let parent_obj = head.peel(git2::ObjectType::Commit).unwrap(); + + let parent_commit = parent_obj.as_commit().unwrap(); + + repo.commit( + Some("HEAD"), + &signature, + &signature, + msg, + &tree, + &[parent_commit], + ) + .expect("Failed to commit with parent"); + } else { + repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[]) + .expect("Failed to commit"); + } +} + +#[track_caller] +fn git_stash(repo: &mut git2::Repository) { + use git2::Signature; + + let signature = Signature::now("test", "test@zed.dev").unwrap(); + repo.stash_save(&signature, "N/A", None) + .expect("Failed to stash"); +} + +#[track_caller] +fn git_reset(offset: usize, repo: &git2::Repository) { + let head = repo.head().expect("Couldn't get repo head"); + let object = head.peel(git2::ObjectType::Commit).unwrap(); + let commit = object.as_commit().unwrap(); + let new_head = commit + .parents() + .inspect(|parnet| { + parnet.message(); + }) + .skip(offset) + .next() + .expect("Not enough history"); + repo.reset(&new_head.as_object(), git2::ResetType::Soft, None) + .expect("Could not reset"); +} + +#[allow(dead_code)] +#[track_caller] +fn git_status(repo: &git2::Repository) -> collections::HashMap { + repo.statuses(None) + .unwrap() + .iter() + .map(|status| (status.path().unwrap().to_string(), status.status())) + .collect() +} diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9563d54be8..dc592b7588 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1253,7 +1253,10 @@ impl ProjectPanel { let show_editor = details.is_editing && !details.is_processing; MouseEventHandler::::new(entry_id.to_usize(), cx, |state, cx| { - let mut style = entry_style.style_for(state, details.is_selected).clone(); + let mut style = entry_style + .in_state(details.is_selected) + .style_for(state) + .clone(); if cx .global::>() @@ -1264,7 +1267,7 @@ impl ProjectPanel { .filter(|destination| details.path.starts_with(destination)) .is_some() { - style = entry_style.active.clone().unwrap(); + style = entry_style.active_state().default.clone(); } let row_container_style = if show_editor { @@ -1405,9 +1408,11 @@ impl View for ProjectPanel { let button_style = theme.open_project_button.clone(); let context_menu_item_style = theme::current(cx).context_menu.item.clone(); move |state, cx| { - let button_style = button_style.style_for(state, false).clone(); - let context_menu_item = - context_menu_item_style.style_for(state, true).clone(); + let button_style = button_style.style_for(state).clone(); + let context_menu_item = context_menu_item_style + .active_state() + .style_for(state) + .clone(); theme::ui::keystroke_label( "Open a project", diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 0dc5dad3bf..fc17b57c6d 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -196,7 +196,7 @@ impl PickerDelegate for ProjectSymbolsDelegate { ) -> AnyElement> { let theme = theme::current(cx); let style = &theme.picker.item; - let current_style = style.style_for(mouse_state, selected); + let current_style = style.in_state(selected).style_for(mouse_state); let string_match = &self.matches[ix]; let symbol = &self.symbols[string_match.candidate_id]; @@ -229,7 +229,10 @@ impl PickerDelegate for ProjectSymbolsDelegate { .with_child( // Avoid styling the path differently when it is selected, since // the symbol's syntax highlighting doesn't change when selected. - Label::new(path.to_string(), style.default.label.clone()), + Label::new( + path.to_string(), + style.inactive_state().default.label.clone(), + ), ) .contained() .with_style(current_style.container) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index a1dc8982c7..b13f72da0b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -173,7 +173,7 @@ impl PickerDelegate for RecentProjectsDelegate { cx: &gpui::AppContext, ) -> AnyElement> { let theme = theme::current(cx); - let style = theme.picker.item.style_for(mouse_state, selected); + let style = theme.picker.item.in_state(selected).style_for(mouse_state); let string_match = &self.matches[ix]; diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 6b6f364fdb..0b76dba319 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -53,7 +53,7 @@ impl Rope { } } - self.chunks.push_tree(chunks.suffix(&()), &()); + self.chunks.append(chunks.suffix(&()), &()); self.check_invariants(); } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 87a8b265fb..c6a86b2f6a 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -328,7 +328,11 @@ impl BufferSearchBar { Some( MouseEventHandler::::new(option as usize, cx, |state, cx| { let theme = theme::current(cx); - let style = theme.search.option_button.style_for(state, is_active); + let style = theme + .search + .option_button + .in_state(is_active) + .style_for(state); Label::new(icon, style.text.clone()) .contained() .with_style(style.container) @@ -371,7 +375,7 @@ impl BufferSearchBar { enum NavButton {} MouseEventHandler::::new(direction as usize, cx, |state, cx| { let theme = theme::current(cx); - let style = theme.search.option_button.style_for(state, false); + let style = theme.search.option_button.inactive_state().style_for(state); Label::new(icon, style.text.clone()) .contained() .with_style(style.container) @@ -403,7 +407,7 @@ impl BufferSearchBar { enum CloseButton {} MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.dismiss_button.style_for(state, false); + let style = theme.dismiss_button.style_for(state); Svg::new("icons/x_mark_8.svg") .with_color(style.color) .constrained() diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 27aac1762b..135194df6a 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -896,7 +896,7 @@ impl ProjectSearchBar { enum NavButton {} MouseEventHandler::::new(direction as usize, cx, |state, cx| { let theme = theme::current(cx); - let style = theme.search.option_button.style_for(state, false); + let style = theme.search.option_button.inactive_state().style_for(state); Label::new(icon, style.text.clone()) .contained() .with_style(style.container) @@ -927,7 +927,11 @@ impl ProjectSearchBar { let is_active = self.is_option_enabled(option, cx); MouseEventHandler::::new(option as usize, cx, |state, cx| { let theme = theme::current(cx); - let style = theme.search.option_button.style_for(state, is_active); + let style = theme + .search + .option_button + .in_state(is_active) + .style_for(state); Label::new(icon, style.text.clone()) .contained() .with_style(style.container) diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index f0396266fc..b5dc301a5c 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -21,7 +21,7 @@ util = { path = "../util" } anyhow.workspace = true futures.workspace = true -json_comments = "0.2" +serde_json_lenient = {version = "0.1", features = ["preserve_order", "raw_value"]} lazy_static.workspace = true postage.workspace = true rust-embed.workspace = true @@ -31,12 +31,12 @@ serde_derive.workspace = true serde_json.workspace = true smallvec.workspace = true toml.workspace = true -tree-sitter = "*" +tree-sitter.workspace = true tree-sitter-json = "*" [dev-dependencies] gpui = { path = "../gpui", features = ["test-support"] } fs = { path = "../fs", features = ["test-support"] } - +indoc.workspace = true pretty_assertions = "1.3.0" unindent.workspace = true diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index c843c53e99..42fbeebe57 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -1,5 +1,5 @@ use crate::{settings_store::parse_json_with_comments, SettingsAssets}; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use collections::BTreeMap; use gpui::{keymap_matcher::Binding, AppContext}; use schemars::{ @@ -7,7 +7,7 @@ use schemars::{ schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation}, JsonSchema, }; use serde::Deserialize; -use serde_json::{value::RawValue, Value}; +use serde_json::Value; use util::{asset_str, ResultExt}; #[derive(Deserialize, Default, Clone, JsonSchema)] @@ -23,7 +23,7 @@ pub struct KeymapBlock { #[derive(Deserialize, Default, Clone)] #[serde(transparent)] -pub struct KeymapAction(Box); +pub struct KeymapAction(Value); impl JsonSchema for KeymapAction { fn schema_name() -> String { @@ -36,11 +36,12 @@ impl JsonSchema for KeymapAction { } #[derive(Deserialize)] -struct ActionWithData(Box, Box); +struct ActionWithData(Box, Value); impl KeymapFile { pub fn load_asset(asset_path: &str, cx: &mut AppContext) -> Result<()> { let content = asset_str::(asset_path); + Self::parse(content.as_ref())?.add_to_cx(cx) } @@ -53,18 +54,27 @@ impl KeymapFile { let bindings = bindings .into_iter() .filter_map(|(keystroke, action)| { - let action = action.0.get(); + let action = action.0; // This is a workaround for a limitation in serde: serde-rs/json#497 // We want to deserialize the action data as a `RawValue` so that we can // deserialize the action itself dynamically directly from the JSON // string. But `RawValue` currently does not work inside of an untagged enum. - if action.starts_with('[') { - let ActionWithData(name, data) = serde_json::from_str(action).log_err()?; - cx.deserialize_action(&name, Some(data.get())) + if let Value::Array(items) = action { + let Ok([name, data]): Result<[serde_json::Value; 2], _> = items.try_into() else { + return Some(Err(anyhow!("Expected array of length 2"))); + }; + let serde_json::Value::String(name) = name else { + return Some(Err(anyhow!("Expected first item in array to be a string."))) + }; + cx.deserialize_action( + &name, + Some(data), + ) + } else if let Value::String(name) = action { + cx.deserialize_action(&name, None) } else { - let name = serde_json::from_str(action).log_err()?; - cx.deserialize_action(name, None) + return Some(Err(anyhow!("Expected two-element array, got {:?}", action))); } .with_context(|| { format!( @@ -117,3 +127,24 @@ impl KeymapFile { serde_json::to_value(root_schema).unwrap() } } + +#[cfg(test)] +mod tests { + use crate::KeymapFile; + + #[test] + fn can_deserialize_keymap_with_trailing_comma() { + let json = indoc::indoc! {"[ + // Standard macOS bindings + { + \"bindings\": { + \"up\": \"menu::SelectPrev\", + }, + }, + ] + " + + }; + KeymapFile::parse(json).unwrap(); + } +} diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 1133cb5970..1188018cd8 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -86,9 +86,9 @@ pub struct SettingsJsonSchemaParams<'a> { /// A set of strongly-typed setting values defined via multiple JSON files. pub struct SettingsStore { setting_values: HashMap>, - default_deserialized_settings: serde_json::Value, - user_deserialized_settings: serde_json::Value, - local_deserialized_settings: BTreeMap<(usize, Arc), serde_json::Value>, + raw_default_settings: serde_json::Value, + raw_user_settings: serde_json::Value, + raw_local_settings: BTreeMap<(usize, Arc), serde_json::Value>, tab_size_callback: Option<(TypeId, Box Option>)>, } @@ -96,9 +96,9 @@ 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(), + raw_default_settings: serde_json::json!({}), + raw_user_settings: serde_json::json!({}), + raw_local_settings: Default::default(), tab_size_callback: Default::default(), } } @@ -148,13 +148,13 @@ impl SettingsStore { })); if let Some(default_settings) = setting_value - .deserialize_setting(&self.default_deserialized_settings) + .deserialize_setting(&self.raw_default_settings) .log_err() { let mut user_values_stack = Vec::new(); if let Some(user_settings) = setting_value - .deserialize_setting(&self.user_deserialized_settings) + .deserialize_setting(&self.raw_user_settings) .log_err() { user_values_stack = vec![user_settings]; @@ -196,8 +196,8 @@ 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 + pub fn raw_user_settings(&self) -> &serde_json::Value { + &self.raw_user_settings } #[cfg(any(test, feature = "test-support"))] @@ -219,7 +219,7 @@ impl SettingsStore { cx: &AppContext, update: impl FnOnce(&mut T::FileContent), ) { - let old_text = serde_json::to_string(&self.user_deserialized_settings).unwrap(); + let old_text = serde_json::to_string(&self.raw_user_settings).unwrap(); let new_text = self.new_text_for_update::(old_text, update); self.set_user_settings(&new_text, cx).unwrap(); } @@ -248,25 +248,19 @@ impl SettingsStore { ) -> Vec<(Range, String)> { let setting_type_id = TypeId::of::(); - let old_content = self + let setting = self .setting_values .get(&setting_type_id) - .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::())) - .deserialize_setting(&self.user_deserialized_settings) - .unwrap_or_else(|e| { - panic!( - "could not deserialize setting type {} from user settings: {}", - type_name::(), - e - ) - }) - .0 - .downcast::() - .unwrap(); + .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::())); + let raw_settings = parse_json_with_comments::(text).unwrap_or_default(); + let old_content = match setting.deserialize_setting(&raw_settings) { + Ok(content) => content.0.downcast::().unwrap(), + Err(_) => Box::new(T::FileContent::default()), + }; let mut new_content = old_content.clone(); update(&mut new_content); - let old_value = &serde_json::to_value(&old_content).unwrap(); + let old_value = serde_json::to_value(&old_content).unwrap(); let new_value = serde_json::to_value(new_content).unwrap(); let mut key_path = Vec::new(); @@ -323,7 +317,7 @@ impl SettingsStore { ) -> Result<()> { let settings: serde_json::Value = parse_json_with_comments(default_settings_content)?; if settings.is_object() { - self.default_deserialized_settings = settings; + self.raw_default_settings = settings; self.recompute_values(None, cx)?; Ok(()) } else { @@ -339,7 +333,7 @@ impl SettingsStore { ) -> Result<()> { let settings: serde_json::Value = parse_json_with_comments(user_settings_content)?; if settings.is_object() { - self.user_deserialized_settings = settings; + self.raw_user_settings = settings; self.recompute_values(None, cx)?; Ok(()) } else { @@ -356,11 +350,10 @@ impl SettingsStore { cx: &AppContext, ) -> Result<()> { if let Some(content) = settings_content { - self.local_deserialized_settings + self.raw_local_settings .insert((root_id, path.clone()), parse_json_with_comments(content)?); } else { - self.local_deserialized_settings - .remove(&(root_id, path.clone())); + self.raw_local_settings.remove(&(root_id, path.clone())); } self.recompute_values(Some((root_id, &path)), cx)?; Ok(()) @@ -368,14 +361,13 @@ impl SettingsStore { /// Add or remove a set of local settings via a JSON string. pub fn clear_local_settings(&mut self, root_id: usize, cx: &AppContext) -> Result<()> { - self.local_deserialized_settings - .retain(|k, _| k.0 != root_id); + self.raw_local_settings.retain(|k, _| k.0 != root_id); self.recompute_values(Some((root_id, "".as_ref())), cx)?; Ok(()) } pub fn local_settings(&self, root_id: usize) -> impl '_ + Iterator, String)> { - self.local_deserialized_settings + self.raw_local_settings .range((root_id, Path::new("").into())..(root_id + 1, Path::new("").into())) .map(|((_, path), content)| (path.clone(), serde_json::to_string(content).unwrap())) } @@ -466,14 +458,13 @@ impl SettingsStore { let mut user_settings_stack = Vec::::new(); let mut paths_stack = Vec::>::new(); for setting_value in self.setting_values.values_mut() { - let default_settings = - setting_value.deserialize_setting(&self.default_deserialized_settings)?; + let default_settings = setting_value.deserialize_setting(&self.raw_default_settings)?; user_settings_stack.clear(); paths_stack.clear(); if let Some(user_settings) = setting_value - .deserialize_setting(&self.user_deserialized_settings) + .deserialize_setting(&self.raw_user_settings) .log_err() { user_settings_stack.push(user_settings); @@ -491,7 +482,7 @@ impl SettingsStore { } // Reload the local values for the setting. - for ((root_id, path), local_settings) in &self.local_deserialized_settings { + for ((root_id, path), local_settings) in &self.raw_local_settings { // Build a stack of all of the local values for that setting. while let Some(prev_entry) = paths_stack.last() { if let Some((prev_root_id, prev_path)) = prev_entry { @@ -542,9 +533,9 @@ impl Debug for SettingsStore { .map(|value| value.setting_type_name()) .collect::>(), ) - .field("default_settings", &self.default_deserialized_settings) - .field("user_settings", &self.user_deserialized_settings) - .field("local_settings", &self.local_deserialized_settings) + .field("default_settings", &self.raw_default_settings) + .field("user_settings", &self.raw_user_settings) + .field("local_settings", &self.raw_local_settings) .finish_non_exhaustive() } } @@ -843,11 +834,8 @@ fn to_pretty_json(value: &impl Serialize, indent_size: usize, indent_prefix_len: } pub fn parse_json_with_comments(content: &str) -> Result { - Ok(serde_json::from_reader( - json_comments::CommentSettings::c_style().strip_comments(content.as_bytes()), - )?) + Ok(serde_json_lenient::from_str(content)?) } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/sum_tree/src/cursor.rs b/crates/sum_tree/src/cursor.rs index 88412f6059..b78484e6de 100644 --- a/crates/sum_tree/src/cursor.rs +++ b/crates/sum_tree/src/cursor.rs @@ -669,7 +669,7 @@ impl<'a, T: Item> SeekAggregate<'a, T> for () { impl<'a, T: Item> SeekAggregate<'a, T> for SliceSeekAggregate { fn begin_leaf(&mut self) {} fn end_leaf(&mut self, cx: &::Context) { - self.tree.push_tree( + self.tree.append( SumTree(Arc::new(Node::Leaf { summary: mem::take(&mut self.leaf_summary), items: mem::take(&mut self.leaf_items), @@ -689,7 +689,7 @@ impl<'a, T: Item> SeekAggregate<'a, T> for SliceSeekAggregate { _: &T::Summary, cx: &::Context, ) { - self.tree.push_tree(tree.clone(), cx); + self.tree.append(tree.clone(), cx); } } diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 6c6150aa3a..fa467886f0 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -268,7 +268,7 @@ impl SumTree { for item in iter { if leaf.is_some() && leaf.as_ref().unwrap().items().len() == 2 * TREE_BASE { - self.push_tree(SumTree(Arc::new(leaf.take().unwrap())), cx); + self.append(SumTree(Arc::new(leaf.take().unwrap())), cx); } if leaf.is_none() { @@ -295,13 +295,13 @@ impl SumTree { } if leaf.is_some() { - self.push_tree(SumTree(Arc::new(leaf.take().unwrap())), cx); + self.append(SumTree(Arc::new(leaf.take().unwrap())), cx); } } pub fn push(&mut self, item: T, cx: &::Context) { let summary = item.summary(); - self.push_tree( + self.append( SumTree(Arc::new(Node::Leaf { summary: summary.clone(), items: ArrayVec::from_iter(Some(item)), @@ -311,11 +311,11 @@ impl SumTree { ); } - pub fn push_tree(&mut self, other: Self, cx: &::Context) { + pub fn append(&mut self, other: Self, cx: &::Context) { if !other.0.is_leaf() || !other.0.items().is_empty() { if self.0.height() < other.0.height() { for tree in other.0.child_trees() { - self.push_tree(tree.clone(), cx); + self.append(tree.clone(), cx); } } else if let Some(split_tree) = self.push_tree_recursive(other, cx) { *self = Self::from_child_trees(self.clone(), split_tree, cx); @@ -512,7 +512,7 @@ impl SumTree { } } new_tree.push(item, cx); - new_tree.push_tree(cursor.suffix(cx), cx); + new_tree.append(cursor.suffix(cx), cx); new_tree }; replaced @@ -529,7 +529,7 @@ impl SumTree { cursor.next(cx); } } - new_tree.push_tree(cursor.suffix(cx), cx); + new_tree.append(cursor.suffix(cx), cx); new_tree }; removed @@ -563,7 +563,7 @@ impl SumTree { { new_tree.extend(buffered_items.drain(..), cx); let slice = cursor.slice(&new_key, Bias::Left, cx); - new_tree.push_tree(slice, cx); + new_tree.append(slice, cx); old_item = cursor.item(); } @@ -583,7 +583,7 @@ impl SumTree { } new_tree.extend(buffered_items, cx); - new_tree.push_tree(cursor.suffix(cx), cx); + new_tree.append(cursor.suffix(cx), cx); new_tree }; @@ -719,7 +719,7 @@ mod tests { let mut tree2 = SumTree::new(); tree2.extend(50..100, &()); - tree1.push_tree(tree2, &()); + tree1.append(tree2, &()); assert_eq!( tree1.items(&()), (0..20).chain(50..100).collect::>() @@ -766,7 +766,7 @@ mod tests { let mut new_tree = cursor.slice(&Count(splice_start), Bias::Right, &()); new_tree.extend(new_items, &()); cursor.seek(&Count(splice_end), Bias::Right, &()); - new_tree.push_tree(cursor.slice(&tree_end, Bias::Right, &()), &()); + new_tree.append(cursor.slice(&tree_end, Bias::Right, &()), &()); new_tree }; diff --git a/crates/sum_tree/src/tree_map.rs b/crates/sum_tree/src/tree_map.rs index ea69fb0dca..4bb98d2ac8 100644 --- a/crates/sum_tree/src/tree_map.rs +++ b/crates/sum_tree/src/tree_map.rs @@ -67,7 +67,7 @@ impl TreeMap { removed = Some(cursor.item().unwrap().value.clone()); cursor.next(&()); } - new_tree.push_tree(cursor.suffix(&()), &()); + new_tree.append(cursor.suffix(&()), &()); drop(cursor); self.0 = new_tree; removed @@ -79,7 +79,7 @@ impl TreeMap { let mut cursor = self.0.cursor::>(); let mut new_tree = cursor.slice(&start, Bias::Left, &()); cursor.seek(&end, Bias::Left, &()); - new_tree.push_tree(cursor.suffix(&()), &()); + new_tree.append(cursor.suffix(&()), &()); drop(cursor); self.0 = new_tree; } @@ -117,7 +117,7 @@ impl TreeMap { new_tree.push(updated, &()); cursor.next(&()); } - new_tree.push_tree(cursor.suffix(&()), &()); + new_tree.append(cursor.suffix(&()), &()); drop(cursor); self.0 = new_tree; result diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 2693add8ed..28c5125de2 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -600,7 +600,7 @@ impl Buffer { let mut old_fragments = self.fragments.cursor::(); let mut new_fragments = old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right, &None); - new_ropes.push_tree(new_fragments.summary().text); + new_ropes.append(new_fragments.summary().text); let mut fragment_start = old_fragments.start().visible; for (range, new_text) in edits { @@ -625,8 +625,8 @@ impl Buffer { } let slice = old_fragments.slice(&range.start, Bias::Right, &None); - new_ropes.push_tree(slice.summary().text); - new_fragments.push_tree(slice, &None); + new_ropes.append(slice.summary().text); + new_fragments.append(slice, &None); fragment_start = old_fragments.start().visible; } @@ -728,8 +728,8 @@ impl Buffer { } let suffix = old_fragments.suffix(&None); - new_ropes.push_tree(suffix.summary().text); - new_fragments.push_tree(suffix, &None); + new_ropes.append(suffix.summary().text); + new_fragments.append(suffix, &None); let (visible_text, deleted_text) = new_ropes.finish(); drop(old_fragments); @@ -828,7 +828,7 @@ impl Buffer { Bias::Left, &cx, ); - new_ropes.push_tree(new_fragments.summary().text); + new_ropes.append(new_fragments.summary().text); let mut fragment_start = old_fragments.start().0.full_offset(); for (range, new_text) in edits { @@ -854,8 +854,8 @@ impl Buffer { let slice = old_fragments.slice(&VersionedFullOffset::Offset(range.start), Bias::Left, &cx); - new_ropes.push_tree(slice.summary().text); - new_fragments.push_tree(slice, &None); + new_ropes.append(slice.summary().text); + new_fragments.append(slice, &None); fragment_start = old_fragments.start().0.full_offset(); } @@ -986,8 +986,8 @@ impl Buffer { } let suffix = old_fragments.suffix(&cx); - new_ropes.push_tree(suffix.summary().text); - new_fragments.push_tree(suffix, &None); + new_ropes.append(suffix.summary().text); + new_fragments.append(suffix, &None); let (visible_text, deleted_text) = new_ropes.finish(); drop(old_fragments); @@ -1056,8 +1056,8 @@ impl Buffer { for fragment_id in self.fragment_ids_for_edits(undo.counts.keys()) { let preceding_fragments = old_fragments.slice(&Some(fragment_id), Bias::Left, &None); - new_ropes.push_tree(preceding_fragments.summary().text); - new_fragments.push_tree(preceding_fragments, &None); + new_ropes.append(preceding_fragments.summary().text); + new_fragments.append(preceding_fragments, &None); if let Some(fragment) = old_fragments.item() { let mut fragment = fragment.clone(); @@ -1087,8 +1087,8 @@ impl Buffer { } let suffix = old_fragments.suffix(&None); - new_ropes.push_tree(suffix.summary().text); - new_fragments.push_tree(suffix, &None); + new_ropes.append(suffix.summary().text); + new_fragments.append(suffix, &None); drop(old_fragments); let (visible_text, deleted_text) = new_ropes.finish(); @@ -2070,7 +2070,7 @@ impl<'a> RopeBuilder<'a> { } } - fn push_tree(&mut self, len: FragmentTextSummary) { + fn append(&mut self, len: FragmentTextSummary) { self.push(len.visible, true, true); self.push(len.deleted, false, false); } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 97223911f5..75ce9b40ce 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -45,7 +45,7 @@ pub struct Theme { pub context_menu: ContextMenu, pub contacts_popover: ContactsPopover, pub contact_list: ContactList, - pub lsp_log_menu: LspLogMenu, + pub toolbar_dropdown_menu: DropdownMenu, pub copilot: Copilot, pub contact_finder: ContactFinder, pub project_panel: ProjectPanel, @@ -129,12 +129,12 @@ pub struct Titlebar { pub leader_avatar: AvatarStyle, pub follower_avatar: AvatarStyle, pub inactive_avatar_grayscale: bool, - pub sign_in_prompt: Interactive, + pub sign_in_prompt: Toggleable>, pub outdated_warning: ContainedText, - pub share_button: Interactive, + pub share_button: Toggleable>, pub call_control: Interactive, - pub toggle_contacts_button: Interactive, - pub user_menu_button: Interactive, + pub toggle_contacts_button: Toggleable>, + pub user_menu_button: Toggleable>, pub toggle_contacts_badge: ContainerStyle, } @@ -205,12 +205,12 @@ pub struct ContactList { pub user_query_editor: FieldEditor, pub user_query_editor_height: f32, pub add_contact_button: IconButton, - pub header_row: Interactive, + pub header_row: Toggleable>, pub leave_call: Interactive, - pub contact_row: Interactive, + pub contact_row: Toggleable>, pub row_height: f32, - pub project_row: Interactive, - pub tree_branch: Interactive, + pub project_row: Toggleable>, + pub tree_branch: Toggleable>, pub contact_avatar: ImageStyle, pub contact_status_free: ContainerStyle, pub contact_status_busy: ContainerStyle, @@ -247,20 +247,31 @@ pub struct ContactFinder { } #[derive(Deserialize, Default, JsonSchema)] -pub struct LspLogMenu { +pub struct DropdownMenu { #[serde(flatten)] pub container: ContainerStyle, - pub header: Interactive, - pub server: ContainedText, - pub item: Interactive, + pub header: Interactive, + pub section_header: ContainedText, + pub item: Toggleable>, pub row_height: f32, } +#[derive(Deserialize, Default, JsonSchema)] +pub struct DropdownMenuItem { + #[serde(flatten)] + pub container: ContainerStyle, + #[serde(flatten)] + pub text: TextStyle, + pub secondary_text: Option, + #[serde(default)] + pub secondary_text_spacing: f32, +} + #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct TabBar { #[serde(flatten)] pub container: ContainerStyle, - pub pane_button: Interactive, + pub pane_button: Toggleable>, pub pane_button_container: ContainerStyle, pub active_pane: TabStyles, pub inactive_pane: TabStyles, @@ -349,7 +360,7 @@ pub struct Search { pub include_exclude_editor: FindEditor, pub invalid_include_exclude_editor: ContainerStyle, pub include_exclude_inputs: ContainedText, - pub option_button: Interactive, + pub option_button: Toggleable>, pub match_background: Color, pub match_index: ContainedText, pub results_status: TextStyle, @@ -385,7 +396,7 @@ pub struct StatusBarPanelButtons { pub group_left: ContainerStyle, pub group_bottom: ContainerStyle, pub group_right: ContainerStyle, - pub button: Interactive, + pub button: Toggleable>, } #[derive(Deserialize, Default, JsonSchema)] @@ -434,10 +445,10 @@ pub struct PanelButton { pub struct ProjectPanel { #[serde(flatten)] pub container: ContainerStyle, - pub entry: Interactive, + pub entry: Toggleable>, pub dragged_entry: ProjectPanelEntry, - pub ignored_entry: Interactive, - pub cut_entry: Interactive, + pub ignored_entry: Toggleable>, + pub cut_entry: Toggleable>, pub filename_editor: FieldEditor, pub indent_width: f32, pub open_project_button: Interactive, @@ -471,7 +482,7 @@ pub struct GitProjectStatus { pub struct ContextMenu { #[serde(flatten)] pub container: ContainerStyle, - pub item: Interactive, + pub item: Toggleable>, pub keystroke_margin: f32, pub separator: ContainerStyle, } @@ -488,7 +499,7 @@ pub struct ContextMenuItem { #[derive(Debug, Deserialize, Default, JsonSchema)] pub struct CommandPalette { - pub key: Interactive, + pub key: Toggleable, pub keystroke_spacing: f32, } @@ -555,7 +566,7 @@ pub struct Picker { pub input_editor: FieldEditor, pub empty_input_editor: FieldEditor, pub no_matches: ContainedLabel, - pub item: Interactive, + pub item: Toggleable>, } #[derive(Clone, Debug, Deserialize, Default, JsonSchema)] @@ -761,13 +772,13 @@ pub struct InteractiveColor { #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct CodeActions { #[serde(default)] - pub indicator: Interactive, + pub indicator: Toggleable>, pub vertical_scale: f32, } #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct Folds { - pub indicator: Interactive, + pub indicator: Toggleable>, pub ellipses: FoldEllipses, pub fold_background: Color, pub icon_margin_scale: f32, @@ -795,38 +806,46 @@ pub struct DiffStyle { #[derive(Debug, Default, Clone, Copy, JsonSchema)] pub struct Interactive { pub default: T, - pub hover: Option, - pub hover_and_active: Option, + pub hovered: Option, pub clicked: Option, - pub click_and_active: Option, - pub active: Option, pub disabled: Option, } -impl Interactive { - pub fn style_for(&self, state: &mut MouseState, active: bool) -> &T { +#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema)] +pub struct Toggleable { + active: T, + inactive: T, +} + +impl Toggleable { + pub fn new(active: T, inactive: T) -> Self { + Self { active, inactive } + } + pub fn in_state(&self, active: bool) -> &T { if active { - if state.hovered() { - self.hover_and_active - .as_ref() - .unwrap_or(self.active.as_ref().unwrap_or(&self.default)) - } else if state.clicked() == Some(platform::MouseButton::Left) && self.clicked.is_some() - { - self.click_and_active - .as_ref() - .unwrap_or(self.active.as_ref().unwrap_or(&self.default)) - } else { - self.active.as_ref().unwrap_or(&self.default) - } - } else if state.clicked() == Some(platform::MouseButton::Left) && self.clicked.is_some() { + &self.active + } else { + &self.inactive + } + } + pub fn active_state(&self) -> &T { + self.in_state(true) + } + pub fn inactive_state(&self) -> &T { + self.in_state(false) + } +} + +impl Interactive { + pub fn style_for(&self, state: &mut MouseState) -> &T { + if state.clicked() == Some(platform::MouseButton::Left) && self.clicked.is_some() { self.clicked.as_ref().unwrap() } else if state.hovered() { - self.hover.as_ref().unwrap_or(&self.default) + self.hovered.as_ref().unwrap_or(&self.default) } else { &self.default } } - pub fn disabled_style(&self) -> &T { self.disabled.as_ref().unwrap_or(&self.default) } @@ -839,13 +858,9 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive { { #[derive(Deserialize)] struct Helper { - #[serde(flatten)] default: Value, - hover: Option, - hover_and_active: Option, + hovered: Option, clicked: Option, - click_and_active: Option, - active: Option, disabled: Option, } @@ -870,21 +885,15 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive { } }; - let hover = deserialize_state(json.hover)?; - let hover_and_active = deserialize_state(json.hover_and_active)?; + let hovered = deserialize_state(json.hovered)?; let clicked = deserialize_state(json.clicked)?; - let click_and_active = deserialize_state(json.click_and_active)?; - let active = deserialize_state(json.active)?; let disabled = deserialize_state(json.disabled)?; let default = serde_json::from_value(json.default).map_err(serde::de::Error::custom)?; Ok(Interactive { default, - hover, - hover_and_active, + hovered, clicked, - click_and_active, - active, disabled, }) } diff --git a/crates/theme/src/ui.rs b/crates/theme/src/ui.rs index 6cb7675ffd..5443e19d7a 100644 --- a/crates/theme/src/ui.rs +++ b/crates/theme/src/ui.rs @@ -171,7 +171,7 @@ where F: Fn(MouseClick, &mut V, &mut EventContext) + 'static, { MouseEventHandler::::new(0, cx, |state, _| { - let style = style.style_for(state, false); + let style = style.style_for(state); Label::new(label, style.text.to_owned()) .aligned() .contained() @@ -221,13 +221,13 @@ where title, style .title_text - .style_for(&mut MouseState::default(), false) + .style_for(&mut MouseState::default()) .clone(), )) .with_child( // FIXME: Get a better tag type MouseEventHandler::::new(999999, cx, |state, _cx| { - let style = style.close_icon.style_for(state, false); + let style = style.close_icon.style_for(state); icon(style) }) .on_click(platform::MouseButton::Left, move |_, _, cx| { diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index a6c84d1d91..5775f1b3e7 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -208,7 +208,7 @@ impl PickerDelegate for ThemeSelectorDelegate { cx: &AppContext, ) -> AnyElement> { let theme = theme::current(cx); - let style = theme.picker.item.style_for(mouse_state, selected); + let style = theme.picker.item.in_state(selected).style_for(mouse_state); let theme_match = &self.matches[ix]; Label::new(theme_match.string.clone(), style.label.clone()) diff --git a/crates/theme_testbench/Cargo.toml b/crates/theme_testbench/Cargo.toml deleted file mode 100644 index 32dca6a07a..0000000000 --- a/crates/theme_testbench/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "theme_testbench" -version = "0.1.0" -edition = "2021" -publish = false - -[lib] -path = "src/theme_testbench.rs" -doctest = false - - -[dependencies] -gpui = { path = "../gpui" } -theme = { path = "../theme" } -settings = { path = "../settings" } -workspace = { path = "../workspace" } -project = { path = "../project" } - -smallvec.workspace = true diff --git a/crates/theme_testbench/src/theme_testbench.rs b/crates/theme_testbench/src/theme_testbench.rs deleted file mode 100644 index 258249b599..0000000000 --- a/crates/theme_testbench/src/theme_testbench.rs +++ /dev/null @@ -1,300 +0,0 @@ -use gpui::{ - actions, - color::Color, - elements::{ - AnyElement, Canvas, Container, ContainerStyle, Flex, Label, Margin, MouseEventHandler, - Padding, ParentElement, - }, - fonts::TextStyle, - AppContext, Border, Element, Entity, ModelHandle, Quad, Task, View, ViewContext, ViewHandle, - WeakViewHandle, -}; -use project::Project; -use theme::{ColorScheme, Layer, Style, StyleSet, ThemeSettings}; -use workspace::{item::Item, register_deserializable_item, Pane, Workspace}; - -actions!(theme, [DeployThemeTestbench]); - -pub fn init(cx: &mut AppContext) { - cx.add_action(ThemeTestbench::deploy); - - register_deserializable_item::(cx) -} - -pub struct ThemeTestbench {} - -impl ThemeTestbench { - pub fn deploy( - workspace: &mut Workspace, - _: &DeployThemeTestbench, - cx: &mut ViewContext, - ) { - let view = cx.add_view(|_| ThemeTestbench {}); - workspace.add_item(Box::new(view), cx); - } - - fn render_ramps(color_scheme: &ColorScheme) -> Flex { - fn display_ramp(ramp: &Vec) -> AnyElement { - Flex::row() - .with_children(ramp.iter().cloned().map(|color| { - Canvas::new(move |scene, bounds, _, _, _| { - scene.push_quad(Quad { - bounds, - background: Some(color), - ..Default::default() - }); - }) - .flex(1.0, false) - })) - .flex(1.0, false) - .into_any() - } - - Flex::column() - .with_child(display_ramp(&color_scheme.ramps.neutral)) - .with_child(display_ramp(&color_scheme.ramps.red)) - .with_child(display_ramp(&color_scheme.ramps.orange)) - .with_child(display_ramp(&color_scheme.ramps.yellow)) - .with_child(display_ramp(&color_scheme.ramps.green)) - .with_child(display_ramp(&color_scheme.ramps.cyan)) - .with_child(display_ramp(&color_scheme.ramps.blue)) - .with_child(display_ramp(&color_scheme.ramps.violet)) - .with_child(display_ramp(&color_scheme.ramps.magenta)) - } - - fn render_layer( - layer_index: usize, - layer: &Layer, - cx: &mut ViewContext, - ) -> Container { - Flex::column() - .with_child( - Self::render_button_set(0, layer_index, "base", &layer.base, cx).flex(1., false), - ) - .with_child( - Self::render_button_set(1, layer_index, "variant", &layer.variant, cx) - .flex(1., false), - ) - .with_child( - Self::render_button_set(2, layer_index, "on", &layer.on, cx).flex(1., false), - ) - .with_child( - Self::render_button_set(3, layer_index, "accent", &layer.accent, cx) - .flex(1., false), - ) - .with_child( - Self::render_button_set(4, layer_index, "positive", &layer.positive, cx) - .flex(1., false), - ) - .with_child( - Self::render_button_set(5, layer_index, "warning", &layer.warning, cx) - .flex(1., false), - ) - .with_child( - Self::render_button_set(6, layer_index, "negative", &layer.negative, cx) - .flex(1., false), - ) - .contained() - .with_style(ContainerStyle { - margin: Margin { - top: 10., - bottom: 10., - left: 10., - right: 10., - }, - background_color: Some(layer.base.default.background), - ..Default::default() - }) - } - - fn render_button_set( - set_index: usize, - layer_index: usize, - set_name: &'static str, - style_set: &StyleSet, - cx: &mut ViewContext, - ) -> Flex { - Flex::row() - .with_child(Self::render_button( - set_index * 6, - layer_index, - set_name, - &style_set, - None, - cx, - )) - .with_child(Self::render_button( - set_index * 6 + 1, - layer_index, - "hovered", - &style_set, - Some(|style_set| &style_set.hovered), - cx, - )) - .with_child(Self::render_button( - set_index * 6 + 2, - layer_index, - "pressed", - &style_set, - Some(|style_set| &style_set.pressed), - cx, - )) - .with_child(Self::render_button( - set_index * 6 + 3, - layer_index, - "active", - &style_set, - Some(|style_set| &style_set.active), - cx, - )) - .with_child(Self::render_button( - set_index * 6 + 4, - layer_index, - "disabled", - &style_set, - Some(|style_set| &style_set.disabled), - cx, - )) - .with_child(Self::render_button( - set_index * 6 + 5, - layer_index, - "inverted", - &style_set, - Some(|style_set| &style_set.inverted), - cx, - )) - } - - fn render_button( - button_index: usize, - layer_index: usize, - text: &'static str, - style_set: &StyleSet, - style_override: Option &Style>, - cx: &mut ViewContext, - ) -> AnyElement { - enum TestBenchButton {} - MouseEventHandler::::new(layer_index + button_index, cx, |state, cx| { - let style = if let Some(style_override) = style_override { - style_override(&style_set) - } else if state.clicked().is_some() { - &style_set.pressed - } else if state.hovered() { - &style_set.hovered - } else { - &style_set.default - }; - - Self::render_label(text.to_string(), style, cx) - .contained() - .with_style(ContainerStyle { - margin: Margin { - top: 4., - bottom: 4., - left: 4., - right: 4., - }, - padding: Padding { - top: 4., - bottom: 4., - left: 4., - right: 4., - }, - background_color: Some(style.background), - border: Border { - width: 1., - color: style.border, - overlay: false, - top: true, - bottom: true, - left: true, - right: true, - }, - corner_radius: 2., - ..Default::default() - }) - }) - .flex(1., true) - .into_any() - } - - fn render_label(text: String, style: &Style, cx: &mut ViewContext) -> Label { - let settings = settings::get::(cx); - let font_cache = cx.font_cache(); - let family_id = settings.buffer_font_family; - let font_size = settings.buffer_font_size(cx); - let font_id = font_cache - .select_font(family_id, &Default::default()) - .unwrap(); - - let text_style = TextStyle { - color: style.foreground, - font_family_id: family_id, - font_family_name: font_cache.family_name(family_id).unwrap(), - font_id, - font_size, - font_properties: Default::default(), - underline: Default::default(), - }; - - Label::new(text, text_style) - } -} - -impl Entity for ThemeTestbench { - type Event = (); -} - -impl View for ThemeTestbench { - fn ui_name() -> &'static str { - "ThemeTestbench" - } - - fn render(&mut self, cx: &mut gpui::ViewContext) -> AnyElement { - let color_scheme = &theme::current(cx).clone().color_scheme; - - Flex::row() - .with_child( - Self::render_ramps(color_scheme) - .contained() - .with_margin_right(10.) - .flex(0.1, false), - ) - .with_child( - Flex::column() - .with_child(Self::render_layer(100, &color_scheme.lowest, cx).flex(1., true)) - .with_child(Self::render_layer(200, &color_scheme.middle, cx).flex(1., true)) - .with_child(Self::render_layer(300, &color_scheme.highest, cx).flex(1., true)) - .flex(1., false), - ) - .into_any() - } -} - -impl Item for ThemeTestbench { - fn tab_content( - &self, - _: Option, - style: &theme::Tab, - _: &AppContext, - ) -> AnyElement { - Label::new("Theme Testbench", style.label.clone()) - .aligned() - .contained() - .into_any() - } - - fn serialized_item_kind() -> Option<&'static str> { - Some("ThemeTestBench") - } - - fn deserialize( - _project: ModelHandle, - _workspace: WeakViewHandle, - _workspace_id: workspace::WorkspaceId, - _item_id: workspace::ItemId, - cx: &mut ViewContext, - ) -> Task>> { - Task::ready(Ok(cx.add_view(|_| Self {}))) - } -} diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 0214806e11..2ee3c85435 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -98,3 +98,14 @@ async fn test_buffer_search(cx: &mut gpui::TestAppContext) { assert_eq!(bar.query_editor.read(cx).text(cx), "jumps"); }) } + +#[gpui::test] +async fn test_count_down(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state(indoc! {"aˇa\nbb\ncc\ndd\nee"}, Mode::Normal); + cx.simulate_keystrokes(["2", "down"]); + cx.assert_editor_state("aa\nbb\ncˇc\ndd\nee"); + cx.simulate_keystrokes(["9", "down"]); + cx.assert_editor_state("aa\nbb\ncc\ndd\neˇe"); +} diff --git a/crates/welcome/src/base_keymap_picker.rs b/crates/welcome/src/base_keymap_picker.rs index e44b391d84..cf24a9127e 100644 --- a/crates/welcome/src/base_keymap_picker.rs +++ b/crates/welcome/src/base_keymap_picker.rs @@ -141,7 +141,7 @@ impl PickerDelegate for BaseKeymapSelectorDelegate { ) -> gpui::AnyElement> { let theme = &theme::current(cx); let keymap_match = &self.matches[ix]; - let style = theme.picker.item.style_for(mouse_state, selected); + let style = theme.picker.item.in_state(selected).style_for(mouse_state); Label::new(keymap_match.string.clone(), style.label.clone()) .with_highlights(keymap_match.positions.clone()) diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 48f486381d..9d23c6aca4 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -498,7 +498,9 @@ impl View for PanelButtons { Stack::new() .with_child( MouseEventHandler::::new(panel_ix, cx, |state, cx| { - let style = button_style.style_for(state, is_active); + let style = button_style.in_state(is_active); + + let style = style.style_for(state); Flex::row() .with_child( Svg::new(view.icon_path(cx)) @@ -598,8 +600,8 @@ impl StatusItemView for PanelButtons { } } -#[cfg(test)] -pub(crate) mod test { +#[cfg(any(test, feature = "test-support"))] +pub mod test { use super::*; use gpui::{ViewContext, WindowContext}; diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 9a3fb5e475..a3e3ab9299 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -710,8 +710,8 @@ impl FollowableItemHandle for ViewHandle { } } -#[cfg(test)] -pub(crate) mod test { +#[cfg(any(test, feature = "test-support"))] +pub mod test { use super::{Item, ItemEvent}; use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId}; use gpui::{ diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 1e3c6044a1..09cfb4d5d8 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -291,7 +291,7 @@ pub mod simple_message_notification { ) .with_child( MouseEventHandler::::new(0, cx, |state, _| { - let style = theme.dismiss_button.style_for(state, false); + let style = theme.dismiss_button.style_for(state); Svg::new("icons/x_mark_8.svg") .with_color(style.color) .constrained() @@ -323,7 +323,7 @@ pub mod simple_message_notification { 0, cx, |state, _| { - let style = theme.action_message.style_for(state, false); + let style = theme.action_message.style_for(state); Flex::row() .with_child( diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 551bc831d3..5136db1d18 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1410,7 +1410,7 @@ impl Pane { pub fn render_tab_bar_button)>( index: usize, icon: &'static str, - active: bool, + is_active: bool, tooltip: Option<(String, Option>)>, cx: &mut ViewContext, on_click: F, @@ -1420,7 +1420,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, active); + let style = theme.pane_button.in_state(is_active).style_for(mouse_state); Svg::new(icon) .with_color(style.color) .constrained() diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index d27818d202..dd2aa5a818 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -162,6 +162,12 @@ define_connection! { ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT; ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT; + ), + // Add panel zoom persistence + sql!( + ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool )]; } @@ -196,10 +202,13 @@ impl WorkspaceDb { display, left_dock_visible, left_dock_active_panel, + left_dock_zoom, right_dock_visible, right_dock_active_panel, + right_dock_zoom, bottom_dock_visible, - bottom_dock_active_panel + bottom_dock_active_panel, + bottom_dock_zoom FROM workspaces WHERE workspace_location = ? }) @@ -244,22 +253,28 @@ impl WorkspaceDb { workspace_location, left_dock_visible, left_dock_active_panel, + left_dock_zoom, right_dock_visible, right_dock_active_panel, + right_dock_zoom, bottom_dock_visible, bottom_dock_active_panel, + bottom_dock_zoom, timestamp ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, CURRENT_TIMESTAMP) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) ON CONFLICT DO UPDATE SET workspace_location = ?2, left_dock_visible = ?3, left_dock_active_panel = ?4, - right_dock_visible = ?5, - right_dock_active_panel = ?6, - bottom_dock_visible = ?7, - bottom_dock_active_panel = ?8, + left_dock_zoom = ?5, + right_dock_visible = ?6, + right_dock_active_panel = ?7, + right_dock_zoom = ?8, + bottom_dock_visible = ?9, + bottom_dock_active_panel = ?10, + bottom_dock_zoom = ?11, timestamp = CURRENT_TIMESTAMP ))?((workspace.id, &workspace.location, workspace.docks)) .context("Updating workspace")?; diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 9e3c4012cd..1075061853 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -100,16 +100,19 @@ impl Bind for DockStructure { pub struct DockData { pub(crate) visible: bool, pub(crate) active_panel: Option, + pub(crate) zoom: bool, } impl Column for DockData { fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { let (visible, next_index) = Option::::column(statement, start_index)?; let (active_panel, next_index) = Option::::column(statement, next_index)?; + let (zoom, next_index) = Option::::column(statement, next_index)?; Ok(( DockData { visible: visible.unwrap_or(false), active_panel, + zoom: zoom.unwrap_or(false), }, next_index, )) @@ -119,7 +122,8 @@ impl Column for DockData { impl Bind for DockData { fn bind(&self, statement: &Statement, start_index: i32) -> Result { let next_index = statement.bind(&self.visible, start_index)?; - statement.bind(&self.active_panel, next_index) + let next_index = statement.bind(&self.active_panel, next_index)?; + statement.bind(&self.zoom, next_index) } } diff --git a/crates/workspace/src/toolbar.rs b/crates/workspace/src/toolbar.rs index 8b26b1181b..49f9db12e6 100644 --- a/crates/workspace/src/toolbar.rs +++ b/crates/workspace/src/toolbar.rs @@ -231,7 +231,7 @@ fn nav_button ) -> AnyElement { MouseEventHandler::::new(0, cx, |state, _| { let style = if enabled { - style.style_for(state, false) + style.style_for(state) } else { style.disabled_style() }; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ef8cde78a6..a4974fc173 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -140,9 +140,11 @@ pub struct OpenPaths { #[derive(Clone, Deserialize, PartialEq)] pub struct ActivatePane(pub usize); +#[derive(Deserialize)] pub struct Toast { id: usize, msg: Cow<'static, str>, + #[serde(skip)] on_click: Option<(Cow<'static, str>, Arc)>, } @@ -183,9 +185,9 @@ impl Clone for Toast { } } -pub type WorkspaceId = i64; +impl_actions!(workspace, [ActivatePane, Toast]); -impl_actions!(workspace, [ActivatePane]); +pub type WorkspaceId = i64; pub fn init_settings(cx: &mut AppContext) { settings::register::(cx); @@ -553,6 +555,10 @@ impl Workspace { } } + project::Event::Notification(message) => this.show_notification(0, cx, |cx| { + cx.add_view(|_| MessageNotification::new(message.clone())) + }), + _ => {} } cx.notify() @@ -919,6 +925,7 @@ impl Workspace { this.zoomed = None; this.zoomed_position = None; } + this.update_active_view_for_followers(cx); cx.notify(); } } @@ -1598,9 +1605,7 @@ impl Workspace { focus_center = true; } } else { - if active_panel.is_zoomed(cx) { - cx.focus(active_panel.as_any()); - } + cx.focus(active_panel.as_any()); reveal_dock = true; } } @@ -1946,18 +1951,7 @@ impl Workspace { 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| { - item.to_followable_item_handle(cx)? - .remote_id(&self.app_state.client, cx) - .map(|id| id.to_proto()) - }), - leader_id: self.leader_for_pane(&pane), - }), - cx, - ); + self.update_active_view_for_followers(cx); cx.notify(); } @@ -2646,6 +2640,30 @@ impl Workspace { Ok(()) } + fn update_active_view_for_followers(&self, cx: &AppContext) { + if self.active_pane.read(cx).has_focus() { + self.update_followers( + proto::update_followers::Variant::UpdateActiveView(proto::UpdateActiveView { + id: self.active_item(cx).and_then(|item| { + item.to_followable_item_handle(cx)? + .remote_id(&self.app_state.client, cx) + .map(|id| id.to_proto()) + }), + leader_id: self.leader_for_pane(&self.active_pane), + }), + cx, + ); + } else { + self.update_followers( + proto::update_followers::Variant::UpdateActiveView(proto::UpdateActiveView { + id: None, + leader_id: None, + }), + cx, + ); + } + } + fn update_followers( &self, update: proto::update_followers::Variant, @@ -2693,12 +2711,10 @@ impl Workspace { .and_then(|id| state.items_by_leader_view_id.get(&id)) { items_to_activate.push((pane.clone(), item.boxed_clone())); - } else { - if let Some(shared_screen) = - self.shared_screen_for_peer(leader_id, pane, cx) - { - items_to_activate.push((pane.clone(), Box::new(shared_screen))); - } + } else if let Some(shared_screen) = + self.shared_screen_for_peer(leader_id, pane, cx) + { + items_to_activate.push((pane.clone(), Box::new(shared_screen))); } } } @@ -2838,7 +2854,7 @@ impl Workspace { cx.notify(); } - fn serialize_workspace(&self, cx: &AppContext) { + fn serialize_workspace(&self, cx: &ViewContext) { fn serialize_pane_handle( pane_handle: &ViewHandle, cx: &AppContext, @@ -2881,7 +2897,7 @@ impl Workspace { } } - fn build_serialized_docks(this: &Workspace, cx: &AppContext) -> DockStructure { + fn build_serialized_docks(this: &Workspace, cx: &ViewContext) -> DockStructure { let left_dock = this.left_dock.read(cx); let left_visible = left_dock.is_open(); let left_active_panel = left_dock.visible_panel().and_then(|panel| { @@ -2890,6 +2906,10 @@ impl Workspace { .to_string(), ) }); + let left_dock_zoom = left_dock + .visible_panel() + .map(|panel| panel.is_zoomed(cx)) + .unwrap_or(false); let right_dock = this.right_dock.read(cx); let right_visible = right_dock.is_open(); @@ -2899,6 +2919,10 @@ impl Workspace { .to_string(), ) }); + let right_dock_zoom = right_dock + .visible_panel() + .map(|panel| panel.is_zoomed(cx)) + .unwrap_or(false); let bottom_dock = this.bottom_dock.read(cx); let bottom_visible = bottom_dock.is_open(); @@ -2908,19 +2932,26 @@ impl Workspace { .to_string(), ) }); + let bottom_dock_zoom = bottom_dock + .visible_panel() + .map(|panel| panel.is_zoomed(cx)) + .unwrap_or(false); DockStructure { left: DockData { visible: left_visible, active_panel: left_active_panel, + zoom: left_dock_zoom, }, right: DockData { visible: right_visible, active_panel: right_active_panel, + zoom: right_dock_zoom, }, bottom: DockData { visible: bottom_visible, active_panel: bottom_active_panel, + zoom: bottom_dock_zoom, }, } } @@ -3033,14 +3064,31 @@ impl Workspace { dock.activate_panel(ix, cx); } } + dock.active_panel() + .map(|panel| { + panel.set_zoomed(docks.left.zoom, cx) + }); + if docks.left.visible && docks.left.zoom { + cx.focus_self() + } }); + // TODO: I think the bug is that setting zoom or active undoes the bottom zoom or something workspace.right_dock.update(cx, |dock, cx| { dock.set_open(docks.right.visible, cx); if let Some(active_panel) = docks.right.active_panel { if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) { dock.activate_panel(ix, cx); + } } + dock.active_panel() + .map(|panel| { + panel.set_zoomed(docks.right.zoom, cx) + }); + + if docks.right.visible && docks.right.zoom { + cx.focus_self() + } }); workspace.bottom_dock.update(cx, |dock, cx| { dock.set_open(docks.bottom.visible, cx); @@ -3049,8 +3097,18 @@ impl Workspace { dock.activate_panel(ix, cx); } } + + dock.active_panel() + .map(|panel| { + panel.set_zoomed(docks.bottom.zoom, cx) + }); + + if docks.bottom.visible && docks.bottom.zoom { + cx.focus_self() + } }); + cx.notify(); })?; @@ -4413,7 +4471,7 @@ mod tests { workspace.read_with(cx, |workspace, cx| { assert!(workspace.right_dock().read(cx).is_open()); assert!(!panel.is_zoomed(cx)); - assert!(!panel.has_focus(cx)); + assert!(panel.has_focus(cx)); }); // Focus and zoom panel @@ -4488,7 +4546,7 @@ mod tests { workspace.read_with(cx, |workspace, cx| { let pane = pane.read(cx); assert!(!pane.is_zoomed()); - assert!(pane.has_focus()); + assert!(!pane.has_focus()); assert!(workspace.right_dock().read(cx).is_open()); assert!(workspace.zoomed.is_none()); }); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index b29b4e5025..3bf500057f 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.91.0" +version = "0.93.0" publish = false [lib] @@ -45,11 +45,11 @@ journal = { path = "../journal" } language = { path = "../language" } language_selector = { path = "../language_selector" } lsp = { path = "../lsp" } -lsp_log = { path = "../lsp_log" } +language_tools = { path = "../language_tools" } node_runtime = { path = "../node_runtime" } ai = { path = "../ai" } outline = { path = "../outline" } -plugin_runtime = { path = "../plugin_runtime" } +plugin_runtime = { path = "../plugin_runtime",optional = true } project = { path = "../project" } project_panel = { path = "../project_panel" } project_symbols = { path = "../project_symbols" } @@ -62,7 +62,6 @@ text = { path = "../text" } terminal_view = { path = "../terminal_view" } theme = { path = "../theme" } theme_selector = { path = "../theme_selector" } -theme_testbench = { path = "../theme_testbench" } util = { path = "../util" } vim = { path = "../vim" } workspace = { path = "../workspace" } @@ -102,13 +101,14 @@ tempdir.workspace = true thiserror.workspace = true tiny_http = "0.8" toml.workspace = true -tree-sitter = "0.20" +tree-sitter.workspace = true tree-sitter-c = "0.20.1" tree-sitter-cpp = "0.20.0" tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" } tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "4ba9dab6e2602960d95b2b625f3386c27e08084e" } tree-sitter-embedded-template = "0.20.0" tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" } +tree-sitter-heex = { git = "https://github.com/phoenixframework/tree-sitter-heex", rev = "2e1348c3cf2c9323e87c2744796cf3f3868aa82a" } tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "40a81c01a40ac48744e0c8ccabbaba1920441199" } tree-sitter-rust = "0.20.3" tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" } @@ -146,15 +146,19 @@ icon = ["resources/app-icon-preview@2x.png", "resources/app-icon-preview.png"] identifier = "dev.zed.Zed-Dev" name = "Zed Dev" osx_minimum_system_version = "10.15.7" +osx_info_plist_exts = ["resources/info/*"] [package.metadata.bundle-preview] icon = ["resources/app-icon-preview@2x.png", "resources/app-icon-preview.png"] identifier = "dev.zed.Zed-Preview" name = "Zed Preview" osx_minimum_system_version = "10.15.7" +osx_info_plist_exts = ["resources/info/*"] + [package.metadata.bundle-stable] icon = ["resources/app-icon@2x.png", "resources/app-icon.png"] identifier = "dev.zed.Zed" name = "Zed" osx_minimum_system_version = "10.15.7" +osx_info_plist_exts = ["resources/info/*"] diff --git a/crates/zed/BundleDocumentTypes.plist b/crates/zed/resources/info/DocumentTypes.plist similarity index 100% rename from crates/zed/BundleDocumentTypes.plist rename to crates/zed/resources/info/DocumentTypes.plist diff --git a/crates/zed/resources/info/Permissions.plist b/crates/zed/resources/info/Permissions.plist new file mode 100644 index 0000000000..bded5a82e2 --- /dev/null +++ b/crates/zed/resources/info/Permissions.plist @@ -0,0 +1,24 @@ +NSSystemAdministrationUsageDescription +The operation being performed by a program in Zed requires elevated permission. +NSAppleEventsUsageDescription +An application in Zed wants to use AppleScript. +NSBluetoothAlwaysUsageDescription +An application in Zed wants to use Bluetooth. +NSCalendarsUsageDescription +An application in Zed wants to use Calendar data. +NSCameraUsageDescription +An application in Zed wants to use the camera. +NSContactsUsageDescription +An application in Zed wants to use your contacts. +NSLocationAlwaysUsageDescription +An application in Zed wants to use your location information, even in the background. +NSLocationUsageDescription +An application in Zed wants to use your location information. +NSLocationWhenInUseUsageDescription +An application in Zed wants to use your location information while active. +NSMicrophoneUsageDescription +An application in Zed wants to use your microphone. +NSSpeechRecognitionUsageDescription +An application in Zed wants to use speech recognition. +NSRemindersUsageDescription +An application in Zed wants to use your reminders. diff --git a/crates/zed/resources/zed.entitlements b/crates/zed/resources/zed.entitlements new file mode 100644 index 0000000000..07af3124cd --- /dev/null +++ b/crates/zed/resources/zed.entitlements @@ -0,0 +1,28 @@ + + + + + com.apple.security.automation.apple-events + + com.apple.security.cs.allow-jit + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.personal-information.addressbook + + com.apple.security.personal-information.calendars + + com.apple.security.personal-information.location + + com.apple.security.personal-information.photos-library + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/crates/zed/src/languages.rs b/crates/zed/src/languages.rs index 3ae564d13b..44e144e89b 100644 --- a/crates/zed/src/languages.rs +++ b/crates/zed/src/languages.rs @@ -10,6 +10,7 @@ mod elixir; mod go; mod html; mod json; +#[cfg(feature = "plugin_runtime")] mod language_plugin; mod lua; mod python; @@ -33,110 +34,109 @@ mod yaml; struct LanguageDir; pub fn init(languages: Arc, node_runtime: Arc) { - fn adapter_arc(adapter: impl LspAdapter) -> Arc { - Arc::new(adapter) - } + let language = |name, grammar, adapters| { + languages.register(name, load_config(name), grammar, adapters, load_queries) + }; - let languages_list = [ - ( - "c", - tree_sitter_c::language(), - vec![adapter_arc(c::CLspAdapter)], - ), - ( - "cpp", - tree_sitter_cpp::language(), - vec![adapter_arc(c::CLspAdapter)], - ), - ("css", tree_sitter_css::language(), vec![]), - ( - "elixir", - tree_sitter_elixir::language(), - vec![adapter_arc(elixir::ElixirLspAdapter)], - ), - ( - "go", - tree_sitter_go::language(), - vec![adapter_arc(go::GoLspAdapter)], - ), - ( - "json", - tree_sitter_json::language(), - vec![adapter_arc(json::JsonLspAdapter::new( - node_runtime.clone(), - languages.clone(), - ))], - ), - ("markdown", tree_sitter_markdown::language(), vec![]), - ( - "python", - tree_sitter_python::language(), - vec![adapter_arc(python::PythonLspAdapter::new( - node_runtime.clone(), - ))], - ), - ( - "rust", - tree_sitter_rust::language(), - vec![adapter_arc(rust::RustLspAdapter)], - ), - ("toml", tree_sitter_toml::language(), vec![]), - ( - "tsx", - tree_sitter_typescript::language_tsx(), - vec![ - adapter_arc(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - adapter_arc(typescript::EsLintLspAdapter::new(node_runtime.clone())), - ], - ), - ( - "typescript", - tree_sitter_typescript::language_typescript(), - vec![ - adapter_arc(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - adapter_arc(typescript::EsLintLspAdapter::new(node_runtime.clone())), - ], - ), - ( - "javascript", - tree_sitter_typescript::language_tsx(), - vec![ - adapter_arc(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - adapter_arc(typescript::EsLintLspAdapter::new(node_runtime.clone())), - ], - ), - ( - "html", - tree_sitter_html::language(), - vec![adapter_arc(html::HtmlLspAdapter::new(node_runtime.clone()))], - ), - ( - "ruby", - tree_sitter_ruby::language(), - vec![adapter_arc(ruby::RubyLanguageServer)], - ), - ( - "erb", - tree_sitter_embedded_template::language(), - vec![adapter_arc(ruby::RubyLanguageServer)], - ), - ("scheme", tree_sitter_scheme::language(), vec![]), - ("racket", tree_sitter_racket::language(), vec![]), - ( - "lua", - tree_sitter_lua::language(), - vec![adapter_arc(lua::LuaLspAdapter)], - ), - ( - "yaml", - tree_sitter_yaml::language(), - vec![adapter_arc(yaml::YamlLspAdapter::new(node_runtime))], - ), - ]; - - for (name, grammar, lsp_adapters) in languages_list { - languages.register(name, load_config(name), grammar, lsp_adapters, load_queries); - } + language( + "c", + tree_sitter_c::language(), + vec![Arc::new(c::CLspAdapter) as Arc], + ); + language( + "cpp", + tree_sitter_cpp::language(), + vec![Arc::new(c::CLspAdapter)], + ); + language("css", tree_sitter_css::language(), vec![]); + language( + "elixir", + tree_sitter_elixir::language(), + vec![Arc::new(elixir::ElixirLspAdapter)], + ); + language( + "go", + tree_sitter_go::language(), + vec![Arc::new(go::GoLspAdapter)], + ); + language( + "heex", + tree_sitter_heex::language(), + vec![Arc::new(elixir::ElixirLspAdapter)], + ); + language( + "json", + tree_sitter_json::language(), + vec![Arc::new(json::JsonLspAdapter::new( + node_runtime.clone(), + languages.clone(), + ))], + ); + language("markdown", tree_sitter_markdown::language(), vec![]); + language( + "python", + tree_sitter_python::language(), + vec![Arc::new(python::PythonLspAdapter::new( + node_runtime.clone(), + ))], + ); + language( + "rust", + tree_sitter_rust::language(), + vec![Arc::new(rust::RustLspAdapter)], + ); + language("toml", tree_sitter_toml::language(), vec![]); + language( + "tsx", + tree_sitter_typescript::language_tsx(), + vec![ + Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), + Arc::new(typescript::EsLintLspAdapter::new(node_runtime.clone())), + ], + ); + language( + "typescript", + tree_sitter_typescript::language_typescript(), + vec![ + Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), + Arc::new(typescript::EsLintLspAdapter::new(node_runtime.clone())), + ], + ); + language( + "javascript", + tree_sitter_typescript::language_tsx(), + vec![ + Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), + Arc::new(typescript::EsLintLspAdapter::new(node_runtime.clone())), + ], + ); + language( + "html", + tree_sitter_html::language(), + vec![Arc::new(html::HtmlLspAdapter::new(node_runtime.clone()))], + ); + language( + "ruby", + tree_sitter_ruby::language(), + vec![Arc::new(ruby::RubyLanguageServer)], + ); + language( + "erb", + tree_sitter_embedded_template::language(), + vec![Arc::new(ruby::RubyLanguageServer)], + ); + language("scheme", tree_sitter_scheme::language(), vec![]); + language("racket", tree_sitter_racket::language(), vec![]); + language( + "lua", + tree_sitter_lua::language(), + vec![Arc::new(lua::LuaLspAdapter)], + ); + language( + "yaml", + tree_sitter_yaml::language(), + vec![Arc::new(yaml::YamlLspAdapter::new(node_runtime))], + ); } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/zed/src/languages/c.rs b/crates/zed/src/languages/c.rs index 7e4ddcef19..241b11b47c 100644 --- a/crates/zed/src/languages/c.rs +++ b/crates/zed/src/languages/c.rs @@ -4,12 +4,11 @@ use futures::StreamExt; pub use language::*; use smol::fs::{self, File}; use std::{any::Any, path::PathBuf, sync::Arc}; -use util::fs::remove_matching; -use util::github::latest_github_release; -use util::http::HttpClient; -use util::ResultExt; - -use util::github::GitHubLspBinaryVersion; +use util::{ + fs::remove_matching, + github::{latest_github_release, GitHubLspBinaryVersion}, + ResultExt, +}; pub struct CLspAdapter; @@ -21,9 +20,9 @@ impl super::LspAdapter for CLspAdapter { async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result> { - let release = latest_github_release("clangd/clangd", false, http).await?; + let release = latest_github_release("clangd/clangd", false, delegate.http_client()).await?; let asset_name = format!("clangd-mac-{}.zip", release.name); let asset = release .assets @@ -40,8 +39,8 @@ impl super::LspAdapter for CLspAdapter { async fn fetch_server_binary( &self, version: Box, - http: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let zip_path = container_dir.join(format!("clangd_{}.zip", version.name)); @@ -49,7 +48,8 @@ impl super::LspAdapter for CLspAdapter { let binary_path = version_dir.join("bin/clangd"); if fs::metadata(&binary_path).await.is_err() { - let mut response = http + let mut response = delegate + .http_client() .get(&version.url, Default::default(), true) .await .context("error downloading release")?; @@ -81,7 +81,11 @@ impl super::LspAdapter for CLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last_clangd_dir = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/elixir.rs b/crates/zed/src/languages/elixir.rs index 2939a0fa5f..22aaedc069 100644 --- a/crates/zed/src/languages/elixir.rs +++ b/crates/zed/src/languages/elixir.rs @@ -1,16 +1,23 @@ use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use futures::StreamExt; +use gpui::{AsyncAppContext, Task}; pub use language::*; use lsp::{CompletionItemKind, SymbolKind}; use smol::fs::{self, File}; -use std::{any::Any, path::PathBuf, sync::Arc}; -use util::fs::remove_matching; -use util::github::latest_github_release; -use util::http::HttpClient; -use util::ResultExt; - -use util::github::GitHubLspBinaryVersion; +use std::{ + any::Any, + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering::SeqCst}, + Arc, + }, +}; +use util::{ + fs::remove_matching, + github::{latest_github_release, GitHubLspBinaryVersion}, + ResultExt, +}; pub struct ElixirLspAdapter; @@ -20,11 +27,43 @@ impl LspAdapter for ElixirLspAdapter { LanguageServerName("elixir-ls".into()) } + fn will_start_server( + &self, + delegate: &Arc, + cx: &mut AsyncAppContext, + ) -> Option>> { + static DID_SHOW_NOTIFICATION: AtomicBool = AtomicBool::new(false); + + const NOTIFICATION_MESSAGE: &str = "Could not run the elixir language server, `elixir-ls`, because `elixir` was not found."; + + let delegate = delegate.clone(); + Some(cx.spawn(|mut cx| async move { + let elixir_output = smol::process::Command::new("elixir") + .args(["--version"]) + .output() + .await; + if elixir_output.is_err() { + if DID_SHOW_NOTIFICATION + .compare_exchange(false, true, SeqCst, SeqCst) + .is_ok() + { + cx.update(|cx| { + delegate.show_notification(NOTIFICATION_MESSAGE, cx); + }) + } + return Err(anyhow!("cannot run elixir-ls")); + } + + Ok(()) + })) + } + async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result> { - let release = latest_github_release("elixir-lsp/elixir-ls", false, http).await?; + let release = + latest_github_release("elixir-lsp/elixir-ls", false, delegate.http_client()).await?; let asset_name = "elixir-ls.zip"; let asset = release .assets @@ -41,8 +80,8 @@ impl LspAdapter for ElixirLspAdapter { async fn fetch_server_binary( &self, version: Box, - http: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let zip_path = container_dir.join(format!("elixir-ls_{}.zip", version.name)); @@ -50,7 +89,8 @@ impl LspAdapter for ElixirLspAdapter { let binary_path = version_dir.join("language_server.sh"); if fs::metadata(&binary_path).await.is_err() { - let mut response = http + let mut response = delegate + .http_client() .get(&version.url, Default::default(), true) .await .context("error downloading release")?; @@ -88,7 +128,11 @@ impl LspAdapter for ElixirLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/elixir/highlights.scm b/crates/zed/src/languages/elixir/highlights.scm index deea51c436..0e779d195c 100644 --- a/crates/zed/src/languages/elixir/highlights.scm +++ b/crates/zed/src/languages/elixir/highlights.scm @@ -36,8 +36,6 @@ (char) @constant -(interpolation "#{" @punctuation.special "}" @punctuation.special) @embedded - (escape_sequence) @string.escape [ @@ -146,3 +144,10 @@ "<<" ">>" ] @punctuation.bracket + +(interpolation "#{" @punctuation.special "}" @punctuation.special) @embedded + +((sigil + (sigil_name) @_sigil_name + (quoted_content) @embedded) + (#eq? @_sigil_name "H")) diff --git a/crates/zed/src/languages/elixir/injections.scm b/crates/zed/src/languages/elixir/injections.scm new file mode 100644 index 0000000000..4de229f104 --- /dev/null +++ b/crates/zed/src/languages/elixir/injections.scm @@ -0,0 +1,7 @@ +; Phoenix HTML template + +((sigil + (sigil_name) @_sigil_name + (quoted_content) @content) + (#eq? @_sigil_name "H") + (#set! language "heex")) diff --git a/crates/zed/src/languages/erb/highlights.scm b/crates/zed/src/languages/erb/highlights.scm index 91b21d081f..0bf76a7d49 100644 --- a/crates/zed/src/languages/erb/highlights.scm +++ b/crates/zed/src/languages/erb/highlights.scm @@ -9,4 +9,4 @@ "%>" "-%>" "_%>" -] @keyword \ No newline at end of file +] @keyword diff --git a/crates/zed/src/languages/go.rs b/crates/zed/src/languages/go.rs index ed24abb45c..34364d0b7f 100644 --- a/crates/zed/src/languages/go.rs +++ b/crates/zed/src/languages/go.rs @@ -1,16 +1,23 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures::StreamExt; +use gpui::{AsyncAppContext, Task}; pub use language::*; use lazy_static::lazy_static; use regex::Regex; use smol::{fs, process}; -use std::ffi::{OsStr, OsString}; -use std::{any::Any, ops::Range, path::PathBuf, str, sync::Arc}; -use util::fs::remove_matching; -use util::github::latest_github_release; -use util::http::HttpClient; -use util::ResultExt; +use std::{ + any::Any, + ffi::{OsStr, OsString}, + ops::Range, + path::PathBuf, + str, + sync::{ + atomic::{AtomicBool, Ordering::SeqCst}, + Arc, + }, +}; +use util::{fs::remove_matching, github::latest_github_release, ResultExt}; fn server_binary_arguments() -> Vec { vec!["-mode=stdio".into()] @@ -31,9 +38,9 @@ impl super::LspAdapter for GoLspAdapter { async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result> { - let release = latest_github_release("golang/tools", false, http).await?; + let release = latest_github_release("golang/tools", false, delegate.http_client()).await?; let version: Option = release.name.strip_prefix("gopls/v").map(str::to_string); if version.is_none() { log::warn!( @@ -44,11 +51,39 @@ impl super::LspAdapter for GoLspAdapter { Ok(Box::new(version) as Box<_>) } + fn will_fetch_server( + &self, + delegate: &Arc, + cx: &mut AsyncAppContext, + ) -> Option>> { + static DID_SHOW_NOTIFICATION: AtomicBool = AtomicBool::new(false); + + const NOTIFICATION_MESSAGE: &str = + "Could not install the Go language server `gopls`, because `go` was not found."; + + let delegate = delegate.clone(); + Some(cx.spawn(|mut cx| async move { + let install_output = process::Command::new("go").args(["version"]).output().await; + if install_output.is_err() { + if DID_SHOW_NOTIFICATION + .compare_exchange(false, true, SeqCst, SeqCst) + .is_ok() + { + cx.update(|cx| { + delegate.show_notification(NOTIFICATION_MESSAGE, cx); + }) + } + return Err(anyhow!("cannot install gopls")); + } + Ok(()) + })) + } + async fn fetch_server_binary( &self, version: Box, - _: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::>().unwrap(); let this = *self; @@ -68,7 +103,10 @@ impl super::LspAdapter for GoLspAdapter { }); } } - } else if let Some(path) = this.cached_server_binary(container_dir.clone()).await { + } else if let Some(path) = this + .cached_server_binary(container_dir.clone(), delegate) + .await + { return Ok(path); } @@ -105,7 +143,11 @@ impl super::LspAdapter for GoLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last_binary_path = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/heex/config.toml b/crates/zed/src/languages/heex/config.toml new file mode 100644 index 0000000000..fafd75dc8d --- /dev/null +++ b/crates/zed/src/languages/heex/config.toml @@ -0,0 +1,7 @@ +name = "HEEX" +path_suffixes = ["heex"] +autoclose_before = ">})" +brackets = [ + { start = "<", end = ">", close = true, newline = true }, +] +block_comment = ["<%#", "%>"] diff --git a/crates/zed/src/languages/heex/highlights.scm b/crates/zed/src/languages/heex/highlights.scm new file mode 100644 index 0000000000..8728110d58 --- /dev/null +++ b/crates/zed/src/languages/heex/highlights.scm @@ -0,0 +1,57 @@ +; HEEx delimiters +[ + "--%>" + "-->" + "/>" + "" + "{" + "}" +] @punctuation.bracket + +[ + "<%!--" + "<%" + "<%#" + "<%%=" + "<%=" + "%>" +] @keyword + +; HEEx operators are highlighted as such +"=" @operator + +; HEEx inherits the DOCTYPE tag from HTML +(doctype) @constant + +(comment) @comment + +; HEEx tags and slots are highlighted as HTML +[ + (tag_name) + (slot_name) +] @tag + +; HEEx attributes are highlighted as HTML attributes +(attribute_name) @attribute + +; HEEx special attributes are highlighted as keywords +(special_attribute_name) @keyword + +[ + (attribute_value) + (quoted_attribute_value) +] @string + +; HEEx components are highlighted as Elixir modules and functions +(component_name + [ + (module) @module + (function) @function + "." @punctuation.delimiter + ]) diff --git a/crates/zed/src/languages/heex/injections.scm b/crates/zed/src/languages/heex/injections.scm new file mode 100644 index 0000000000..41b65db8ee --- /dev/null +++ b/crates/zed/src/languages/heex/injections.scm @@ -0,0 +1,15 @@ +( + (directive + [ + (partial_expression_value) + (expression_value) + (ending_expression_value) + ] @content) + (#set! language "elixir") + (#set! combined) +) + +; expressions live within HTML tags, and do not need to be combined +; +((expression (expression_value) @content) + (#set! language "elixir")) diff --git a/crates/zed/src/languages/html.rs b/crates/zed/src/languages/html.rs index 68f780c3af..d038ef8071 100644 --- a/crates/zed/src/languages/html.rs +++ b/crates/zed/src/languages/html.rs @@ -1,14 +1,16 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures::StreamExt; -use language::{LanguageServerBinary, LanguageServerName, LspAdapter}; +use language::{LanguageServerBinary, LanguageServerName, LspAdapter, LspAdapterDelegate}; use node_runtime::NodeRuntime; use serde_json::json; use smol::fs; -use std::ffi::OsString; -use std::path::Path; -use std::{any::Any, path::PathBuf, sync::Arc}; -use util::http::HttpClient; +use std::{ + any::Any, + ffi::OsString, + path::{Path, PathBuf}, + sync::Arc, +}; use util::ResultExt; fn server_binary_arguments(server_path: &Path) -> Vec { @@ -36,7 +38,7 @@ impl LspAdapter for HtmlLspAdapter { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { Ok(Box::new( self.node @@ -48,8 +50,8 @@ impl LspAdapter for HtmlLspAdapter { async fn fetch_server_binary( &self, version: Box, - _: Arc, container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let server_path = container_dir.join(Self::SERVER_PATH); @@ -69,7 +71,11 @@ impl LspAdapter for HtmlLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last_version_dir = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/json.rs b/crates/zed/src/languages/json.rs index e1f3da9e02..ced733fa1b 100644 --- a/crates/zed/src/languages/json.rs +++ b/crates/zed/src/languages/json.rs @@ -3,7 +3,9 @@ use async_trait::async_trait; use collections::HashMap; use futures::{future::BoxFuture, FutureExt, StreamExt}; use gpui::AppContext; -use language::{LanguageRegistry, LanguageServerBinary, LanguageServerName, LspAdapter}; +use language::{ + LanguageRegistry, LanguageServerBinary, LanguageServerName, LspAdapter, LspAdapterDelegate, +}; use node_runtime::NodeRuntime; use serde_json::json; use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore}; @@ -16,7 +18,6 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::http::HttpClient; use util::{paths, ResultExt}; const SERVER_PATH: &'static str = @@ -45,7 +46,7 @@ impl LspAdapter for JsonLspAdapter { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { Ok(Box::new( self.node @@ -57,8 +58,8 @@ impl LspAdapter for JsonLspAdapter { async fn fetch_server_binary( &self, version: Box, - _: Arc, container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let server_path = container_dir.join(SERVER_PATH); @@ -78,7 +79,11 @@ impl LspAdapter for JsonLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last_version_dir = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/language_plugin.rs b/crates/zed/src/languages/language_plugin.rs index 9b82713d08..5209102454 100644 --- a/crates/zed/src/languages/language_plugin.rs +++ b/crates/zed/src/languages/language_plugin.rs @@ -3,10 +3,9 @@ use async_trait::async_trait; use collections::HashMap; use futures::lock::Mutex; use gpui::executor::Background; -use language::{LanguageServerBinary, LanguageServerName, LspAdapter}; +use language::{LanguageServerBinary, LanguageServerName, LspAdapter, LspAdapterDelegate}; use plugin_runtime::{Plugin, PluginBinary, PluginBuilder, WasiFn}; use std::{any::Any, path::PathBuf, sync::Arc}; -use util::http::HttpClient; use util::ResultExt; #[allow(dead_code)] @@ -72,7 +71,7 @@ impl LspAdapter for PluginLspAdapter { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { let runtime = self.runtime.clone(); let function = self.fetch_latest_server_version; @@ -92,8 +91,8 @@ impl LspAdapter for PluginLspAdapter { async fn fetch_server_binary( &self, version: Box, - _: Arc, container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { let version = *version.downcast::().unwrap(); let runtime = self.runtime.clone(); @@ -110,7 +109,11 @@ impl LspAdapter for PluginLspAdapter { .await } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { let runtime = self.runtime.clone(); let function = self.cached_server_binary; diff --git a/crates/zed/src/languages/lua.rs b/crates/zed/src/languages/lua.rs index f204eb2555..7f63a1fae2 100644 --- a/crates/zed/src/languages/lua.rs +++ b/crates/zed/src/languages/lua.rs @@ -3,12 +3,14 @@ use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use async_trait::async_trait; use futures::{io::BufReader, StreamExt}; -use language::{LanguageServerBinary, LanguageServerName}; +use language::{LanguageServerBinary, LanguageServerName, LspAdapterDelegate}; use smol::fs; -use std::{any::Any, env::consts, ffi::OsString, path::PathBuf, sync::Arc}; -use util::{async_iife, github::latest_github_release, http::HttpClient, ResultExt}; - -use util::github::GitHubLspBinaryVersion; +use std::{any::Any, env::consts, ffi::OsString, path::PathBuf}; +use util::{ + async_iife, + github::{latest_github_release, GitHubLspBinaryVersion}, + ResultExt, +}; #[derive(Copy, Clone)] pub struct LuaLspAdapter; @@ -28,9 +30,11 @@ impl super::LspAdapter for LuaLspAdapter { async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result> { - let release = latest_github_release("LuaLS/lua-language-server", false, http).await?; + let release = + latest_github_release("LuaLS/lua-language-server", false, delegate.http_client()) + .await?; let version = release.name.clone(); let platform = match consts::ARCH { "x86_64" => "x64", @@ -53,15 +57,16 @@ impl super::LspAdapter for LuaLspAdapter { async fn fetch_server_binary( &self, version: Box, - http: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let binary_path = container_dir.join("bin/lua-language-server"); if fs::metadata(&binary_path).await.is_err() { - let mut response = http + let mut response = delegate + .http_client() .get(&version.url, Default::default(), true) .await .map_err(|err| anyhow!("error downloading release: {}", err))?; @@ -81,7 +86,11 @@ impl super::LspAdapter for LuaLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { async_iife!({ let mut last_binary_path = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/python.rs b/crates/zed/src/languages/python.rs index 7aaddf5fe8..b56a0d6175 100644 --- a/crates/zed/src/languages/python.rs +++ b/crates/zed/src/languages/python.rs @@ -1,7 +1,7 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use futures::StreamExt; -use language::{LanguageServerBinary, LanguageServerName, LspAdapter}; +use language::{LanguageServerBinary, LanguageServerName, LspAdapter, LspAdapterDelegate}; use node_runtime::NodeRuntime; use smol::fs; use std::{ @@ -10,7 +10,6 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::http::HttpClient; use util::ResultExt; fn server_binary_arguments(server_path: &Path) -> Vec { @@ -37,7 +36,7 @@ impl LspAdapter for PythonLspAdapter { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { Ok(Box::new(self.node.npm_package_latest_version("pyright").await?) as Box<_>) } @@ -45,8 +44,8 @@ impl LspAdapter for PythonLspAdapter { async fn fetch_server_binary( &self, version: Box, - _: Arc, container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let server_path = container_dir.join(Self::SERVER_PATH); @@ -63,7 +62,11 @@ impl LspAdapter for PythonLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last_version_dir = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/ruby.rs b/crates/zed/src/languages/ruby.rs index d387f815f0..18756e3b77 100644 --- a/crates/zed/src/languages/ruby.rs +++ b/crates/zed/src/languages/ruby.rs @@ -1,8 +1,7 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; -use language::{LanguageServerBinary, LanguageServerName, LspAdapter}; +use language::{LanguageServerBinary, LanguageServerName, LspAdapter, LspAdapterDelegate}; use std::{any::Any, path::PathBuf, sync::Arc}; -use util::http::HttpClient; pub struct RubyLanguageServer; @@ -14,7 +13,7 @@ impl LspAdapter for RubyLanguageServer { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { Ok(Box::new(())) } @@ -22,13 +21,17 @@ impl LspAdapter for RubyLanguageServer { async fn fetch_server_binary( &self, _version: Box, - _: Arc, _container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { Err(anyhow!("solargraph must be installed manually")) } - async fn cached_server_binary(&self, _container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + _: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { Some(LanguageServerBinary { path: "solargraph".into(), arguments: vec!["stdio".into()], diff --git a/crates/zed/src/languages/rust.rs b/crates/zed/src/languages/rust.rs index 15700ec80a..e60846b70a 100644 --- a/crates/zed/src/languages/rust.rs +++ b/crates/zed/src/languages/rust.rs @@ -7,10 +7,11 @@ use lazy_static::lazy_static; use regex::Regex; use smol::fs::{self, File}; use std::{any::Any, borrow::Cow, env::consts, path::PathBuf, str, sync::Arc}; -use util::fs::remove_matching; -use util::github::{latest_github_release, GitHubLspBinaryVersion}; -use util::http::HttpClient; -use util::ResultExt; +use util::{ + fs::remove_matching, + github::{latest_github_release, GitHubLspBinaryVersion}, + ResultExt, +}; pub struct RustLspAdapter; @@ -22,9 +23,11 @@ impl LspAdapter for RustLspAdapter { async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result> { - let release = latest_github_release("rust-analyzer/rust-analyzer", false, http).await?; + let release = + latest_github_release("rust-analyzer/rust-analyzer", false, delegate.http_client()) + .await?; let asset_name = format!("rust-analyzer-{}-apple-darwin.gz", consts::ARCH); let asset = release .assets @@ -40,14 +43,15 @@ impl LspAdapter for RustLspAdapter { async fn fetch_server_binary( &self, version: Box, - http: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let destination_path = container_dir.join(format!("rust-analyzer-{}", version.name)); if fs::metadata(&destination_path).await.is_err() { - let mut response = http + let mut response = delegate + .http_client() .get(&version.url, Default::default(), true) .await .map_err(|err| anyhow!("error downloading release: {}", err))?; @@ -69,7 +73,11 @@ impl LspAdapter for RustLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/typescript.rs b/crates/zed/src/languages/typescript.rs index 7d2d580857..662e73ea33 100644 --- a/crates/zed/src/languages/typescript.rs +++ b/crates/zed/src/languages/typescript.rs @@ -4,7 +4,7 @@ use async_tar::Archive; use async_trait::async_trait; use futures::{future::BoxFuture, FutureExt}; use gpui::AppContext; -use language::{LanguageServerBinary, LanguageServerName, LspAdapter}; +use language::{LanguageServerBinary, LanguageServerName, LspAdapter, LspAdapterDelegate}; use lsp::CodeActionKind; use node_runtime::NodeRuntime; use serde_json::{json, Value}; @@ -16,7 +16,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::{fs::remove_matching, github::latest_github_release, http::HttpClient}; +use util::{fs::remove_matching, github::latest_github_release}; use util::{github::GitHubLspBinaryVersion, ResultExt}; fn typescript_server_binary_arguments(server_path: &Path) -> Vec { @@ -58,7 +58,7 @@ impl LspAdapter for TypeScriptLspAdapter { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { Ok(Box::new(TypeScriptVersions { typescript_version: self.node.npm_package_latest_version("typescript").await?, @@ -72,8 +72,8 @@ impl LspAdapter for TypeScriptLspAdapter { async fn fetch_server_binary( &self, version: Box, - _: Arc, container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let server_path = container_dir.join(Self::NEW_SERVER_PATH); @@ -99,7 +99,11 @@ impl LspAdapter for TypeScriptLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let old_server_path = container_dir.join(Self::OLD_SERVER_PATH); let new_server_path = container_dir.join(Self::NEW_SERVER_PATH); @@ -204,12 +208,13 @@ impl LspAdapter for EsLintLspAdapter { async fn fetch_latest_server_version( &self, - http: Arc, + delegate: &dyn LspAdapterDelegate, ) -> Result> { // At the time of writing the latest vscode-eslint release was released in 2020 and requires // special custom LSP protocol extensions be handled to fully initialize. Download the latest // prerelease instead to sidestep this issue - let release = latest_github_release("microsoft/vscode-eslint", true, http).await?; + let release = + latest_github_release("microsoft/vscode-eslint", true, delegate.http_client()).await?; Ok(Box::new(GitHubLspBinaryVersion { name: release.name, url: release.tarball_url, @@ -219,8 +224,8 @@ impl LspAdapter for EsLintLspAdapter { async fn fetch_server_binary( &self, version: Box, - http: Arc, container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let destination_path = container_dir.join(format!("vscode-eslint-{}", version.name)); @@ -229,7 +234,8 @@ impl LspAdapter for EsLintLspAdapter { if fs::metadata(&server_path).await.is_err() { remove_matching(&container_dir, |entry| entry != destination_path).await; - let mut response = http + let mut response = delegate + .http_client() .get(&version.url, Default::default(), true) .await .map_err(|err| anyhow!("error downloading release: {}", err))?; @@ -257,7 +263,11 @@ impl LspAdapter for EsLintLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { // This is unfortunate but we don't know what the version is to build a path directly let mut dir = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/languages/yaml.rs b/crates/zed/src/languages/yaml.rs index 7f87a7caed..99c226bba7 100644 --- a/crates/zed/src/languages/yaml.rs +++ b/crates/zed/src/languages/yaml.rs @@ -4,6 +4,7 @@ use futures::{future::BoxFuture, FutureExt, StreamExt}; use gpui::AppContext; use language::{ language_settings::all_language_settings, LanguageServerBinary, LanguageServerName, LspAdapter, + LspAdapterDelegate, }; use node_runtime::NodeRuntime; use serde_json::Value; @@ -15,7 +16,6 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use util::http::HttpClient; use util::ResultExt; fn server_binary_arguments(server_path: &Path) -> Vec { @@ -42,7 +42,7 @@ impl LspAdapter for YamlLspAdapter { async fn fetch_latest_server_version( &self, - _: Arc, + _: &dyn LspAdapterDelegate, ) -> Result> { Ok(Box::new( self.node @@ -54,8 +54,8 @@ impl LspAdapter for YamlLspAdapter { async fn fetch_server_binary( &self, version: Box, - _: Arc, container_dir: PathBuf, + _: &dyn LspAdapterDelegate, ) -> Result { let version = version.downcast::().unwrap(); let server_path = container_dir.join(Self::SERVER_PATH); @@ -72,7 +72,11 @@ impl LspAdapter for YamlLspAdapter { }) } - async fn cached_server_binary(&self, container_dir: PathBuf) -> Option { + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { (|| async move { let mut last_version_dir = None; let mut entries = fs::read_dir(&container_dir).await?; diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 2393d0df3b..23ecbf978b 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -31,7 +31,6 @@ use std::{ ffi::OsStr, fs::OpenOptions, io::Write as _, - ops::Not, os::unix::prelude::OsStrExt, panic, path::{Path, PathBuf}, @@ -155,7 +154,6 @@ fn main() { search::init(cx); vim::init(cx); terminal_view::init(cx); - theme_testbench::init(cx); copilot::init(http.clone(), node_runtime, cx); ai::init(cx); @@ -191,7 +189,7 @@ fn main() { language_selector::init(cx); theme_selector::init(cx); activity_indicator::init(cx); - lsp_log::init(cx); + language_tools::init(cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); collab_ui::init(&app_state, cx); feedback::init(cx); @@ -373,7 +371,6 @@ struct Panic { os_version: Option, architecture: String, panicked_on: u128, - identifying_backtrace: Option>, } #[derive(Serialize)] @@ -401,61 +398,18 @@ fn init_panic_hook(app: &App) { .unwrap_or_else(|| "Box".to_string()); let backtrace = Backtrace::new(); - let backtrace = backtrace + let mut backtrace = backtrace .frames() .iter() - .filter_map(|frame| { - let symbol = frame.symbols().first()?; - let path = symbol.filename()?; - Some((path, symbol.lineno(), format!("{:#}", symbol.name()?))) - }) + .filter_map(|frame| Some(format!("{:#}", frame.symbols().first()?.name()?))) .collect::>(); - let this_file_path = Path::new(file!()); - - // Find the first frame in the backtrace for this panic hook itself. Exclude - // that frame and all frames before it. - let mut start_frame_ix = 0; - let mut codebase_root_path = None; - for (ix, (path, _, _)) in backtrace.iter().enumerate() { - if path.ends_with(this_file_path) { - start_frame_ix = ix + 1; - codebase_root_path = path.ancestors().nth(this_file_path.components().count()); - break; - } - } - - // Exclude any subsequent frames inside of rust's panic handling system. - while let Some((path, _, _)) = backtrace.get(start_frame_ix) { - if path.starts_with("/rustc") { - start_frame_ix += 1; - } else { - break; - } - } - - // Build two backtraces: - // * one for display, which includes symbol names for all frames, and files - // and line numbers for symbols in this codebase - // * one for identification and de-duplication, which only includes symbol - // names for symbols in this codebase. - let mut display_backtrace = Vec::new(); - let mut identifying_backtrace = Vec::new(); - for (path, line, symbol) in &backtrace[start_frame_ix..] { - display_backtrace.push(symbol.clone()); - - if let Some(codebase_root_path) = &codebase_root_path { - if let Ok(suffix) = path.strip_prefix(&codebase_root_path) { - identifying_backtrace.push(symbol.clone()); - - let display_path = suffix.to_string_lossy(); - if let Some(line) = line { - display_backtrace.push(format!(" {display_path}:{line}")); - } else { - display_backtrace.push(format!(" {display_path}")); - } - } - } + // Strip out leading stack frames for rust panic-handling. + if let Some(ix) = backtrace + .iter() + .position(|name| name == "rust_begin_unwind") + { + backtrace.drain(0..=ix); } let panic_data = Panic { @@ -477,29 +431,27 @@ fn init_panic_hook(app: &App) { .duration_since(UNIX_EPOCH) .unwrap() .as_millis(), - backtrace: display_backtrace, - identifying_backtrace: identifying_backtrace - .is_empty() - .not() - .then_some(identifying_backtrace), + backtrace, }; - if let Some(panic_data_json) = serde_json::to_string_pretty(&panic_data).log_err() { - if is_pty { + if is_pty { + if let Some(panic_data_json) = serde_json::to_string_pretty(&panic_data).log_err() { eprintln!("{}", panic_data_json); return; } - - let timestamp = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string(); - let panic_file_path = paths::LOGS_DIR.join(format!("zed-{}.panic", timestamp)); - let panic_file = std::fs::OpenOptions::new() - .append(true) - .create(true) - .open(&panic_file_path) - .log_err(); - if let Some(mut panic_file) = panic_file { - write!(&mut panic_file, "{}", panic_data_json).log_err(); - panic_file.flush().log_err(); + } else { + if let Some(panic_data_json) = serde_json::to_string(&panic_data).log_err() { + let timestamp = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string(); + let panic_file_path = paths::LOGS_DIR.join(format!("zed-{}.panic", timestamp)); + let panic_file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(&panic_file_path) + .log_err(); + if let Some(mut panic_file) = panic_file { + writeln!(&mut panic_file, "{}", panic_data_json).log_err(); + panic_file.flush().log_err(); + } } } })); @@ -531,23 +483,45 @@ fn upload_previous_panics(http: Arc, cx: &mut AppContext) { } if telemetry_settings.diagnostics { - let panic_data_text = smol::fs::read_to_string(&child_path) + let panic_file_content = smol::fs::read_to_string(&child_path) .await .context("error reading panic file")?; - let body = serde_json::to_string(&PanicRequest { - panic: serde_json::from_str(&panic_data_text)?, - token: ZED_SECRET_CLIENT_TOKEN.into(), - }) - .unwrap(); + let panic = serde_json::from_str(&panic_file_content) + .ok() + .or_else(|| { + panic_file_content + .lines() + .next() + .and_then(|line| serde_json::from_str(line).ok()) + }) + .unwrap_or_else(|| { + log::error!( + "failed to deserialize panic file {:?}", + panic_file_content + ); + None + }); - let request = Request::post(&panic_report_url) - .redirect_policy(isahc::config::RedirectPolicy::Follow) - .header("Content-Type", "application/json") - .body(body.into())?; - let response = http.send(request).await.context("error sending panic")?; - if !response.status().is_success() { - log::error!("Error uploading panic to server: {}", response.status()); + if let Some(panic) = panic { + let body = serde_json::to_string(&PanicRequest { + panic, + token: ZED_SECRET_CLIENT_TOKEN.into(), + }) + .unwrap(); + + let request = Request::post(&panic_report_url) + .redirect_policy(isahc::config::RedirectPolicy::Follow) + .header("Content-Type", "application/json") + .body(body.into())?; + let response = + http.send(request).await.context("error sending panic")?; + if !response.status().is_success() { + log::error!( + "Error uploading panic to server: {}", + response.status() + ); + } } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index d15bace554..bcdfe57a46 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -254,13 +254,6 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { workspace.toggle_panel_focus::(cx); }, ); - cx.add_action( - |workspace: &mut Workspace, - _: &ai::assistant::ToggleFocus, - cx: &mut ViewContext| { - workspace.toggle_panel_focus::(cx); - }, - ); cx.add_global_action({ let app_state = Arc::downgrade(&app_state); move |_: &NewWindow, cx: &mut AppContext| { @@ -312,8 +305,11 @@ pub fn initialize_workspace( let feedback_info_text = cx.add_view(|_| FeedbackInfoText::new()); toolbar.add_item(feedback_info_text, cx); let lsp_log_item = - cx.add_view(|_| lsp_log::LspLogToolbarItemView::new()); + cx.add_view(|_| language_tools::LspLogToolbarItemView::new()); toolbar.add_item(lsp_log_item, cx); + let syntax_tree_item = cx + .add_view(|_| language_tools::SyntaxTreeToolbarItemView::new()); + toolbar.add_item(syntax_tree_item, cx); }) }); } @@ -365,9 +361,12 @@ pub fn initialize_workspace( let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); - let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); - let (project_panel, terminal_panel, assistant_panel) = - futures::try_join!(project_panel, terminal_panel, assistant_panel)?; + let assistant_panel = if *util::channel::RELEASE_CHANNEL == ReleaseChannel::Stable { + None + } else { + Some(AssistantPanel::load(workspace_handle.clone(), cx.clone()).await?) + }; + let (project_panel, terminal_panel) = futures::try_join!(project_panel, terminal_panel)?; workspace_handle.update(&mut cx, |workspace, cx| { let project_panel_position = project_panel.position(cx); workspace.add_panel(project_panel, cx); @@ -385,8 +384,12 @@ pub fn initialize_workspace( workspace.toggle_dock(project_panel_position, cx); } + cx.focus_self(); + workspace.add_panel(terminal_panel, cx); - workspace.add_panel(assistant_panel, cx); + if let Some(assistant_panel) = assistant_panel { + workspace.add_panel(assistant_panel, cx); + } })?; Ok(()) }) diff --git a/docs/backend-development.md b/docs/backend-development.md new file mode 100644 index 0000000000..59100d3628 --- /dev/null +++ b/docs/backend-development.md @@ -0,0 +1,52 @@ +[⬅ Back to Index](./index.md) + +# Developing Zed's Backend + +Zed's backend consists of the following components: + +- The Zed.dev web site + - implemented in the [`zed.dev`](https://github.com/zed-industries/zed.dev) repository + - hosted on [Vercel](https://vercel.com/zed-industries/zed-dev). +- The Zed Collaboration server + - implemented in the [`crates/collab`](https://github.com/zed-industries/zed/tree/main/crates/collab) directory of the main `zed` repository + - hosted on [DigitalOcean](https://cloud.digitalocean.com/projects/6c680a82-9d3b-4f1a-91e5-63a6ca4a8611), using Kubernetes +- The Zed Postgres database + - defined via migrations in the [`crates/collab/migrations`](https://github.com/zed-industries/zed/tree/main/crates/collab/migrations) directory + - hosted on DigitalOcean + +--- + +## Local Development + +Here's some things you need to develop backend code locally. + +### Dependencies + +- **Postgres** - download [Postgres.app](https://postgresapp.com). + +### Setup + +1. Check out the `zed` and `zed.dev` repositories into a common parent directory +2. Set the `GITHUB_TOKEN` environment variable to one of your GitHub personal access tokens (PATs). + + - You can create a PAT [here](https://github.com/settings/tokens). + - You may want to add something like this to your `~/.zshrc`: + + ``` + export GITHUB_TOKEN= + ``` + +3. In the `zed.dev` directory, run `npm install` to install dependencies. +4. In the `zed directory`, run `script/bootstrap` to set up the database +5. In the `zed directory`, run `foreman start` to start both servers + +--- + +## Production Debugging + +### Datadog + +Zed uses Datadog to collect metrics and logs from backend services. The Zed organization lives within Datadog's _US5_ [site](https://docs.datadoghq.com/getting_started/site/), so it can be accessed at [us5.datadoghq.com](https://us5.datadoghq.com). Useful things to look at in Datadog: + +- The [Logs](https://us5.datadoghq.com/logs) page shows logs from Zed.dev and the Collab server, and the internals of Zed's Kubernetes cluster. +- The [collab metrics dashboard](https://us5.datadoghq.com/dashboard/y2d-gxz-h4h/collab?from_ts=1660517946462&to_ts=1660604346462&live=true) shows metrics about the running collab server diff --git a/docs/building-zed.md b/docs/building-zed.md new file mode 100644 index 0000000000..78653571ad --- /dev/null +++ b/docs/building-zed.md @@ -0,0 +1,79 @@ +[⬅ Back to Index](./index.md) + +# Building Zed + +How to build Zed from source for the first time. + +## Process + +Expect this to take 30min to an hour! Some of these steps will take quite a while based on your connection speed, and how long your first build will be. + +1. Install the [GitHub CLI](https://cli.github.com/): + - `brew install gh` +1. Clone the `zed` repo + - `gh repo clone zed-industries/zed` +1. Install Xcode from the macOS App Store +1. Install [Postgres](https://postgresapp.com) +1. Install rust/rustup + - `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` +1. Install the wasm toolchain + - `rustup target add wasm32-wasi` +1. Generate an GitHub API Key + - Go to https://github.com/settings/tokens and Generate new token + - GitHub currently provides two kinds of tokens: + - Classic Tokens, where only `repo` (Full control of private repositories) OAuth scope has to be selected + Unfortunately, unselecting `repo` scope and selecting every its inner scope instead does not allow the token users to read from private repositories + - (not applicable) Fine-grained Tokens, at the moment of writing, did not allow any kind of access of non-owned private repos + - Keep the token in the browser tab/editor for the next two steps +1. Open Postgres.app +1. From `./path/to/zed/`: + - Run: + - `GITHUB_TOKEN={yourGithubAPIToken} script/bootstrap` + - Replace `{yourGithubAPIToken}` with the API token you generated above. + - Consider removing the token (if it's fine for you to crecreate such tokens during occasional migrations) or store this token somewhere safe (like your Zed 1Password vault). + - If you get: + - ```bash + Error: Cannot install in Homebrew on ARM processor in Intel default prefix (/usr/local)! + Please create a new installation in /opt/homebrew using one of the + "Alternative Installs" from: + https://docs.brew.sh/Installation + ``` + - In that case try: + - `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` + - If Homebrew is not in your PATH: + - Replace `{username}` with your home folder name (usually your login name) + - `echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> /Users/{username}/.zprofile` + - `eval "$(/opt/homebrew/bin/brew shellenv)"` +1. To run the Zed app: + - If you are working on zed: + - `cargo run` + - If you are just using the latest version, but not working on zed: + - `cargo run --release` + - If you need to run the collaboration server locally: + - `script/zed-with-local-servers` + +## Troubleshooting + +### `error: failed to run custom build command for gpui v0.1.0 (/Users/path/to/zed)` + +- Try `xcode-select --switch /Applications/Xcode.app/Contents/Developer` + +### `xcrun: error: unable to find utility "metal", not a developer tool or in PATH` + +### Seeding errors during `script/bootstrap` runs + +``` +seeding database... +thread 'main' panicked at 'failed to deserialize github user from 'https://api.github.com/orgs/zed-industries/teams/staff/members': reqwest::Error { kind: Decode, source: Error("invalid type: map, expected a sequence", line: 1, column: 0) }', crates/collab/src/bin/seed.rs:111:10 +``` + +Wrong permissions for `GITHUB_TOKEN` token used, the token needs to be able to read from private repos. +For Classic GitHub Tokens, that required OAuth scope `repo` (seacrh the scope name above for more details) + +Same command + +`sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` + +### If you experience errors that mention some dependency is using unstable features + +Try `cargo clean` and `cargo build` diff --git a/docs/company-and-vision.md b/docs/company-and-vision.md new file mode 100644 index 0000000000..389d0774a7 --- /dev/null +++ b/docs/company-and-vision.md @@ -0,0 +1,34 @@ +[⬅ Back to Index](./index.md) + +# Company & Vision + +## Vision + +Our goal is to make Zed the primary tool software teams use to collaborate. + +To do this, Zed will... + +* Make collaboration a first-class feature of the code authoring environment. +* Enable text-based conversations about any piece of text, independent of whether/when it was committed to version control. +* Make it smooth to edit and discuss code with teammates in real time. +* Make it easy to recall past conversations any area of the code. + +We believe the best way to make collaboration amazing is to build it into a new editor rather than retrofitting an existing editor. This means that in order for a team to adopt Zed for collaboration, each team member will need to adopt it as their editor as well. + +For this reason, we need to deliver a clearly superior experience as a single-user code editor in addition to being an excellent collaboration tool. This will take time, but we believe the dominance of VS Code demonstrates that it's possible for a single tool to capture substantial market share. We can proceed incrementally, capturing one team at a time and gradually transitioning conversations away from GitHub. + +## Zed Values + +Everyone wants to work quickly and have a lot of users. What are we unwilling to sacrifice in pursuit of those goals? + +- **Performance.** Speed is core to our brand and value proposition. It's important that we consistently deliver a response in less than 8ms on modern hardware for fine-grained actions. Coarse-grained actions should render feedback within 50ms. We consider the performance goals of the product at all times, and take the time to ensure our code meets them with reasonable usage. Once we have met our goals, we assess the impact vs effort of further performance investment and know when to say when. We measure our performance in the field and make an effort to maintain or improve real-world performance and promptly address regressions. + +- **Craftsmanship.** Zed is a premium product, and we put care into design and user experience. We can always cut scope, but what we do ship should be quality. Incomplete is okay, so long as we're executing on a coherent subset well. Half-baked, unintuitive, or broken is not okay. + +- **Shipping.** Knowledge matters only in as much as it drives results. We're here to build a real product in the real world. We care a lot about the experience of developing Zed, but we care about the user's experience more. + +- **Code quality.** This enables craftsmanship. Nobody is creative in a trash heap, and we're willing to dedicate time to keep our codebase clean. If we're spending no time refactoring, we are likely underinvesting. When we realize a design flaw, we assess its centrality to the rest of the system and consider budgeting time to address it. If we're spending all of our time refactoring, we are likely either overinvesting or paying off debt from past underinvestment. It's up to each engineer to allocate a reasonable refactoring budget. We shouldn't be navel gazing, but we also shouldn't be afraid to invest. + +- **Pairing.** Zed depends on regular pair programming to promote cohesion on our remote team. We believe pairing is a powerful substitute for beuracratic management, excessive documentation, and tedious code review. Nobody has to pair all day, every day, but everyone is responsible for pairing at least 2 hours a week with a variety of other engineers. If anyone wants to pair all day every day, that is explicitly endorsed and credited. If pairing temporarily reduces our throughput due to working on one thing instead of two, we trust that it will pay for itself in the long term by increasing our velocity and allowing us to more effectively grow our team. + +- **Long-term thinking.** The Zed vision began several years ago, and we expect Zed to be around many years from today. We must always be mindful to avoid overengineering for the future, but we should also keep the long-term in mind. Are we building a system our future selves would want to work on in 5 years? diff --git a/docs/design-tools.md b/docs/design-tools.md new file mode 100644 index 0000000000..70e5459811 --- /dev/null +++ b/docs/design-tools.md @@ -0,0 +1,74 @@ +[⬅ Back to Index](./index.md) + +# Design Tools & Links + +Generally useful tools and resources for design. + +## General + +[Names of Signs & Symbols](https://www.prepressure.com/fonts/basics/character-names#curlybrackets) + +[The Noun Project](https://thenounproject.com/) - Icons for everything, attempts to describe all of human language visually. + +[SVG Repo](https://www.svgrepo.com/) - Open-licensed SVG Vector and Icons + +[Font Awsesome](https://fontawesome.com/) - High quality icons, has been around for many years. + +--- + +## Color + +[Opacity/Transparency Hex Values](https://gist.github.com/lopspower/03fb1cc0ac9f32ef38f4) + +[Color Ramp Generator](https://lyft-colorbox.herokuapp.com) + +[Designing a Comprehensive Color System +](https://www.rethinkhq.com/videos/designing-a-comprehensive-color-system-for-lyft) - [Linda Dong](https://twitter.com/lindadong) + +--- + +## Figma & Plugins + +[Figma Plugins for Designers](https://www.uiprep.com/blog/21-best-figma-plugins-for-designers-in-2021) + +[Icon Resizer](https://www.figma.com/community/plugin/739117729229117975/Icon-Resizer) + +[Code Syntax Highlighter](https://www.figma.com/community/plugin/938793197191698232/Code-Syntax-Highlighter) + +[Proportional Scale](https://www.figma.com/community/plugin/756895186298946525/Proportional-Scale) + +[LilGrid](https://www.figma.com/community/plugin/795397421598343178/LilGrid) + +Organize your selection into a grid. + +[Automator](https://www.figma.com/community/plugin/1005114571859948695/Automator) + +Build photoshop-style batch actions to automate things. + +[Figma Tokens](https://www.figma.com/community/plugin/843461159747178978/Figma-Tokens) + +Use tokens in Figma and generate JSON from them. + +--- + +## Design Systems + +### Naming + +[Naming Design Tokens](https://uxdesign.cc/naming-design-tokens-9454818ed7cb) + +### Storybook + +[Collaboration with design tokens and storybook](https://zure.com/blog/collaboration-with-design-tokens-and-storybook/) + +### Example DS Documentation + +[Tailwind CSS Documentation](https://tailwindcss.com/docs/container) + +[Material Design Docs](https://material.io/design/color/the-color-system.html#color-usage-and-palettes) + +[Carbon Design System Docs](https://www.carbondesignsystem.com) + +[Adobe Spectrum](https://spectrum.adobe.com/) + - Great documentation, like [Color System](https://spectrum.adobe.com/page/color-system/) and [Design Tokens](https://spectrum.adobe.com/page/design-tokens/). + - A good place to start if thinking about building a design system. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..0fc5bfbc89 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,14 @@ +[⬅ Back to Index](./index.md) + +# Welcome to Zed + +Welcome! These internal docs are a work in progress. You can contribute to them by submitting a PR directly! + +## Contents + +- [The Company](./company-and-vision.md) +- [Tools We Use](./tools.md) +- [Building Zed](./building-zed.md) +- [Release Process](./release-process.md) +- [Backend Development](./backend-development.md) +- [Design Tools & Links](./design-tools.md) diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000000..ce43d647bd --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,96 @@ +[⬅ Back to Index](./index.md) + +# Zed's Release Process + +The process to create and ship a Zed release + +## Overview + +### Release Channels + +Users of Zed can choose between two _release channels_ - 'Stable' and 'Preview'. Most people use Stable, but Preview exists so that the Zed team and other early-adopters can test new features before they are released to our general user-base. + +### Weekly (Minor) Releases + +We normally publish new releases of Zed on Wednesdays, for both the Stable and Preview channels. For each of these releases, we bump Zed's _minor_ version number. + +For the Preview channel, we build the new release based on what's on the `main` branch. For the Stable channel, we build the new release based on the last Preview release. + +### Hotfix (Patch) Releases + +When we find a _regression_ in Zed (a bug that wasn't present in an earlier version), or find a significant bug in a newly-released feature, we typically publish a hotfix release. For these releases, we bump Zed's _patch_ version number. + +### Server Deployments + +Often, changes in the Zed app require corresponding changes in the `collab` server. At the currente stage of our copmany, we don't attempt to keep our server backwards-compatible with older versions of the app. Instead, when making a change, we simply bump Zed's _protocol version_ number (in the `rpc` crate), which causes the server to recognize that it isn't compatible with earlier versions of the Zed app. + +This means that when releasing a new version of Zed that has changes to the RPC protocol, we need to deploy a new version of the `collab` server at the same time. + +## Instructions + +### Publishing a Minor Release + +1. Announce your intent to publish a new version in Discord. This gives other people a chance to raise concerns or postpone the release if they want to get something merged before publishing a new version. +1. Open your terminal and `cd` into your local copy of Zed. Checkout `main` and perform a `git pull` to ensure you have the latest version. +1. Run the following command, which will update two git branches and two git tags (one for each release channel): + + ``` + script/bump-zed-minor-versions + ``` + +1. The script will make local changes only, and print out a shell command that you can use to push all of these branches and tags. +1. Pushing the two new tags will trigger two CI builds that, when finished, will create two draft releases (Stable and Preview) containing `Zed.dmg` files. +1. Now you need to write the release notes for the Stable and Preview releases. For the Stable release, you can just copy the release notes from the last week's Preview release, plus any hotfixes that were published on the Preview channel since then. Some of the hotfixes may not be relevant for the Stable release notes, if they were fixing bugs that were only present in Preview. +1. For the Preview release, you can retrieve the list of changes by running this command (make sure you have at least `Node 18` installed): + + ``` + GITHUB_ACCESS_TOKEN=your_access_token script/get-preview-channel-changes + ``` + +1. The script will list all the merged pull requests and you can use it as a reference to write the release notes. If there were protocol changes, it will also emit a warning. +1. Once CI creates the draft releases, add each release's notes and save the drafts. +1. If there have been server-side changes since the last release, you'll need to re-deploy the `collab` server. See below. +1. Before publishing, download the Zed.dmg and smoke test it to ensure everything looks good. + +### Publishing a Patch Release + +1. Announce your intent to publish a new patch version in Discord. +1. Open your terminal and `cd` into your local copy of Zed. Check out the branch corresponding to the release channel where the fix is needed. For example, if the fix is for a bug in Stable, and the current stable version is `0.63.0`, then checkout the branch `v0.63.x`. Run `git pull` to ensure your branch is up-to-date. +1. Find the merge commit where your bug-fix landed on `main`. You can browse the merged pull requests on main by running `git log main --grep Merge`. +1. Cherry-pick those commits onto the current release branch: + + ``` + git cherry-pick -m1 + ``` + +1. Run the following command, which will bump the version of Zed and create a new tag: + + ``` + script/bump-zed-patch-version + ``` + +1. The script will make local changes only, and print out a shell command that you can use to push all the branch and tag. +1. Pushing the new tag will trigger a CI build that, when finished, will create a draft release containing a `Zed.dmg` file. +1. Once the draft release is created, fill in the release notes based on the bugfixes that you cherry-picked. +1. If any of the bug-fixes require server-side changes, you'll need to re-deploy the `collab` server. See below. +1. Before publishing, download the Zed.dmg and smoke test it to ensure everything looks good. +1. Clicking publish on the release will cause old Zed instances to auto-update and the Zed.dev releases page to re-build and display the new release. + +### Deploying the Server + +1. Deploying the server is a two-step process that begins with pushing a tag. 1. Check out the commit you'd like to deploy. Often it will be the head of `main`, but could be on any branch. +1. Run the following script, which will bump the version of the `collab` crate and create a new tag. The script takes an argument `minor` or `patch`, to indicate how to increment the version. If you're releasing new features, use `minor`. If it's just a bugfix, use `patch` + + ``` + script/bump-collab-version patch + ``` + +1. This script will make local changes only, and print out a shell command that you can use to push the branch and tag. +1. Pushing the new tag will trigger a CI build that, when finished will upload a new versioned docker image to the DigitalOcean docker registry. +1. Once that CI job completes, you will be able to run the following command to deploy that docker image. The script takes two arguments: an environment (`production`, `preview`, or `staging`), and a version number (e.g. `0.10.1`). + + ``` + script/deploy preview 0.10.1 + ``` + +1. This command should complete quickly, updating the given environment to use the given version number of the `collab` server. diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000000..6e424a6f81 --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,82 @@ +[⬅ Back to Index](./index.md) + +# Tools + +Tools to get started at Zed. Work in progress, submit a PR to add any missing tools here! + +--- + +## Everyday Tools + +### Calendar + +To gain access to company calendar, visit [this link](https://calendar.google.com/calendar/u/0/r?cid=Y18xOGdzcGE1aG5wdHJocGRoNWtlb2tlbWxzc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t). + +If you would like the company calendar to be synced with a calendar application (Apple Calendar, etc.): + +- Add your company account (i.e `joseph@zed.dev`) to your calendar application +- Visit [this link](https://calendar.google.com/calendar/u/0/syncselect), check `Zed Industries (Read Only)` under `Shared Calendars`, and save it. + +### 1Password + +We have a shared company 1Password with all of our credentials. To gain access: + +1. Go to [zed-industries.1password.com](https://zed-industries.1password.com). +1. Sign in with your `@zed.dev` email address. +1. Make your account and let an admin know you've signed up. +1. Once they approve your sign up, you'll have access to all of the company credentials. + +### Slack + +Have a team member add you to the [Zed Industries](https://zed-industries.slack.com/) slack. + +### Discord + +We have a discord community. You can use [this link](https://discord.gg/SSD9eJrn6s) to join. **!Don't share this link, this is specifically for team memebers!** + +Once you have joined the community, let a team member know and we can add your correct role. + +--- + +## Engineering + +### Github + +For now, all the Zed source code lives on [Github](https://github.com/zed-industries). A founder will need to add you to the team and set up the appropriate permissions. + +Useful repos: +- [zed-industries/zed](https://github.com/zed-industries/zed) - Zed source +- [zed-industries/zed.dev](https://github.com/zed-industries/zed.dev) - Zed.dev site and collab API +- [zed-industries/docs](https://github.com/zed-industries/docs) - Zed public docs +- [zed-industries/community](https://github.com/zed-industries/community) - Zed community feedback & discussion + +### Vercel + +We use Vercel for all of our web deployments and some backend things. If you sign up with your `@zed.dev` email you should be invited to join the team automatically. If not, ask a founder to invite you to the Vercel team. + +### Environment Variables + +You can get access to many of our shared enviroment variables through 1Password and Vercel. For one password search the value you are looking for, or sort by passwords or API credentials. + +For Vercel, go to `settings` -> `Environment Variables` (either on the entire org, or on a specific project depending on where it is shared.) For a given Vercel project if you have their CLI installed you can use `vercel pull` or `vercel env` to pull values down directly. More on those in their [CLI docs](https://vercel.com/docs/cli/env). + +--- + +## Design + +### Figma + +We use Figma for all of our design work. To gain access: + +1. Use [this link](https://www.figma.com/team_invite/redeem/Xg4RcNXHhwP5netIvVBmKQ) to join the Figma team. +1. You should now have access to all of the company files. +1. You should go to the team page and "favorite" (star) any relevant projects so they show up in your sidebar. +1. Download the [Figma app](https://www.figma.com/downloads/) for easier access on desktop. + +### Campsite + +We use Campsite to review and discuss designs. To gain access: + +1. Download the [Campsite app](https://campsite.design/desktop/download). +1. Open it and sign in with your `@zed.dev` email address. +1. You can access our company campsite directly: [app.campsite.design/zed](https://app.campsite.design/zed) diff --git a/docs/zed/syntax-highlighting.md b/docs/zed/syntax-highlighting.md new file mode 100644 index 0000000000..3878fcc6e9 --- /dev/null +++ b/docs/zed/syntax-highlighting.md @@ -0,0 +1,79 @@ +# Syntax Highlighting in Zed + +This doc is a work in progress! + +## Defining syntax highlighting rules + +We use tree-sitter queries to match certian properties to highlight. + +### Simple Example: + +```scheme +(property_identifier) @property +``` + +```ts +const font: FontFamily = { + weight: "normal", + underline: false, + italic: false, +} +``` + +Match a property identifier and highlight it using the identifier `@property`. In the above example, `weight`, `underline`, and `italic` would be highlighted. + +### Complex example: + +```scheme +(_ + return_type: (type_annotation + [ + (type_identifier) @type.return + (generic_type + name: (type_identifier) @type.return) + ])) +``` + +```ts +function buildDefaultSyntax(colorScheme: ColorScheme): Partial { + // ... +} +``` + +Match a function return type, and highlight the type using the identifier `@type.return`. In the above example, `Partial` would be highlighted. + +### Example - Typescript + +Here is an example portion of our `highlights.scm` for TypeScript: + +```scheme +; crates/zed/src/languages/typescript/highlights.scm + +; Variables + +(identifier) @variable + +; Properties + +(property_identifier) @property + +; Function and method calls + +(call_expression + function: (identifier) @function) + +(call_expression + function: (member_expression + property: (property_identifier) @function.method)) + +; Function and method definitions + +(function + name: (identifier) @function) +(function_declaration + name: (identifier) @function) +(method_definition + name: (property_identifier) @function.method) + +; ... +``` diff --git a/script/bundle b/script/bundle index 2f69d4e67e..9f50862cd5 100755 --- a/script/bundle +++ b/script/bundle @@ -23,7 +23,11 @@ done export ZED_BUNDLE=true export MACOSX_DEPLOYMENT_TARGET=10.15.7 -which cargo-bundle > /dev/null || cargo install cargo-bundle --version 0.5.0 +cargo_bundle_version=$(cargo -q bundle --help 2>&1 | head -n 1 || echo "") +if [ "$cargo_bundle_version" != "cargo-bundle v0.6.0-zed" ]; then + cargo install cargo-bundle --git https://github.com/zed-industries/cargo-bundle.git --branch zed-deploy +fi + rustup target add wasm32-wasi # Deal with versions of macOS that don't include libstdc++ headers @@ -46,9 +50,7 @@ sed \ -i .backup \ "s/package.metadata.bundle-${channel}/package.metadata.bundle/" \ Cargo.toml -app_path=$(cargo bundle ${build_flag} --target x86_64-apple-darwin | xargs) - -echo app_path +app_path=$(cargo bundle ${build_flag} --target x86_64-apple-darwin --select-workspace-root | xargs) mv Cargo.toml.backup Cargo.toml popd @@ -70,13 +72,6 @@ echo "Copying WebRTC.framework into the frameworks folder" mkdir "${app_path}/Contents/Frameworks" cp -R target/x86_64-apple-darwin/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/" -mv "${app_path}/Contents/Info.plist" "${app_path}/Contents/WithoutDocumentTypes.plist" -awk \ - "/<\/dict>/{while(getline line<\"./crates/zed/BundleDocumentTypes.plist\"){print line}}1" \ - "${app_path}/Contents/WithoutDocumentTypes.plist" \ - > "${app_path}/Contents/Info.plist" -rm "${app_path}/Contents/WithoutDocumentTypes.plist" - if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then echo "Signing bundle with Apple-issued certificate" security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain || echo "" @@ -86,12 +81,12 @@ if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTAR security import /tmp/zed-certificate.p12 -k zed.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign rm /tmp/zed-certificate.p12 security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" zed.keychain - /usr/bin/codesign --force --deep --timestamp --options runtime --sign "Zed Industries, Inc." "${app_path}" -v + /usr/bin/codesign --force --deep --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "Zed Industries, Inc." "${app_path}" -v security default-keychain -s login.keychain else echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_USERNAME, APPLE_NOTARIZATION_PASSWORD" echo "Performing an ad-hoc signature, but this bundle should not be distributed" - codesign --force --deep --sign - "${app_path}" -v + codesign --force --deep --entitlements crates/zed/resources/zed.entitlements --sign - "${app_path}" -v fi if [ "$target_dir" = "debug" ]; then diff --git a/styles/.gitignore b/styles/.gitignore index c2658d7d1b..25fbf5a1c4 100644 --- a/styles/.gitignore +++ b/styles/.gitignore @@ -1 +1,2 @@ node_modules/ +coverage/ diff --git a/styles/.zed/settings.json b/styles/.zed/settings.json new file mode 100644 index 0000000000..5c31fc5ac1 --- /dev/null +++ b/styles/.zed/settings.json @@ -0,0 +1,20 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://docs.zed.dev/configuration/configuring-zed#folder-specific-settings +{ + "languages": { + "TypeScript": { + "tab_size": 4 + }, + "TSX": { + "tab_size": 4 + }, + "JavaScript": { + "tab_size": 4 + }, + "JSON": { + "tab_size": 4 + } + } +} diff --git a/styles/package-lock.json b/styles/package-lock.json deleted file mode 100644 index 85855d9064..0000000000 --- a/styles/package-lock.json +++ /dev/null @@ -1,1251 +0,0 @@ -{ - "name": "styles", - "version": "1.0.0", - "lockfileVersion": 2, - "requires": true, - "packages": { - "": { - "name": "styles", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@tokens-studio/types": "^0.2.3", - "@types/chroma-js": "^2.4.0", - "@types/node": "^18.14.1", - "ayu": "^8.0.1", - "bezier-easing": "^2.1.0", - "case-anything": "^2.1.10", - "chroma-js": "^2.4.2", - "deepmerge": "^4.3.0", - "json-schema-to-typescript": "^13.0.2", - "toml": "^3.0.0", - "ts-node": "^10.9.1" - } - }, - "node_modules/@bcherny/json-schema-ref-parser": { - "version": "10.0.5-fork", - "resolved": "https://registry.npmjs.org/@bcherny/json-schema-ref-parser/-/json-schema-ref-parser-10.0.5-fork.tgz", - "integrity": "sha512-E/jKbPoca1tfUPj3iSbitDZTGnq6FUFjkH6L8U2oDwSuwK1WhnnVtCG7oFOTg/DDnyoXbQYUiUiGOibHqaGVnw==", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/philsturgeon" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" - }, - "node_modules/@tokens-studio/types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.2.3.tgz", - "integrity": "sha512-2KN3V0JPf+Zh8aoVMwykJq29Lsi7vYgKGYBQ/zQ+FbDEmrH6T/Vwn8kG7cvbTmW1JAAvgxVxMIivgC9PmFelNA==" - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==" - }, - "node_modules/@types/chroma-js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.0.tgz", - "integrity": "sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw==" - }, - "node_modules/@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==" - }, - "node_modules/@types/lodash": { - "version": "4.14.195", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", - "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==" - }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==" - }, - "node_modules/@types/node": { - "version": "18.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz", - "integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ==" - }, - "node_modules/@types/prettier": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", - "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==" - }, - "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/ayu": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ayu/-/ayu-8.0.1.tgz", - "integrity": "sha512-yuPZ2kZYQoYaPRQ/78F9rXDVx1rVGCJ1neBYithBoSprD6zPdIJdAKizUXG0jtTBu7nTFyAnVFFYuLnCS3cpDw==", - "dependencies": { - "@types/chroma-js": "^2.0.0", - "chroma-js": "^2.1.0", - "nonenumerable": "^1.1.1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/bezier-easing": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", - "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==" - }, - "node_modules/case-anything": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.10.tgz", - "integrity": "sha512-JczJwVrCP0jPKh05McyVsuOg6AYosrB9XWZKbQzXeDAm2ClE/PJE/BcrrQrVyGYH7Jg8V/LDupmyL4kFlVsVFQ==", - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/chroma-js": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", - "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" - }, - "node_modules/cli-color": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.3.tgz", - "integrity": "sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.61", - "es6-iterator": "^2.0.3", - "memoizee": "^0.4.15", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" - }, - "node_modules/d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dependencies": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "node_modules/deepmerge": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz", - "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/es5-ext": { - "version": "0.10.62", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dependencies": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/ext/node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "node_modules/get-stdin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", - "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-promise": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-4.2.2.tgz", - "integrity": "sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw==", - "dependencies": { - "@types/glob": "^7.1.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/ahmadnassri" - }, - "peerDependencies": { - "glob": "^7.1.6" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-schema-to-typescript": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-13.0.2.tgz", - "integrity": "sha512-TCaEVW4aI2FmMQe7f98mvr3/oiVmXEC1xZjkTZ9L/BSoTXFlC7p64mD5AD2d8XWycNBQZUnHwXL5iVXt1HWwNQ==", - "dependencies": { - "@bcherny/json-schema-ref-parser": "10.0.5-fork", - "@types/json-schema": "^7.0.11", - "@types/lodash": "^4.14.182", - "@types/prettier": "^2.6.1", - "cli-color": "^2.0.2", - "get-stdin": "^8.0.0", - "glob": "^7.1.6", - "glob-promise": "^4.2.2", - "is-glob": "^4.0.3", - "lodash": "^4.17.21", - "minimist": "^1.2.6", - "mkdirp": "^1.0.4", - "mz": "^2.7.0", - "prettier": "^2.6.2" - }, - "bin": { - "json2ts": "dist/src/cli.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "dependencies": { - "es5-ext": "~0.10.2" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" - }, - "node_modules/memoizee": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", - "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.53", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "node_modules/nonenumerable": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nonenumerable/-/nonenumerable-1.1.1.tgz", - "integrity": "sha512-ptUD9w9D8WqW6fuJJkZNCImkf+0vdbgUTbRK3i7jsy3olqtH96hYE6Q/S3Tx9NWbcB/ocAjYshXCAUP0lZ9B4Q==" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/timers-ext": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", - "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", - "dependencies": { - "es5-ext": "~0.10.46", - "next-tick": "1" - } - }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" - }, - "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "engines": { - "node": ">=6" - } - } - }, - "dependencies": { - "@bcherny/json-schema-ref-parser": { - "version": "10.0.5-fork", - "resolved": "https://registry.npmjs.org/@bcherny/json-schema-ref-parser/-/json-schema-ref-parser-10.0.5-fork.tgz", - "integrity": "sha512-E/jKbPoca1tfUPj3iSbitDZTGnq6FUFjkH6L8U2oDwSuwK1WhnnVtCG7oFOTg/DDnyoXbQYUiUiGOibHqaGVnw==", - "requires": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - } - }, - "@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "requires": { - "@jridgewell/trace-mapping": "0.3.9" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==" - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" - }, - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" - }, - "@tokens-studio/types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.2.3.tgz", - "integrity": "sha512-2KN3V0JPf+Zh8aoVMwykJq29Lsi7vYgKGYBQ/zQ+FbDEmrH6T/Vwn8kG7cvbTmW1JAAvgxVxMIivgC9PmFelNA==" - }, - "@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==" - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==" - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==" - }, - "@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==" - }, - "@types/chroma-js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.0.tgz", - "integrity": "sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw==" - }, - "@types/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==", - "requires": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==" - }, - "@types/lodash": { - "version": "4.14.195", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", - "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==" - }, - "@types/minimatch": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", - "integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==" - }, - "@types/node": { - "version": "18.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz", - "integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ==" - }, - "@types/prettier": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", - "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==" - }, - "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==" - }, - "acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" - }, - "any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" - }, - "arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" - }, - "argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "ayu": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ayu/-/ayu-8.0.1.tgz", - "integrity": "sha512-yuPZ2kZYQoYaPRQ/78F9rXDVx1rVGCJ1neBYithBoSprD6zPdIJdAKizUXG0jtTBu7nTFyAnVFFYuLnCS3cpDw==", - "requires": { - "@types/chroma-js": "^2.0.0", - "chroma-js": "^2.1.0", - "nonenumerable": "^1.1.1" - } - }, - "balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "bezier-easing": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", - "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==" - }, - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==" - }, - "case-anything": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.10.tgz", - "integrity": "sha512-JczJwVrCP0jPKh05McyVsuOg6AYosrB9XWZKbQzXeDAm2ClE/PJE/BcrrQrVyGYH7Jg8V/LDupmyL4kFlVsVFQ==" - }, - "chroma-js": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", - "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" - }, - "cli-color": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/cli-color/-/cli-color-2.0.3.tgz", - "integrity": "sha512-OkoZnxyC4ERN3zLzZaY9Emb7f/MhBOIpePv0Ycok0fJYT+Ouo00UBEIwsVsr0yoow++n5YWlSUgST9GKhNHiRQ==", - "requires": { - "d": "^1.0.1", - "es5-ext": "^0.10.61", - "es6-iterator": "^2.0.3", - "memoizee": "^0.4.15", - "timers-ext": "^0.1.7" - } - }, - "concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==" - }, - "d": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", - "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "requires": { - "es5-ext": "^0.10.50", - "type": "^1.0.1" - } - }, - "deepmerge": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz", - "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==" - }, - "diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" - }, - "es5-ext": { - "version": "0.10.62", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", - "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", - "requires": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "next-tick": "^1.1.0" - } - }, - "es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "requires": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "es6-symbol": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", - "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "requires": { - "d": "^1.0.1", - "ext": "^1.1.2" - } - }, - "es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "requires": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, - "event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "requires": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "requires": { - "type": "^2.7.2" - }, - "dependencies": { - "type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" - } - } - }, - "fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "get-stdin": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", - "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==" - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-promise": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/glob-promise/-/glob-promise-4.2.2.tgz", - "integrity": "sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw==", - "requires": { - "@types/glob": "^7.1.3" - } - }, - "inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "requires": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" - }, - "is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "requires": { - "is-extglob": "^2.1.1" - } - }, - "is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" - }, - "js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "requires": { - "argparse": "^2.0.1" - } - }, - "json-schema-to-typescript": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-13.0.2.tgz", - "integrity": "sha512-TCaEVW4aI2FmMQe7f98mvr3/oiVmXEC1xZjkTZ9L/BSoTXFlC7p64mD5AD2d8XWycNBQZUnHwXL5iVXt1HWwNQ==", - "requires": { - "@bcherny/json-schema-ref-parser": "10.0.5-fork", - "@types/json-schema": "^7.0.11", - "@types/lodash": "^4.14.182", - "@types/prettier": "^2.6.1", - "cli-color": "^2.0.2", - "get-stdin": "^8.0.0", - "glob": "^7.1.6", - "glob-promise": "^4.2.2", - "is-glob": "^4.0.3", - "lodash": "^4.17.21", - "minimist": "^1.2.6", - "mkdirp": "^1.0.4", - "mz": "^2.7.0", - "prettier": "^2.6.2" - } - }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "requires": { - "es5-ext": "~0.10.2" - } - }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" - }, - "memoizee": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", - "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", - "requires": { - "d": "^1.0.1", - "es5-ext": "^0.10.53", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - } - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" - }, - "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" - }, - "mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "requires": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "nonenumerable": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nonenumerable/-/nonenumerable-1.1.1.tgz", - "integrity": "sha512-ptUD9w9D8WqW6fuJJkZNCImkf+0vdbgUTbRK3i7jsy3olqtH96hYE6Q/S3Tx9NWbcB/ocAjYshXCAUP0lZ9B4Q==" - }, - "object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" - }, - "once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "requires": { - "wrappy": "1" - } - }, - "path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" - }, - "prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==" - }, - "thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "requires": { - "any-promise": "^1.0.0" - } - }, - "thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "requires": { - "thenify": ">= 3.1.0 < 4" - } - }, - "timers-ext": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", - "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", - "requires": { - "es5-ext": "~0.10.46", - "next-tick": "1" - } - }, - "toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" - }, - "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - } - }, - "type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" - }, - "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true - }, - "v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==" - }, - "wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" - } - } -} diff --git a/styles/package.json b/styles/package.json index 64beb7d99a..8820259e86 100644 --- a/styles/package.json +++ b/styles/package.json @@ -7,7 +7,8 @@ "build": "ts-node ./src/buildThemes.ts", "build-licenses": "ts-node ./src/buildLicenses.ts", "build-tokens": "ts-node ./src/buildTokens.ts", - "build-types": "cd ../crates/theme && cargo test && cd ../../styles && ts-node ./src/buildTypes.ts" + "build-types": "cd ../crates/theme && cargo test && cd ../../styles && ts-node ./src/buildTypes.ts", + "test": "vitest" }, "author": "", "license": "ISC", @@ -22,12 +23,18 @@ "deepmerge": "^4.3.0", "json-schema-to-typescript": "^13.0.2", "toml": "^3.0.0", - "ts-node": "^10.9.1" + "ts-deepmerge": "^6.0.3", + "ts-node": "^10.9.1", + "utility-types": "^3.10.0", + "vitest": "^0.32.0" }, "prettier": { "semi": false, "printWidth": 80, "htmlWhitespaceSensitivity": "strict", "tabWidth": 4 + }, + "devDependencies": { + "@vitest/coverage-v8": "^0.32.0" } } diff --git a/styles/src/buildTokens.ts b/styles/src/buildTokens.ts index 0cf1ea037e..6c6acd2f22 100644 --- a/styles/src/buildTokens.ts +++ b/styles/src/buildTokens.ts @@ -1,13 +1,13 @@ -import * as fs from "fs"; -import * as path from "path"; -import { ColorScheme, createColorScheme } from "./common"; -import { themes } from "./themes"; -import { slugify } from "./utils/slugify"; -import { colorSchemeTokens } from "./theme/tokens/colorScheme"; +import * as fs from "fs" +import * as path from "path" +import { ColorScheme, createColorScheme } from "./common" +import { themes } from "./themes" +import { slugify } from "./utils/slugify" +import { colorSchemeTokens } from "./theme/tokens/colorScheme" -const TOKENS_DIRECTORY = path.join(__dirname, "..", "target", "tokens"); -const TOKENS_FILE = path.join(TOKENS_DIRECTORY, "$themes.json"); -const METADATA_FILE = path.join(TOKENS_DIRECTORY, "$metadata.json"); +const TOKENS_DIRECTORY = path.join(__dirname, "..", "target", "tokens") +const TOKENS_FILE = path.join(TOKENS_DIRECTORY, "$themes.json") +const METADATA_FILE = path.join(TOKENS_DIRECTORY, "$metadata.json") function clearTokens(tokensDirectory: string) { if (!fs.existsSync(tokensDirectory)) { @@ -22,64 +22,66 @@ function clearTokens(tokensDirectory: string) { } type TokenSet = { - id: string; - name: string; - selectedTokenSets: { [key: string]: "enabled" }; -}; + id: string + name: string + selectedTokenSets: { [key: string]: "enabled" } +} -function buildTokenSetOrder(colorSchemes: ColorScheme[]): { tokenSetOrder: string[] } { - const tokenSetOrder: string[] = colorSchemes.map( - (scheme) => scheme.name.toLowerCase().replace(/\s+/g, "_") - ); - return { tokenSetOrder }; +function buildTokenSetOrder(colorSchemes: ColorScheme[]): { + tokenSetOrder: string[] +} { + const tokenSetOrder: string[] = colorSchemes.map((scheme) => + scheme.name.toLowerCase().replace(/\s+/g, "_") + ) + return { tokenSetOrder } } function buildThemesIndex(colorSchemes: ColorScheme[]): TokenSet[] { const themesIndex: TokenSet[] = colorSchemes.map((scheme, index) => { const id = `${scheme.isLight ? "light" : "dark"}_${scheme.name .toLowerCase() - .replace(/\s+/g, "_")}_${index}`; - const selectedTokenSets: { [key: string]: "enabled" } = {}; - const tokenSet = scheme.name.toLowerCase().replace(/\s+/g, "_"); - selectedTokenSets[tokenSet] = "enabled"; + .replace(/\s+/g, "_")}_${index}` + const selectedTokenSets: { [key: string]: "enabled" } = {} + const tokenSet = scheme.name.toLowerCase().replace(/\s+/g, "_") + selectedTokenSets[tokenSet] = "enabled" return { id, name: `${scheme.name} - ${scheme.isLight ? "Light" : "Dark"}`, selectedTokenSets, - }; - }); + } + }) - return themesIndex; + return themesIndex } function writeTokens(colorSchemes: ColorScheme[], tokensDirectory: string) { - clearTokens(tokensDirectory); + clearTokens(tokensDirectory) for (const colorScheme of colorSchemes) { - const fileName = slugify(colorScheme.name) + ".json"; - const tokens = colorSchemeTokens(colorScheme); - const tokensJSON = JSON.stringify(tokens, null, 2); - const outPath = path.join(tokensDirectory, fileName); - fs.writeFileSync(outPath, tokensJSON, { mode: 0o644 }); - console.log(`- ${outPath} created`); + const fileName = slugify(colorScheme.name) + ".json" + const tokens = colorSchemeTokens(colorScheme) + const tokensJSON = JSON.stringify(tokens, null, 2) + const outPath = path.join(tokensDirectory, fileName) + fs.writeFileSync(outPath, tokensJSON, { mode: 0o644 }) + console.log(`- ${outPath} created`) } - const themeIndexData = buildThemesIndex(colorSchemes); + const themeIndexData = buildThemesIndex(colorSchemes) - const themesJSON = JSON.stringify(themeIndexData, null, 2); - fs.writeFileSync(TOKENS_FILE, themesJSON, { mode: 0o644 }); - console.log(`- ${TOKENS_FILE} created`); + const themesJSON = JSON.stringify(themeIndexData, null, 2) + fs.writeFileSync(TOKENS_FILE, themesJSON, { mode: 0o644 }) + console.log(`- ${TOKENS_FILE} created`) - const tokenSetOrderData = buildTokenSetOrder(colorSchemes); + const tokenSetOrderData = buildTokenSetOrder(colorSchemes) - const metadataJSON = JSON.stringify(tokenSetOrderData, null, 2); - fs.writeFileSync(METADATA_FILE, metadataJSON, { mode: 0o644 }); - console.log(`- ${METADATA_FILE} created`); + const metadataJSON = JSON.stringify(tokenSetOrderData, null, 2) + fs.writeFileSync(METADATA_FILE, metadataJSON, { mode: 0o644 }) + console.log(`- ${METADATA_FILE} created`) } const colorSchemes: ColorScheme[] = themes.map((theme) => createColorScheme(theme) -); +) -writeTokens(colorSchemes, TOKENS_DIRECTORY); +writeTokens(colorSchemes, TOKENS_DIRECTORY) diff --git a/styles/src/element/index.ts b/styles/src/element/index.ts new file mode 100644 index 0000000000..b1e3cfe415 --- /dev/null +++ b/styles/src/element/index.ts @@ -0,0 +1,4 @@ +import { interactive } from "./interactive" +import { toggleable } from "./toggle" + +export { interactive, toggleable } diff --git a/styles/src/element/interactive.test.ts b/styles/src/element/interactive.test.ts new file mode 100644 index 0000000000..b0cc57875f --- /dev/null +++ b/styles/src/element/interactive.test.ts @@ -0,0 +1,56 @@ +import { + NOT_ENOUGH_STATES_ERROR, + NO_DEFAULT_OR_BASE_ERROR, + interactive, +} from "./interactive" +import { describe, it, expect } from "vitest" + +describe("interactive", () => { + it("creates an Interactive with base properties and states", () => { + const result = interactive({ + base: { fontSize: 10, color: "#FFFFFF" }, + state: { + hovered: { color: "#EEEEEE" }, + clicked: { color: "#CCCCCC" }, + }, + }) + + expect(result).toEqual({ + default: { color: "#FFFFFF", fontSize: 10 }, + hovered: { color: "#EEEEEE", fontSize: 10 }, + clicked: { color: "#CCCCCC", fontSize: 10 }, + }) + }) + + it("creates an Interactive with no base properties", () => { + const result = interactive({ + state: { + default: { color: "#FFFFFF", fontSize: 10 }, + hovered: { color: "#EEEEEE" }, + clicked: { color: "#CCCCCC" }, + }, + }) + + expect(result).toEqual({ + default: { color: "#FFFFFF", fontSize: 10 }, + hovered: { color: "#EEEEEE", fontSize: 10 }, + clicked: { color: "#CCCCCC", fontSize: 10 }, + }) + }) + + it("throws error when both default and base are missing", () => { + const state = { + hovered: { color: "blue" }, + } + + expect(() => interactive({ state })).toThrow(NO_DEFAULT_OR_BASE_ERROR) + }) + + it("throws error when no other state besides default is present", () => { + const state = { + default: { fontSize: 10 }, + } + + expect(() => interactive({ state })).toThrow(NOT_ENOUGH_STATES_ERROR) + }) +}) diff --git a/styles/src/element/interactive.ts b/styles/src/element/interactive.ts new file mode 100644 index 0000000000..1c0f393cff --- /dev/null +++ b/styles/src/element/interactive.ts @@ -0,0 +1,97 @@ +import merge from "ts-deepmerge" +import { DeepPartial } from "utility-types" + +type InteractiveState = + | "default" + | "hovered" + | "clicked" + | "selected" + | "disabled" + +type Interactive = { + default: T + hovered?: T + clicked?: T + selected?: T + disabled?: T +} + +export const NO_DEFAULT_OR_BASE_ERROR = + "An interactive object must have a default state, or a base property." +export const NOT_ENOUGH_STATES_ERROR = + "An interactive object must have a default and at least one other state." + +interface InteractiveProps { + base?: T + state: Partial>> +} + +/** + * Helper function for creating Interactive objects that works with Toggle-like behavior. + * It takes a default object to be used as the value for `default` field and fills out other fields + * with fields from either `base` or from the `state` object which contains values for specific states. + * Notably, it does not touch `hover`, `clicked`, `selected` and `disabled` states if there are no modifications for them. + * + * @param defaultObj Object to be used as the value for the `default` field. + * @param base Optional object containing base fields to be included in the resulting object. + * @param state Object containing optional modified fields to be included in the resulting object for each state. + * @returns Interactive object with fields from `base` and `state`. + */ +export function interactive({ + base, + state, +}: InteractiveProps): Interactive { + if (!base && !state.default) throw new Error(NO_DEFAULT_OR_BASE_ERROR) + + let defaultState: T + + if (state.default && base) { + defaultState = merge(base, state.default) as T + } else { + defaultState = base ? base : (state.default as T) + } + + let interactiveObj: Interactive = { + default: defaultState, + } + + let stateCount = 0 + + if (state.hovered !== undefined) { + interactiveObj.hovered = merge( + interactiveObj.default, + state.hovered + ) as T + stateCount++ + } + + if (state.clicked !== undefined) { + interactiveObj.clicked = merge( + interactiveObj.default, + state.clicked + ) as T + stateCount++ + } + + if (state.selected !== undefined) { + interactiveObj.selected = merge( + interactiveObj.default, + state.selected + ) as T + stateCount++ + } + + if (state.disabled !== undefined) { + interactiveObj.disabled = merge( + interactiveObj.default, + state.disabled + ) as T + stateCount++ + } + + if (stateCount < 1) { + throw new Error(NOT_ENOUGH_STATES_ERROR) + } + + return interactiveObj +} diff --git a/styles/src/element/toggle.test.ts b/styles/src/element/toggle.test.ts new file mode 100644 index 0000000000..8018ce1039 --- /dev/null +++ b/styles/src/element/toggle.test.ts @@ -0,0 +1,52 @@ +import { + NO_ACTIVE_ERROR, + NO_INACTIVE_OR_BASE_ERROR, + toggleable, +} from "./toggle" +import { describe, it, expect } from "vitest" + +describe("toggleable", () => { + it("creates a Toggleable with base properties and states", () => { + const result = toggleable({ + base: { background: "#000000", color: "#CCCCCC" }, + state: { + active: { color: "#FFFFFF" }, + }, + }) + + expect(result).toEqual({ + inactive: { background: "#000000", color: "#CCCCCC" }, + active: { background: "#000000", color: "#FFFFFF" }, + }) + }) + + it("creates a Toggleable with no base properties", () => { + const result = toggleable({ + state: { + inactive: { background: "#000000", color: "#CCCCCC" }, + active: { background: "#000000", color: "#FFFFFF" }, + }, + }) + + expect(result).toEqual({ + inactive: { background: "#000000", color: "#CCCCCC" }, + active: { background: "#000000", color: "#FFFFFF" }, + }) + }) + + it("throws error when both inactive and base are missing", () => { + const state = { + active: { background: "#000000", color: "#FFFFFF" }, + } + + expect(() => toggleable({ state })).toThrow(NO_INACTIVE_OR_BASE_ERROR) + }) + + it("throws error when no active state is present", () => { + const state = { + inactive: { background: "#000000", color: "#CCCCCC" }, + } + + expect(() => toggleable({ state })).toThrow(NO_ACTIVE_ERROR) + }) +}) diff --git a/styles/src/element/toggle.ts b/styles/src/element/toggle.ts new file mode 100644 index 0000000000..ead8f1e824 --- /dev/null +++ b/styles/src/element/toggle.ts @@ -0,0 +1,47 @@ +import merge from "ts-deepmerge" +import { DeepPartial } from "utility-types" + +type ToggleState = "inactive" | "active" + +type Toggleable = Record + +export const NO_INACTIVE_OR_BASE_ERROR = + "A toggleable object must have an inactive state, or a base property." +export const NO_ACTIVE_ERROR = "A toggleable object must have an active state." + +interface ToggleableProps { + base?: T + state: Partial>> +} + +/** + * Helper function for creating Toggleable objects. + * @template T The type of the object being toggled. + * @param props Object containing the base (inactive) state and state modifications to create the active state. + * @returns A Toggleable object containing both the inactive and active states. + * @example + * ``` + * toggleable({ + * base: { background: "#000000", text: "#CCCCCC" }, + * state: { active: { text: "#CCCCCC" } }, + * }) + * ``` + */ +export function toggleable( + props: ToggleableProps +): Toggleable { + const { base, state } = props + + if (!base && !state.inactive) throw new Error(NO_INACTIVE_OR_BASE_ERROR) + if (!state.active) throw new Error(NO_ACTIVE_ERROR) + + const inactiveState = base + ? ((state.inactive ? merge(base, state.inactive) : base) as T) + : (state.inactive as T) + + const toggleObj: Toggleable = { + inactive: inactiveState, + active: merge(base ?? {}, state.active) as T, + } + return toggleObj +} diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index 886553d418..754443cc5f 100644 --- a/styles/src/styleTree/app.ts +++ b/styles/src/styleTree/app.ts @@ -1,4 +1,3 @@ -import { text } from "./components" import contactFinder from "./contactFinder" import contactsPopover from "./contactsPopover" import commandPalette from "./commandPalette" @@ -17,7 +16,7 @@ import projectSharedNotification from "./projectSharedNotification" import tooltip from "./tooltip" import terminal from "./terminal" import contactList from "./contactList" -import lspLogMenu from "./lspLogMenu" +import toolbarDropdownMenu from "./toolbarDropdownMenu" import incomingCallNotification from "./incomingCallNotification" import { ColorScheme } from "../theme/colorScheme" import feedback from "./feedback" @@ -46,7 +45,7 @@ export default function app(colorScheme: ColorScheme): Object { contactsPopover: contactsPopover(colorScheme), contactFinder: contactFinder(colorScheme), contactList: contactList(colorScheme), - lspLogMenu: lspLogMenu(colorScheme), + toolbarDropdownMenu: toolbarDropdownMenu(colorScheme), search: search(colorScheme), sharedScreen: sharedScreen(colorScheme), updateNotification: updateNotification(colorScheme), diff --git a/styles/src/styleTree/assistant.ts b/styles/src/styleTree/assistant.ts index bbb4aae5e1..163584cd6d 100644 --- a/styles/src/styleTree/assistant.ts +++ b/styles/src/styleTree/assistant.ts @@ -1,6 +1,7 @@ import { ColorScheme } from "../theme/colorScheme" import { text, border, background, foreground } from "./components" import editor from "./editor" +import { interactive } from "../element" export default function assistant(colorScheme: ColorScheme) { const layer = colorScheme.highest @@ -15,13 +16,28 @@ export default function assistant(colorScheme: ColorScheme) { background: editor(colorScheme).background, }, userSender: { - ...text(layer, "sans", "default", { size: "sm", weight: "bold" }), + default: { + ...text(layer, "sans", "default", { + size: "sm", + weight: "bold", + }), + }, }, assistantSender: { - ...text(layer, "sans", "accent", { size: "sm", weight: "bold" }), + default: { + ...text(layer, "sans", "accent", { + size: "sm", + weight: "bold", + }), + }, }, systemSender: { - ...text(layer, "sans", "variant", { size: "sm", weight: "bold" }), + default: { + ...text(layer, "sans", "variant", { + size: "sm", + weight: "bold", + }), + }, }, sentAt: { margin: { top: 2, left: 8 }, @@ -30,16 +46,20 @@ export default function assistant(colorScheme: ColorScheme) { modelInfoContainer: { margin: { right: 16, top: 4 }, }, - model: { - background: background(layer, "on"), - border: border(layer, "on", { overlay: true }), - padding: 4, - cornerRadius: 4, - ...text(layer, "sans", "default", { size: "xs" }), - hover: { - background: background(layer, "on", "hovered"), + model: interactive({ + base: { + background: background(layer, "on"), + border: border(layer, "on", { overlay: true }), + padding: 4, + cornerRadius: 4, + ...text(layer, "sans", "default", { size: "xs" }), }, - }, + state: { + hovered: { + background: background(layer, "on", "hovered"), + }, + }, + }), remainingTokens: { background: background(layer, "on"), border: border(layer, "on", { overlay: true }), diff --git a/styles/src/styleTree/commandPalette.ts b/styles/src/styleTree/commandPalette.ts index c49e1f194c..5d4b7373c3 100644 --- a/styles/src/styleTree/commandPalette.ts +++ b/styles/src/styleTree/commandPalette.ts @@ -1,12 +1,13 @@ import { ColorScheme } from "../theme/colorScheme" import { withOpacity } from "../theme/color" import { text, background } from "./components" +import { toggleable } from "../element" export default function commandPalette(colorScheme: ColorScheme) { let layer = colorScheme.highest - return { - keystrokeSpacing: 8, - key: { + + const key = toggleable({ + base: { text: text(layer, "mono", "variant", "default", { size: "xs" }), cornerRadius: 2, background: background(layer, "on"), @@ -21,10 +22,21 @@ export default function commandPalette(colorScheme: ColorScheme) { bottom: 1, left: 2, }, + }, + state: { active: { text: text(layer, "mono", "on", "default", { size: "xs" }), background: withOpacity(background(layer, "on"), 0.2), }, }, + }) + + return { + keystrokeSpacing: 8, + // TODO: This should be a Toggle on the rust side so we don't have to do this + key: { + inactive: { ...key.inactive }, + active: key.active, + }, } } diff --git a/styles/src/styleTree/components.ts b/styles/src/styleTree/components.ts index a575dad527..bfd1f75cb0 100644 --- a/styles/src/styleTree/components.ts +++ b/styles/src/styleTree/components.ts @@ -85,7 +85,7 @@ export function foreground( return getStyle(layer, styleSetOrStyles, style).foreground } -interface Text { +interface Text extends Object { family: keyof typeof fontFamilies color: string size: number diff --git a/styles/src/styleTree/contactList.ts b/styles/src/styleTree/contactList.ts index a597e44d9f..88ae04277e 100644 --- a/styles/src/styleTree/contactList.ts +++ b/styles/src/styleTree/contactList.ts @@ -1,6 +1,6 @@ import { ColorScheme } from "../theme/colorScheme" import { background, border, borderColor, foreground, text } from "./components" - +import { interactive, toggleable } from "../element" export default function contactsPanel(colorScheme: ColorScheme) { const nameMargin = 8 const sidePadding = 12 @@ -71,47 +71,85 @@ export default function contactsPanel(colorScheme: ColorScheme) { }, rowHeight: 28, sectionIconSize: 8, - headerRow: { - ...text(layer, "mono", { size: "sm" }), - margin: { top: 14 }, - padding: { - left: sidePadding, - right: sidePadding, + headerRow: toggleable({ + base: interactive({ + base: { + ...text(layer, "mono", { size: "sm" }), + margin: { top: 14 }, + padding: { + left: sidePadding, + right: sidePadding, + }, + background: background(layer, "default"), // posiewic: breaking change + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, // hack, we want headerRow to be interactive for whatever reason. It probably shouldn't be interactive in the first place. + }), + state: { + active: { + default: { + ...text(layer, "mono", "active", { size: "sm" }), + background: background(layer, "active"), + }, + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, }, - active: { - ...text(layer, "mono", "active", { size: "sm" }), - background: background(layer, "active"), + }), + leaveCall: interactive({ + base: { + background: background(layer), + border: border(layer), + cornerRadius: 6, + margin: { + top: 1, + }, + padding: { + top: 1, + bottom: 1, + left: 7, + right: 7, + }, + ...text(layer, "sans", "variant", { size: "xs" }), }, - }, - leaveCall: { - background: background(layer), - border: border(layer), - cornerRadius: 6, - margin: { - top: 1, + state: { + hovered: { + ...text(layer, "sans", "hovered", { size: "xs" }), + background: background(layer, "hovered"), + border: border(layer, "hovered"), + }, }, - padding: { - top: 1, - bottom: 1, - left: 7, - right: 7, - }, - ...text(layer, "sans", "variant", { size: "xs" }), - hover: { - ...text(layer, "sans", "hovered", { size: "xs" }), - background: background(layer, "hovered"), - border: border(layer, "hovered"), - }, - }, + }), contactRow: { - padding: { - left: sidePadding, - right: sidePadding, + inactive: { + default: { + padding: { + left: sidePadding, + right: sidePadding, + }, + }, }, active: { - background: background(layer, "active"), + default: { + background: background(layer, "active"), + padding: { + left: sidePadding, + right: sidePadding, + }, + }, }, }, + contactAvatar: { cornerRadius: 10, width: 18, @@ -135,12 +173,14 @@ export default function contactsPanel(colorScheme: ColorScheme) { }, }, contactButtonSpacing: nameMargin, - contactButton: { - ...contactButton, - hover: { - background: background(layer, "hovered"), + contactButton: interactive({ + base: { ...contactButton }, + state: { + hovered: { + background: background(layer, "hovered"), + }, }, - }, + }), disabledButton: { ...contactButton, background: background(layer, "on"), @@ -149,34 +189,52 @@ export default function contactsPanel(colorScheme: ColorScheme) { callingIndicator: { ...text(layer, "mono", "variant", { size: "xs" }), }, - treeBranch: { - color: borderColor(layer), - width: 1, - hover: { - color: borderColor(layer), + treeBranch: toggleable({ + base: interactive({ + base: { + color: borderColor(layer), + width: 1, + }, + state: { + hovered: { + color: borderColor(layer), + }, + }, + }), + state: { + active: { + default: { + color: borderColor(layer), + }, + }, }, - active: { - color: borderColor(layer), + }), + projectRow: toggleable({ + base: interactive({ + base: { + ...projectRow, + background: background(layer), + icon: { + margin: { left: nameMargin }, + color: foreground(layer, "variant"), + width: 12, + }, + name: { + ...projectRow.name, + ...text(layer, "mono", { size: "sm" }), + }, + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + }, + }), + state: { + active: { + default: { background: background(layer, "active") }, + }, }, - }, - projectRow: { - ...projectRow, - background: background(layer), - icon: { - margin: { left: nameMargin }, - color: foreground(layer, "variant"), - width: 12, - }, - name: { - ...projectRow.name, - ...text(layer, "mono", { size: "sm" }), - }, - hover: { - background: background(layer, "hovered"), - }, - active: { - background: background(layer, "active"), - }, - }, + }), } } diff --git a/styles/src/styleTree/contactNotification.ts b/styles/src/styleTree/contactNotification.ts index 85a0b9d0de..825c5a389a 100644 --- a/styles/src/styleTree/contactNotification.ts +++ b/styles/src/styleTree/contactNotification.ts @@ -1,6 +1,6 @@ import { ColorScheme } from "../theme/colorScheme" import { background, foreground, text } from "./components" - +import { interactive } from "../element" const avatarSize = 12 const headerPadding = 8 @@ -21,24 +21,32 @@ export default function contactNotification(colorScheme: ColorScheme): Object { ...text(layer, "sans", { size: "xs" }), margin: { left: avatarSize + headerPadding, top: 6, bottom: 6 }, }, - button: { - ...text(layer, "sans", "on", { size: "xs" }), - background: background(layer, "on"), - padding: 4, - cornerRadius: 6, - margin: { left: 6 }, - hover: { - background: background(layer, "on", "hovered"), + button: interactive({ + base: { + ...text(layer, "sans", "on", { size: "xs" }), + background: background(layer, "on"), + padding: 4, + cornerRadius: 6, + margin: { left: 6 }, }, - }, + + state: { + hovered: { + background: background(layer, "on", "hovered"), + }, + }, + }), + dismissButton: { - color: foreground(layer, "variant"), - iconWidth: 8, - iconHeight: 8, - buttonWidth: 8, - buttonHeight: 8, - hover: { - color: foreground(layer, "hovered"), + default: { + color: foreground(layer, "variant"), + iconWidth: 8, + iconHeight: 8, + buttonWidth: 8, + buttonHeight: 8, + hover: { + color: foreground(layer, "hovered"), + }, }, }, } diff --git a/styles/src/styleTree/contextMenu.ts b/styles/src/styleTree/contextMenu.ts index f14cd90219..a59284c43a 100644 --- a/styles/src/styleTree/contextMenu.ts +++ b/styles/src/styleTree/contextMenu.ts @@ -1,5 +1,6 @@ import { ColorScheme } from "../theme/colorScheme" import { background, border, borderColor, text } from "./components" +import { interactive, toggleable } from "../element" export default function contextMenu(colorScheme: ColorScheme) { let layer = colorScheme.middle @@ -10,37 +11,54 @@ export default function contextMenu(colorScheme: ColorScheme) { shadow: colorScheme.popoverShadow, border: border(layer), keystrokeMargin: 30, - item: { - iconSpacing: 8, - iconWidth: 14, - padding: { left: 6, right: 6, top: 2, bottom: 2 }, - cornerRadius: 6, - label: text(layer, "sans", { size: "sm" }), - keystroke: { - ...text(layer, "sans", "variant", { - size: "sm", - weight: "bold", - }), - padding: { left: 3, right: 3 }, - }, - hover: { - background: background(layer, "hovered"), - label: text(layer, "sans", "hovered", { size: "sm" }), - keystroke: { - ...text(layer, "sans", "hovered", { - size: "sm", - weight: "bold", - }), - padding: { left: 3, right: 3 }, + item: toggleable({ + base: interactive({ + base: { + iconSpacing: 8, + iconWidth: 14, + padding: { left: 6, right: 6, top: 2, bottom: 2 }, + cornerRadius: 6, + label: text(layer, "sans", { size: "sm" }), + keystroke: { + ...text(layer, "sans", "variant", { + size: "sm", + weight: "bold", + }), + padding: { left: 3, right: 3 }, + }, + }, + state: { + hovered: { + background: background(layer, "hovered"), + label: text(layer, "sans", "hovered", { size: "sm" }), + keystroke: { + ...text(layer, "sans", "hovered", { + size: "sm", + weight: "bold", + }), + padding: { left: 3, right: 3 }, + }, + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }), + state: { + active: { + default: { + background: background(layer, "active"), + }, + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), + }, }, }, - active: { - background: background(layer, "active"), - }, - activeHover: { - background: background(layer, "active"), - }, - }, + }), + separator: { background: borderColor(layer), margin: { top: 2, bottom: 2 }, diff --git a/styles/src/styleTree/copilot.ts b/styles/src/styleTree/copilot.ts index 8614cb6976..1e09f4ff6b 100644 --- a/styles/src/styleTree/copilot.ts +++ b/styles/src/styleTree/copilot.ts @@ -1,60 +1,69 @@ import { ColorScheme } from "../theme/colorScheme" import { background, border, foreground, svg, text } from "./components" - +import { interactive } from "../element" export default function copilot(colorScheme: ColorScheme) { let layer = colorScheme.middle let content_width = 264 - let ctaButton = { + let ctaButton = // Copied from welcome screen. FIXME: Move this into a ZDS component - background: background(layer), - border: border(layer, "default"), - cornerRadius: 4, - margin: { - top: 4, - bottom: 4, - left: 8, - right: 8, - }, - padding: { - top: 3, - bottom: 3, - left: 7, - right: 7, - }, - ...text(layer, "sans", "default", { size: "sm" }), - hover: { - ...text(layer, "sans", "default", { size: "sm" }), - background: background(layer, "hovered"), - border: border(layer, "active"), - }, - } + interactive({ + base: { + background: background(layer), + border: border(layer, "default"), + cornerRadius: 4, + margin: { + top: 4, + bottom: 4, + left: 8, + right: 8, + }, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + ...text(layer, "sans", "default", { size: "sm" }), + }, + state: { + hovered: { + ...text(layer, "sans", "default", { size: "sm" }), + background: background(layer, "hovered"), + border: border(layer, "active"), + }, + }, + }) return { - outLinkIcon: { - icon: svg( - foreground(layer, "variant"), - "icons/link_out_12.svg", - 12, - 12 - ), - container: { - cornerRadius: 6, - padding: { left: 6 }, - }, - hover: { + outLinkIcon: interactive({ + base: { icon: svg( - foreground(layer, "hovered"), + foreground(layer, "variant"), "icons/link_out_12.svg", 12, 12 ), + container: { + cornerRadius: 6, + padding: { left: 6 }, + }, }, - }, + state: { + hovered: { + icon: { + color: foreground(layer, "hovered"), + }, + }, + }, + }), + modal: { titleText: { - ...text(layer, "sans", { size: "xs", weight: "bold" }), + default: { + ...text(layer, "sans", { size: "xs", weight: "bold" }), + }, }, titlebar: { background: background(colorScheme.lowest), @@ -75,42 +84,46 @@ export default function copilot(colorScheme: ColorScheme) { bottom: 8, }, }, - closeIcon: { - icon: svg( - foreground(layer, "variant"), - "icons/x_mark_8.svg", - 8, - 8 - ), - container: { - cornerRadius: 2, - padding: { - top: 4, - bottom: 4, - left: 4, - right: 4, - }, - margin: { - right: 0, - }, - }, - hover: { + closeIcon: interactive({ + base: { icon: svg( - foreground(layer, "on"), + foreground(layer, "variant"), "icons/x_mark_8.svg", 8, 8 ), + container: { + cornerRadius: 2, + padding: { + top: 4, + bottom: 4, + left: 4, + right: 4, + }, + margin: { + right: 0, + }, + }, }, - clicked: { - icon: svg( - foreground(layer, "base"), - "icons/x_mark_8.svg", - 8, - 8 - ), + state: { + hovered: { + icon: svg( + foreground(layer, "on"), + "icons/x_mark_8.svg", + 8, + 8 + ), + }, + clicked: { + icon: svg( + foreground(layer, "base"), + "icons/x_mark_8.svg", + 8, + 8 + ), + }, }, - }, + }), dimensions: { width: 280, height: 280, @@ -185,28 +198,32 @@ export default function copilot(colorScheme: ColorScheme) { }, }, right: (content_width * 1) / 3, - rightContainer: { - border: border(colorScheme.lowest, "inverted", { - bottom: false, - right: false, - top: false, - left: true, - }), - padding: { - top: 3, - bottom: 5, - left: 8, - right: 0, - }, - hover: { - border: border(layer, "active", { + rightContainer: interactive({ + base: { + border: border(colorScheme.lowest, "inverted", { bottom: false, right: false, top: false, left: true, }), + padding: { + top: 3, + bottom: 5, + left: 8, + right: 0, + }, }, - }, + state: { + hovered: { + border: border(layer, "active", { + bottom: false, + right: false, + top: false, + left: true, + }), + }, + }, + }), }, }, diff --git a/styles/src/styleTree/editor.ts b/styles/src/styleTree/editor.ts index 55f3da6e90..c53f3ba2ff 100644 --- a/styles/src/styleTree/editor.ts +++ b/styles/src/styleTree/editor.ts @@ -4,6 +4,7 @@ import { background, border, borderColor, foreground, text } from "./components" import hoverPopover from "./hoverPopover" import { buildSyntax } from "../theme/syntax" +import { interactive, toggleable } from "../element" export default function editor(colorScheme: ColorScheme) { const { isLight } = colorScheme @@ -48,46 +49,76 @@ export default function editor(colorScheme: ColorScheme) { // Inline autocomplete suggestions, Co-pilot suggestions, etc. suggestion: syntax.predictive, codeActions: { - indicator: { - color: foreground(layer, "variant"), + indicator: toggleable({ + base: interactive({ + base: { + color: foreground(layer, "variant"), + }, + state: { + hovered: { + color: foreground(layer, "variant", "hovered"), + }, + clicked: { + color: foreground(layer, "variant", "pressed"), + }, + }, + }), + state: { + active: { + default: { + color: foreground(layer, "accent"), + }, + hovered: { + color: foreground(layer, "accent", "hovered"), + }, + clicked: { + color: foreground(layer, "accent", "pressed"), + }, + }, + }, + }), - clicked: { - color: foreground(layer, "base"), - }, - hover: { - color: foreground(layer, "on"), - }, - active: { - color: foreground(layer, "on"), - }, - }, verticalScale: 0.55, }, folds: { iconMarginScale: 2.5, foldedIcon: "icons/chevron_right_8.svg", foldableIcon: "icons/chevron_down_8.svg", - indicator: { - color: foreground(layer, "variant"), - - clicked: { - color: foreground(layer, "base"), + indicator: toggleable({ + base: interactive({ + base: { + color: foreground(layer, "variant"), + }, + state: { + hovered: { + color: foreground(layer, "on"), + }, + clicked: { + color: foreground(layer, "base"), + }, + }, + }), + state: { + active: { + default: { + color: foreground(layer, "default"), + }, + hovered: { + color: foreground(layer, "variant"), + }, + }, }, - hover: { - color: foreground(layer, "on"), - }, - active: { - color: foreground(layer, "on"), - }, - }, + }), ellipses: { textColor: colorScheme.ramps.neutral(0.71).hex(), cornerRadiusFactor: 0.15, background: { // Copied from hover_popover highlight - color: colorScheme.ramps.neutral(0.5).alpha(0.0).hex(), + default: { + color: colorScheme.ramps.neutral(0.5).alpha(0.0).hex(), + }, - hover: { + hovered: { color: colorScheme.ramps.neutral(0.5).alpha(0.5).hex(), }, @@ -223,21 +254,26 @@ export default function editor(colorScheme: ColorScheme) { color: syntax.linkUri.color, underline: syntax.linkUri.underline, }, - jumpIcon: { - color: foreground(layer, "on"), - iconWidth: 20, - buttonWidth: 20, - cornerRadius: 6, - padding: { - top: 6, - bottom: 6, - left: 6, - right: 6, + jumpIcon: interactive({ + base: { + color: foreground(layer, "on"), + iconWidth: 20, + buttonWidth: 20, + cornerRadius: 6, + padding: { + top: 6, + bottom: 6, + left: 6, + right: 6, + }, }, - hover: { - background: background(layer, "on", "hovered"), + state: { + hovered: { + background: background(layer, "on", "hovered"), + }, }, - }, + }), + scrollbar: { width: 12, minHeightFactor: 1.0, diff --git a/styles/src/styleTree/feedback.ts b/styles/src/styleTree/feedback.ts index 5eef4b4279..c98cbe768f 100644 --- a/styles/src/styleTree/feedback.ts +++ b/styles/src/styleTree/feedback.ts @@ -1,35 +1,40 @@ import { ColorScheme } from "../theme/colorScheme" import { background, border, text } from "./components" +import { interactive } from "../element" export default function feedback(colorScheme: ColorScheme) { let layer = colorScheme.highest return { - submit_button: { - ...text(layer, "mono", "on"), - background: background(layer, "on"), - cornerRadius: 6, - border: border(layer, "on"), - margin: { - right: 4, + submit_button: interactive({ + base: { + ...text(layer, "mono", "on"), + background: background(layer, "on"), + cornerRadius: 6, + border: border(layer, "on"), + margin: { + right: 4, + }, + padding: { + bottom: 2, + left: 10, + right: 10, + top: 2, + }, }, - padding: { - bottom: 2, - left: 10, - right: 10, - top: 2, + state: { + clicked: { + ...text(layer, "mono", "on", "pressed"), + background: background(layer, "on", "pressed"), + border: border(layer, "on", "pressed"), + }, + hovered: { + ...text(layer, "mono", "on", "hovered"), + background: background(layer, "on", "hovered"), + border: border(layer, "on", "hovered"), + }, }, - clicked: { - ...text(layer, "mono", "on", "pressed"), - background: background(layer, "on", "pressed"), - border: border(layer, "on", "pressed"), - }, - hover: { - ...text(layer, "mono", "on", "hovered"), - background: background(layer, "on", "hovered"), - border: border(layer, "on", "hovered"), - }, - }, + }), button_margin: 8, info_text_default: text(layer, "sans", "default", { size: "xs" }), link_text_default: text(layer, "sans", "default", { diff --git a/styles/src/styleTree/lspLogMenu.ts b/styles/src/styleTree/lspLogMenu.ts deleted file mode 100644 index 94dd4831b2..0000000000 --- a/styles/src/styleTree/lspLogMenu.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ColorScheme } from "../theme/colorScheme" -import { background, border, text } from "./components" - -export default function contactsPanel(colorScheme: ColorScheme) { - let layer = colorScheme.middle - - return { - rowHeight: 30, - background: background(layer), - border: border(layer), - shadow: colorScheme.popoverShadow, - header: { - ...text(layer, "sans", { size: "sm" }), - padding: { left: 8, right: 8, top: 2, bottom: 2 }, - cornerRadius: 6, - background: background(layer, "on"), - border: border(layer, "on", { overlay: true }), - hover: { - background: background(layer, "hovered"), - ...text(layer, "sans", "hovered", { size: "sm" }), - } - }, - server: { - ...text(layer, "sans", { size: "sm" }), - padding: { left: 8, right: 8, top: 8, bottom: 8 }, - }, - item: { - ...text(layer, "sans", { size: "sm" }), - padding: { left: 18, right: 18, top: 2, bottom: 2 }, - hover: { - background: background(layer, "hovered"), - ...text(layer, "sans", "hovered", { size: "sm" }), - }, - active: { - background: background(layer, "active"), - }, - activeHover: { - background: background(layer, "active"), - }, - }, - } -} diff --git a/styles/src/styleTree/picker.ts b/styles/src/styleTree/picker.ts index d84bd6fc7a..5501bd4df2 100644 --- a/styles/src/styleTree/picker.ts +++ b/styles/src/styleTree/picker.ts @@ -1,6 +1,7 @@ import { ColorScheme } from "../theme/colorScheme" import { withOpacity } from "../theme/color" import { background, border, text } from "./components" +import { interactive, toggleable } from "../element" export default function picker(colorScheme: ColorScheme): any { let layer = colorScheme.lowest @@ -38,35 +39,65 @@ export default function picker(colorScheme: ColorScheme): any { ...container, padding: {}, }, - item: { - padding: { - bottom: 4, - left: 12, - right: 12, - top: 4, + item: toggleable({ + base: interactive({ + base: { + padding: { + bottom: 4, + left: 12, + right: 12, + top: 4, + }, + margin: { + top: 1, + left: 4, + right: 4, + }, + cornerRadius: 8, + text: text(layer, "sans", "variant"), + highlightText: text(layer, "sans", "accent", { + weight: "bold", + }), + }, + state: { + hovered: { + background: withOpacity( + background(layer, "hovered"), + 0.5 + ), + }, + clicked: { + background: withOpacity( + background(layer, "pressed"), + 0.5 + ), + }, + }, + }), + state: { + active: { + default: { + background: withOpacity( + background(layer, "base", "active"), + 0.5 + ), + }, + hovered: { + background: withOpacity( + background(layer, "hovered"), + 0.5 + ), + }, + clicked: { + background: withOpacity( + background(layer, "pressed"), + 0.5 + ), + }, + }, }, - margin: { - top: 1, - left: 4, - right: 4, - }, - cornerRadius: 8, - text: text(layer, "sans", "variant"), - highlightText: text(layer, "sans", "accent", { weight: "bold" }), - active: { - background: withOpacity( - background(layer, "base", "active"), - 0.5 - ), - text: text(layer, "sans", "base", "active"), - highlightText: text(layer, "sans", "accent", { - weight: "bold", - }), - }, - hover: { - background: withOpacity(background(layer, "hovered"), 0.5), - }, - }, + }), + inputEditor, emptyInputEditor, noMatches: { diff --git a/styles/src/styleTree/projectPanel.ts b/styles/src/styleTree/projectPanel.ts index a86ae010b6..6bec951288 100644 --- a/styles/src/styleTree/projectPanel.ts +++ b/styles/src/styleTree/projectPanel.ts @@ -1,7 +1,7 @@ import { ColorScheme } from "../theme/colorScheme" import { withOpacity } from "../theme/color" import { background, border, foreground, text } from "./components" - +import { interactive, toggleable } from "../element" export default function projectPanel(colorScheme: ColorScheme) { const { isLight } = colorScheme @@ -28,48 +28,79 @@ export default function projectPanel(colorScheme: ColorScheme) { }, } - let entry = { - ...baseEntry, - text: text(layer, "mono", "variant", { size: "sm" }), - hover: { - background: background(layer, "variant", "hovered"), + const default_entry = interactive({ + base: { + ...baseEntry, + text: text(layer, "mono", "variant", { size: "sm" }), + status, }, - active: { - background: colorScheme.isLight - ? withOpacity(background(layer, "active"), 0.5) - : background(layer, "active"), - text: text(layer, "mono", "active", { size: "sm" }), + state: { + default: { + background: background(layer), + }, + hovered: { + background: background(layer, "variant", "hovered"), + }, + clicked: { + background: background(layer, "variant", "pressed"), + }, }, - activeHover: { - background: background(layer, "active"), - text: text(layer, "mono", "active", { size: "sm" }), + }) + + let entry = toggleable({ + base: default_entry, + state: { + active: interactive({ + base: { + ...default_entry, + }, + state: { + default: { + background: background(colorScheme.lowest), + }, + hovered: { + background: background(colorScheme.lowest, "hovered"), + }, + clicked: { + background: background(colorScheme.lowest, "pressed"), + }, + }, + }), }, - status, - } + }) return { - openProjectButton: { - background: background(layer), - border: border(layer, "active"), - cornerRadius: 4, - margin: { - top: 16, - left: 16, - right: 16, - }, - padding: { - top: 3, - bottom: 3, - left: 7, - right: 7, - }, - ...text(layer, "sans", "default", { size: "sm" }), - hover: { - ...text(layer, "sans", "default", { size: "sm" }), - background: background(layer, "hovered"), + openProjectButton: interactive({ + base: { + background: background(layer), border: border(layer, "active"), + cornerRadius: 4, + margin: { + top: 16, + left: 16, + right: 16, + }, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + ...text(layer, "sans", "default", { size: "sm" }), }, - }, + state: { + hovered: { + ...text(layer, "sans", "default", { size: "sm" }), + background: background(layer, "hovered"), + border: border(layer, "active"), + }, + clicked: { + ...text(layer, "sans", "default", { size: "sm" }), + background: background(layer, "pressed"), + border: border(layer, "active"), + }, + }, + }), background: background(layer), padding: { left: 6, right: 6, top: 0, bottom: 6 }, indentWidth: 12, @@ -94,8 +125,12 @@ export default function projectPanel(colorScheme: ColorScheme) { ...entry, text: text(layer, "mono", "disabled"), active: { - background: background(layer, "active"), - text: text(layer, "mono", "disabled", { size: "sm" }), + ...entry.active, + default: { + ...entry.active.default, + background: background(layer, "active"), + text: text(layer, "mono", "disabled", { size: "sm" }), + }, }, }, filenameEditor: { diff --git a/styles/src/styleTree/search.ts b/styles/src/styleTree/search.ts index d69c4bb2d9..b471e6cbda 100644 --- a/styles/src/styleTree/search.ts +++ b/styles/src/styleTree/search.ts @@ -1,6 +1,7 @@ import { ColorScheme } from "../theme/colorScheme" import { withOpacity } from "../theme/color" import { background, border, foreground, text } from "./components" +import { interactive, toggleable } from "../element" export default function search(colorScheme: ColorScheme) { let layer = colorScheme.highest @@ -35,36 +36,50 @@ export default function search(colorScheme: ColorScheme) { return { // TODO: Add an activeMatchBackground on the rust side to differentiate between active and inactive matchBackground: withOpacity(foreground(layer, "accent"), 0.4), - optionButton: { - ...text(layer, "mono", "on"), - background: background(layer, "on"), - cornerRadius: 6, - border: border(layer, "on"), - margin: { - right: 4, + optionButton: toggleable({ + base: interactive({ + base: { + ...text(layer, "mono", "on"), + background: background(layer, "on"), + cornerRadius: 6, + border: border(layer, "on"), + margin: { + right: 4, + }, + padding: { + bottom: 2, + left: 10, + right: 10, + top: 2, + }, + }, + state: { + hovered: { + ...text(layer, "mono", "on", "hovered"), + background: background(layer, "on", "hovered"), + border: border(layer, "on", "hovered"), + }, + clicked: { + ...text(layer, "mono", "on", "pressed"), + background: background(layer, "on", "pressed"), + border: border(layer, "on", "pressed"), + }, + }, + }), + state: { + active: { + default: { + ...text(layer, "mono", "accent"), + }, + hovered: { + ...text(layer, "mono", "accent", "hovered"), + }, + clicked: { + ...text(layer, "mono", "accent", "pressed"), + }, + }, }, - padding: { - bottom: 2, - left: 10, - right: 10, - top: 2, - }, - active: { - ...text(layer, "mono", "on", "inverted"), - background: background(layer, "on", "inverted"), - border: border(layer, "on", "inverted"), - }, - clicked: { - ...text(layer, "mono", "on", "pressed"), - background: background(layer, "on", "pressed"), - border: border(layer, "on", "pressed"), - }, - hover: { - ...text(layer, "mono", "on", "hovered"), - background: background(layer, "on", "hovered"), - border: border(layer, "on", "hovered"), - }, - }, + }), editor, invalidEditor: { ...editor, @@ -97,17 +112,24 @@ export default function search(colorScheme: ColorScheme) { ...text(layer, "mono", "on"), size: 18, }, - dismissButton: { - color: foreground(layer, "variant"), - iconWidth: 12, - buttonWidth: 14, - padding: { - left: 10, - right: 10, + dismissButton: interactive({ + base: { + color: foreground(layer, "variant"), + iconWidth: 12, + buttonWidth: 14, + padding: { + left: 10, + right: 10, + }, }, - hover: { - color: foreground(layer, "hovered"), + state: { + hovered: { + color: foreground(layer, "hovered"), + }, + clicked: { + color: foreground(layer, "pressed"), + }, }, - }, + }), } } diff --git a/styles/src/styleTree/simpleMessageNotification.ts b/styles/src/styleTree/simpleMessageNotification.ts index 8d88f05c53..e894db3514 100644 --- a/styles/src/styleTree/simpleMessageNotification.ts +++ b/styles/src/styleTree/simpleMessageNotification.ts @@ -1,5 +1,6 @@ import { ColorScheme } from "../theme/colorScheme" import { background, border, foreground, text } from "./components" +import { interactive } from "../element" const headerPadding = 8 @@ -12,33 +13,41 @@ export default function simpleMessageNotification( ...text(layer, "sans", { size: "xs" }), margin: { left: headerPadding, right: headerPadding }, }, - actionMessage: { - ...text(layer, "sans", { size: "xs" }), - border: border(layer, "active"), - cornerRadius: 4, - padding: { - top: 3, - bottom: 3, - left: 7, - right: 7, - }, - - margin: { left: headerPadding, top: 6, bottom: 6 }, - hover: { - ...text(layer, "sans", "default", { size: "xs" }), - background: background(layer, "hovered"), + actionMessage: interactive({ + base: { + ...text(layer, "sans", { size: "xs" }), border: border(layer, "active"), + cornerRadius: 4, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + + margin: { left: headerPadding, top: 6, bottom: 6 }, }, - }, - dismissButton: { - color: foreground(layer), - iconWidth: 8, - iconHeight: 8, - buttonWidth: 8, - buttonHeight: 8, - hover: { - color: foreground(layer, "hovered"), + state: { + hovered: { + ...text(layer, "sans", "default", { size: "xs" }), + background: background(layer, "hovered"), + border: border(layer, "active"), + }, }, - }, + }), + dismissButton: interactive({ + base: { + color: foreground(layer), + iconWidth: 8, + iconHeight: 8, + buttonWidth: 8, + buttonHeight: 8, + }, + state: { + hovered: { + color: foreground(layer, "hovered"), + }, + }, + }), } } diff --git a/styles/src/styleTree/statusBar.ts b/styles/src/styleTree/statusBar.ts index a8d926f40e..339e2e40cf 100644 --- a/styles/src/styleTree/statusBar.ts +++ b/styles/src/styleTree/statusBar.ts @@ -1,6 +1,6 @@ import { ColorScheme } from "../theme/colorScheme" import { background, border, foreground, text } from "./components" - +import { interactive, toggleable } from "../element" export default function statusBar(colorScheme: ColorScheme) { let layer = colorScheme.lowest @@ -25,95 +25,123 @@ export default function statusBar(colorScheme: ColorScheme) { }, border: border(layer, { top: true, overlay: true }), cursorPosition: text(layer, "sans", "variant"), - activeLanguage: { - padding: { left: 6, right: 6 }, - ...text(layer, "sans", "variant"), - hover: { - ...text(layer, "sans", "on"), + activeLanguage: interactive({ + base: { + padding: { left: 6, right: 6 }, + ...text(layer, "sans", "variant"), }, - }, + state: { + hovered: { + ...text(layer, "sans", "on"), + }, + }, + }), autoUpdateProgressMessage: text(layer, "sans", "variant"), autoUpdateDoneMessage: text(layer, "sans", "variant"), - lspStatus: { - ...diagnosticStatusContainer, - iconSpacing: 4, - iconWidth: 14, - height: 18, - message: text(layer, "sans"), - iconColor: foreground(layer), - hover: { + lspStatus: interactive({ + base: { + ...diagnosticStatusContainer, + iconSpacing: 4, + iconWidth: 14, + height: 18, message: text(layer, "sans"), iconColor: foreground(layer), - background: background(layer, "hovered"), }, - }, - diagnosticMessage: { - ...text(layer, "sans"), - hover: text(layer, "sans", "hovered"), - }, - diagnosticSummary: { - height: 20, - iconWidth: 16, - iconSpacing: 2, - summarySpacing: 6, - text: text(layer, "sans", { size: "sm" }), - iconColorOk: foreground(layer, "variant"), - iconColorWarning: foreground(layer, "warning"), - iconColorError: foreground(layer, "negative"), - containerOk: { - cornerRadius: 6, - padding: { top: 3, bottom: 3, left: 7, right: 7 }, + state: { + hovered: { + message: text(layer, "sans"), + iconColor: foreground(layer), + background: background(layer, "hovered"), + }, }, - containerWarning: { - ...diagnosticStatusContainer, - background: background(layer, "warning"), - border: border(layer, "warning"), + }), + diagnosticMessage: interactive({ + base: { + ...text(layer, "sans"), }, - containerError: { - ...diagnosticStatusContainer, - background: background(layer, "negative"), - border: border(layer, "negative"), - }, - hover: { - iconColorOk: foreground(layer, "on"), + state: { hovered: text(layer, "sans", "hovered") }, + }), + diagnosticSummary: interactive({ + base: { + height: 20, + iconWidth: 16, + iconSpacing: 2, + summarySpacing: 6, + text: text(layer, "sans", { size: "sm" }), + iconColorOk: foreground(layer, "variant"), + iconColorWarning: foreground(layer, "warning"), + iconColorError: foreground(layer, "negative"), containerOk: { cornerRadius: 6, padding: { top: 3, bottom: 3, left: 7, right: 7 }, - background: background(layer, "on", "hovered"), }, containerWarning: { ...diagnosticStatusContainer, - background: background(layer, "warning", "hovered"), - border: border(layer, "warning", "hovered"), + background: background(layer, "warning"), + border: border(layer, "warning"), }, containerError: { ...diagnosticStatusContainer, - background: background(layer, "negative", "hovered"), - border: border(layer, "negative", "hovered"), + background: background(layer, "negative"), + border: border(layer, "negative"), }, }, - }, + state: { + hovered: { + iconColorOk: foreground(layer, "on"), + containerOk: { + background: background(layer, "on", "hovered"), + }, + containerWarning: { + background: background(layer, "warning", "hovered"), + border: border(layer, "warning", "hovered"), + }, + containerError: { + background: background(layer, "negative", "hovered"), + border: border(layer, "negative", "hovered"), + }, + }, + }, + }), panelButtons: { groupLeft: {}, groupBottom: {}, groupRight: {}, - button: { - ...statusContainer, - iconSize: 16, - iconColor: foreground(layer, "variant"), - label: { - margin: { left: 6 }, - ...text(layer, "sans", { size: "sm" }), + button: toggleable({ + base: interactive({ + base: { + ...statusContainer, + iconSize: 16, + iconColor: foreground(layer, "variant"), + label: { + margin: { left: 6 }, + ...text(layer, "sans", { size: "sm" }), + }, + }, + state: { + hovered: { + iconColor: foreground(layer, "hovered"), + background: background(layer, "variant"), + }, + }, + }), + state: { + active: { + default: { + iconColor: foreground(layer, "active"), + background: background(layer, "active"), + }, + hovered: { + iconColor: foreground(layer, "hovered"), + background: background(layer, "hovered"), + }, + clicked: { + iconColor: foreground(layer, "pressed"), + background: background(layer, "pressed"), + }, + }, }, - hover: { - iconColor: foreground(layer, "hovered"), - background: background(layer, "variant"), - }, - active: { - iconColor: foreground(layer, "active"), - background: background(layer, "active"), - }, - }, + }), badge: { cornerRadius: 3, padding: 2, diff --git a/styles/src/styleTree/tabBar.ts b/styles/src/styleTree/tabBar.ts index c5b397b34a..af35a8fef4 100644 --- a/styles/src/styleTree/tabBar.ts +++ b/styles/src/styleTree/tabBar.ts @@ -1,6 +1,7 @@ import { ColorScheme } from "../theme/colorScheme" import { withOpacity } from "../theme/color" import { text, border, background, foreground } from "./components" +import { interactive, toggleable } from "../element" export default function tabBar(colorScheme: ColorScheme) { const height = 32 @@ -87,17 +88,36 @@ export default function tabBar(colorScheme: ColorScheme) { inactiveTab: inactivePaneInactiveTab, }, draggedTab, - paneButton: { - color: foreground(layer, "variant"), - iconWidth: 12, - buttonWidth: activePaneActiveTab.height, - hover: { - color: foreground(layer, "hovered"), + paneButton: toggleable({ + base: interactive({ + base: { + color: foreground(layer, "variant"), + iconWidth: 12, + buttonWidth: activePaneActiveTab.height, + }, + state: { + hovered: { + color: foreground(layer, "hovered"), + }, + clicked: { + color: foreground(layer, "pressed"), + }, + }, + }), + state: { + active: { + default: { + color: foreground(layer, "accent"), + }, + hovered: { + color: foreground(layer, "hovered"), + }, + clicked: { + color: foreground(layer, "pressed"), + }, + }, }, - active: { - color: foreground(layer, "accent"), - }, - }, + }), paneButtonContainer: { background: tab.background, border: { diff --git a/styles/src/styleTree/toggle.ts b/styles/src/styleTree/toggle.ts new file mode 100644 index 0000000000..2b6858e15b --- /dev/null +++ b/styles/src/styleTree/toggle.ts @@ -0,0 +1,47 @@ +import merge from "ts-deepmerge" + +type ToggleState = "inactive" | "active" + +type Toggleable = Record + +const NO_INACTIVE_OR_BASE_ERROR = + "A toggleable object must have an inactive state, or a base property." +const NO_ACTIVE_ERROR = "A toggleable object must have an active state." + +interface ToggleableProps { + base?: T + state: Partial> +} + +/** + * Helper function for creating Toggleable objects. + * @template T The type of the object being toggled. + * @param props Object containing the base (inactive) state and state modifications to create the active state. + * @returns A Toggleable object containing both the inactive and active states. + * @example + * ``` + * toggleable({ + * base: { background: "#000000", text: "#CCCCCC" }, + * state: { active: { text: "#CCCCCC" } }, + * }) + * ``` + */ +export function toggleable( + props: ToggleableProps +): Toggleable { + const { base, state } = props + + if (!base && !state.inactive) throw new Error(NO_INACTIVE_OR_BASE_ERROR) + if (!state.active) throw new Error(NO_ACTIVE_ERROR) + + const inactiveState = base + ? ((state.inactive ? merge(base, state.inactive) : base) as T) + : (state.inactive as T) + + const toggleObj: Toggleable = { + inactive: inactiveState, + active: merge(base ?? {}, state.active) as T, + } + + return toggleObj +} diff --git a/styles/src/styleTree/toolbarDropdownMenu.ts b/styles/src/styleTree/toolbarDropdownMenu.ts new file mode 100644 index 0000000000..d82e5f1cde --- /dev/null +++ b/styles/src/styleTree/toolbarDropdownMenu.ts @@ -0,0 +1,64 @@ +import { ColorScheme } from "../theme/colorScheme" +import { background, border, text } from "./components" +import { interactive, toggleable } from "../element" +export default function dropdownMenu(colorScheme: ColorScheme) { + let layer = colorScheme.middle + + return { + rowHeight: 30, + background: background(layer), + border: border(layer), + shadow: colorScheme.popoverShadow, + header: interactive({ + base: { + ...text(layer, "sans", { size: "sm" }), + secondaryText: text(layer, "sans", { + size: "sm", + color: "#aaaaaa", + }), + secondaryTextSpacing: 10, + padding: { left: 8, right: 8, top: 2, bottom: 2 }, + cornerRadius: 6, + background: background(layer, "on"), + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }), + sectionHeader: { + ...text(layer, "sans", { size: "sm" }), + padding: { left: 8, right: 8, top: 8, bottom: 8 }, + }, + item: toggleable({ + base: interactive({ + base: { + ...text(layer, "sans", { size: "sm" }), + secondaryTextSpacing: 10, + secondaryText: text(layer, "sans", { size: "sm" }), + padding: { left: 18, right: 18, top: 2, bottom: 2 }, + }, + state: { + hovered: { + background: background(layer, "hovered"), + ...text(layer, "sans", "hovered", { size: "sm" }), + }, + }, + }), + state: { + active: { + default: { + background: background(layer, "active"), + }, + hovered: { + background: background(layer, "hovered"), + }, + }, + }, + }), + } +} diff --git a/styles/src/styleTree/updateNotification.ts b/styles/src/styleTree/updateNotification.ts index 281012e62f..c6ef81c667 100644 --- a/styles/src/styleTree/updateNotification.ts +++ b/styles/src/styleTree/updateNotification.ts @@ -1,5 +1,6 @@ import { ColorScheme } from "../theme/colorScheme" import { foreground, text } from "./components" +import { interactive } from "../element" const headerPadding = 8 @@ -10,22 +11,30 @@ export default function updateNotification(colorScheme: ColorScheme): Object { ...text(layer, "sans", { size: "xs" }), margin: { left: headerPadding, right: headerPadding }, }, - actionMessage: { - ...text(layer, "sans", { size: "xs" }), - margin: { left: headerPadding, top: 6, bottom: 6 }, - hover: { - color: foreground(layer, "hovered"), + actionMessage: interactive({ + base: { + ...text(layer, "sans", { size: "xs" }), + margin: { left: headerPadding, top: 6, bottom: 6 }, }, - }, - dismissButton: { - color: foreground(layer), - iconWidth: 8, - iconHeight: 8, - buttonWidth: 8, - buttonHeight: 8, - hover: { - color: foreground(layer, "hovered"), + state: { + hovered: { + color: foreground(layer, "hovered"), + }, }, - }, + }), + dismissButton: interactive({ + base: { + color: foreground(layer), + iconWidth: 8, + iconHeight: 8, + buttonWidth: 8, + buttonHeight: 8, + }, + state: { + hovered: { + color: foreground(layer, "hovered"), + }, + }, + }), } } diff --git a/styles/src/styleTree/welcome.ts b/styles/src/styleTree/welcome.ts index 10e6e02b95..311ff6daff 100644 --- a/styles/src/styleTree/welcome.ts +++ b/styles/src/styleTree/welcome.ts @@ -8,6 +8,7 @@ import { TextProperties, svg, } from "./components" +import { interactive } from "../element" export default function welcome(colorScheme: ColorScheme) { let layer = colorScheme.highest @@ -63,27 +64,31 @@ export default function welcome(colorScheme: ColorScheme) { bottom: 2, }, }, - button: { - background: background(layer), - border: border(layer, "active"), - cornerRadius: 4, - margin: { - top: 4, - bottom: 4, - }, - padding: { - top: 3, - bottom: 3, - left: 7, - right: 7, - }, - ...text(layer, "sans", "default", interactive_text_size), - hover: { - ...text(layer, "sans", "default", interactive_text_size), - background: background(layer, "hovered"), + button: interactive({ + base: { + background: background(layer), border: border(layer, "active"), + cornerRadius: 4, + margin: { + top: 4, + bottom: 4, + }, + padding: { + top: 3, + bottom: 3, + left: 7, + right: 7, + }, + ...text(layer, "sans", "default", interactive_text_size), }, - }, + state: { + hovered: { + ...text(layer, "sans", "default", interactive_text_size), + background: background(layer, "hovered"), + }, + }, + }), + usageNote: { ...text(layer, "sans", "variant", { size: "2xs" }), padding: { diff --git a/styles/src/styleTree/workspace.ts b/styles/src/styleTree/workspace.ts index ae8de178f8..bd14a3af72 100644 --- a/styles/src/styleTree/workspace.ts +++ b/styles/src/styleTree/workspace.ts @@ -1,5 +1,6 @@ import { ColorScheme } from "../theme/colorScheme" import { withOpacity } from "../theme/color" +import { toggleable } from "../element" import { background, border, @@ -10,38 +11,53 @@ import { } from "./components" import statusBar from "./statusBar" import tabBar from "./tabBar" - +import { interactive } from "../element" +import merge from "ts-deepmerge" export default function workspace(colorScheme: ColorScheme) { const layer = colorScheme.lowest const isLight = colorScheme.isLight const itemSpacing = 8 - const titlebarButton = { - cornerRadius: 6, - padding: { - top: 1, - bottom: 1, - left: 8, - right: 8, + const titlebarButton = toggleable({ + base: interactive({ + base: { + cornerRadius: 6, + padding: { + top: 1, + bottom: 1, + left: 8, + right: 8, + }, + ...text(layer, "sans", "variant", { size: "xs" }), + background: background(layer, "variant"), + border: border(layer), + }, + state: { + hovered: { + ...text(layer, "sans", "variant", "hovered", { + size: "xs", + }), + background: background(layer, "variant", "hovered"), + border: border(layer, "variant", "hovered"), + }, + clicked: { + ...text(layer, "sans", "variant", "pressed", { + size: "xs", + }), + background: background(layer, "variant", "pressed"), + border: border(layer, "variant", "pressed"), + }, + }, + }), + state: { + active: { + default: { + ...text(layer, "sans", "variant", "active", { size: "xs" }), + background: background(layer, "variant", "active"), + border: border(layer, "variant", "active"), + }, + }, }, - ...text(layer, "sans", "variant", { size: "xs" }), - background: background(layer, "variant"), - border: border(layer), - hover: { - ...text(layer, "sans", "variant", "hovered", { size: "xs" }), - background: background(layer, "variant", "hovered"), - border: border(layer, "variant", "hovered"), - }, - clicked: { - ...text(layer, "sans", "variant", "pressed", { size: "xs" }), - background: background(layer, "variant", "pressed"), - border: border(layer, "variant", "pressed"), - }, - active: { - ...text(layer, "sans", "variant", "active", { size: "xs" }), - background: background(layer, "variant", "active"), - border: border(layer, "variant", "active"), - }, - } + }) const avatarWidth = 18 const avatarOuterWidth = avatarWidth + 4 const followerAvatarWidth = 14 @@ -78,19 +94,24 @@ export default function workspace(colorScheme: ColorScheme) { }, cornerRadius: 4, }, - keyboardHint: { - ...text(layer, "sans", "variant", { size: "sm" }), - padding: { - top: 3, - left: 8, - right: 8, - bottom: 3, + keyboardHint: interactive({ + base: { + ...text(layer, "sans", "variant", { size: "sm" }), + padding: { + top: 3, + left: 8, + right: 8, + bottom: 3, + }, + cornerRadius: 8, }, - cornerRadius: 8, - hover: { - ...text(layer, "sans", "active", { size: "sm" }), + state: { + hovered: { + ...text(layer, "sans", "active", { size: "sm" }), + }, }, - }, + }), + keyboardHintWidth: 320, }, joiningProjectAvatar: { @@ -201,12 +222,15 @@ export default function workspace(colorScheme: ColorScheme) { // Sign in buttom // FlatButton, Variant - signInPrompt: { - margin: { - left: itemSpacing, + signInPrompt: merge(titlebarButton, { + inactive: { + default: { + margin: { + left: itemSpacing, + }, + }, }, - ...titlebarButton, - }, + }), // Offline Indicator offlineIcon: { @@ -234,40 +258,68 @@ export default function workspace(colorScheme: ColorScheme) { }, cornerRadius: 6, }, - callControl: { - cornerRadius: 6, - color: foreground(layer, "variant"), - iconWidth: 12, - buttonWidth: 20, - hover: { - background: background(layer, "variant", "hovered"), - color: foreground(layer, "variant", "hovered"), + callControl: interactive({ + base: { + cornerRadius: 6, + color: foreground(layer, "variant"), + iconWidth: 12, + buttonWidth: 20, + }, + state: { + hovered: { + background: background(layer, "variant", "hovered"), + color: foreground(layer, "variant", "hovered"), + }, + }, + }), + toggleContactsButton: toggleable({ + base: interactive({ + base: { + margin: { left: itemSpacing }, + cornerRadius: 6, + color: foreground(layer, "variant"), + iconWidth: 14, + buttonWidth: 20, + }, + state: { + clicked: { + background: background(layer, "variant", "pressed"), + }, + hovered: { + background: background(layer, "variant", "hovered"), + }, + }, + }), + state: { + active: { + default: { + background: background(layer, "on", "default"), + }, + hovered: { + background: background(layer, "on", "hovered"), + }, + clicked: { + background: background(layer, "on", "pressed"), + }, + }, + }, + }), + userMenuButton: merge(titlebarButton, { + inactive: { + default: { + buttonWidth: 20, + iconWidth: 12, + }, }, - }, - toggleContactsButton: { - margin: { left: itemSpacing }, - cornerRadius: 6, - color: foreground(layer, "variant"), - iconWidth: 14, - buttonWidth: 20, active: { - background: background(layer, "variant", "active"), - color: foreground(layer, "variant", "active"), + // posiewic: these properties are not currently set on main + default: { + iconWidth: 12, + button_width: 20, + }, }, - clicked: { - background: background(layer, "variant", "pressed"), - color: foreground(layer, "variant", "pressed"), - }, - hover: { - background: background(layer, "variant", "hovered"), - color: foreground(layer, "variant", "hovered"), - }, - }, - userMenuButton: { - buttonWidth: 20, - iconWidth: 12, - ...titlebarButton, - }, + }), + toggleContactsBadge: { cornerRadius: 3, padding: 2, @@ -285,12 +337,45 @@ export default function workspace(colorScheme: ColorScheme) { background: background(colorScheme.highest), border: border(colorScheme.highest, { bottom: true }), itemSpacing: 8, - navButton: { - color: foreground(colorScheme.highest, "on"), - iconWidth: 12, - buttonWidth: 24, + navButton: interactive({ + base: { + color: foreground(colorScheme.highest, "on"), + iconWidth: 12, + buttonWidth: 24, + cornerRadius: 6, + }, + state: { + hovered: { + color: foreground(colorScheme.highest, "on", "hovered"), + background: background( + colorScheme.highest, + "on", + "hovered" + ), + }, + disabled: { + color: foreground( + colorScheme.highest, + "on", + "disabled" + ), + }, + }, + }), + padding: { left: 8, right: 8, top: 4, bottom: 4 }, + }, + breadcrumbHeight: 24, + breadcrumbs: interactive({ + base: { + ...text(colorScheme.highest, "sans", "variant"), cornerRadius: 6, - hover: { + padding: { + left: 6, + right: 6, + }, + }, + state: { + hovered: { color: foreground(colorScheme.highest, "on", "hovered"), background: background( colorScheme.highest, @@ -298,25 +383,8 @@ export default function workspace(colorScheme: ColorScheme) { "hovered" ), }, - disabled: { - color: foreground(colorScheme.highest, "on", "disabled"), - }, }, - padding: { left: 8, right: 8, top: 4, bottom: 4 }, - }, - breadcrumbHeight: 24, - breadcrumbs: { - ...text(colorScheme.highest, "sans", "variant"), - cornerRadius: 6, - padding: { - left: 6, - right: 6, - }, - hover: { - color: foreground(colorScheme.highest, "on", "hovered"), - background: background(colorScheme.highest, "on", "hovered"), - }, - }, + }), disconnectedOverlay: { ...text(layer, "sans"), background: withOpacity(background(layer), 0.8), diff --git a/styles/src/theme/syntax.ts b/styles/src/theme/syntax.ts index 380fd31786..369fceb070 100644 --- a/styles/src/theme/syntax.ts +++ b/styles/src/theme/syntax.ts @@ -129,8 +129,6 @@ function buildDefaultSyntax(colorScheme: ColorScheme): Syntax { [key: string]: Omit } = {} - const light = colorScheme.isLight - // then spread the default to each style for (const key of Object.keys({} as Syntax)) { syntax[key as keyof Syntax] = { diff --git a/styles/src/theme/tokens/colorScheme.ts b/styles/src/theme/tokens/colorScheme.ts index 84b8db5ce0..bc53ca802a 100644 --- a/styles/src/theme/tokens/colorScheme.ts +++ b/styles/src/theme/tokens/colorScheme.ts @@ -1,9 +1,19 @@ -import { SingleBoxShadowToken, SingleColorToken, SingleOtherToken, TokenTypes } from "@tokens-studio/types" -import { ColorScheme, Shadow, SyntaxHighlightStyle, ThemeSyntax } from "../colorScheme" +import { + SingleBoxShadowToken, + SingleColorToken, + SingleOtherToken, + TokenTypes, +} from "@tokens-studio/types" +import { + ColorScheme, + Shadow, + SyntaxHighlightStyle, + ThemeSyntax, +} from "../colorScheme" import { LayerToken, layerToken } from "./layer" import { PlayersToken, playersToken } from "./players" import { colorToken } from "./token" -import { Syntax } from "../syntax"; +import { Syntax } from "../syntax" import editor from "../../styleTree/editor" interface ColorSchemeTokens { @@ -18,27 +28,32 @@ interface ColorSchemeTokens { syntax?: Partial } -const createShadowToken = (shadow: Shadow, tokenName: string): SingleBoxShadowToken => { +const createShadowToken = ( + shadow: Shadow, + tokenName: string +): SingleBoxShadowToken => { return { name: tokenName, type: TokenTypes.BOX_SHADOW, - value: `${shadow.offset[0]}px ${shadow.offset[1]}px ${shadow.blur}px 0px ${shadow.color}` - }; -}; + value: `${shadow.offset[0]}px ${shadow.offset[1]}px ${shadow.blur}px 0px ${shadow.color}`, + } +} const popoverShadowToken = (colorScheme: ColorScheme): SingleBoxShadowToken => { - const shadow = colorScheme.popoverShadow; - return createShadowToken(shadow, "popoverShadow"); -}; + const shadow = colorScheme.popoverShadow + return createShadowToken(shadow, "popoverShadow") +} const modalShadowToken = (colorScheme: ColorScheme): SingleBoxShadowToken => { - const shadow = colorScheme.modalShadow; - return createShadowToken(shadow, "modalShadow"); -}; + const shadow = colorScheme.modalShadow + return createShadowToken(shadow, "modalShadow") +} type ThemeSyntaxColorTokens = Record -function syntaxHighlightStyleColorTokens(syntax: Syntax): ThemeSyntaxColorTokens { +function syntaxHighlightStyleColorTokens( + syntax: Syntax +): ThemeSyntaxColorTokens { const styleKeys = Object.keys(syntax) as (keyof Syntax)[] return styleKeys.reduce((acc, styleKey) => { @@ -46,13 +61,16 @@ function syntaxHighlightStyleColorTokens(syntax: Syntax): ThemeSyntaxColorTokens // This can happen because we have a "constructor" property on the syntax object // and a "constructor" property on the prototype of the syntax object // To work around this just assert that the type of the style is not a function - if (!syntax[styleKey] || typeof syntax[styleKey] === 'function') return acc; - const { color } = syntax[styleKey] as Required; - return { ...acc, [styleKey]: colorToken(styleKey, color) }; - }, {} as ThemeSyntaxColorTokens); + if (!syntax[styleKey] || typeof syntax[styleKey] === "function") + return acc + const { color } = syntax[styleKey] as Required + return { ...acc, [styleKey]: colorToken(styleKey, color) } + }, {} as ThemeSyntaxColorTokens) } -const syntaxTokens = (colorScheme: ColorScheme): ColorSchemeTokens['syntax'] => { +const syntaxTokens = ( + colorScheme: ColorScheme +): ColorSchemeTokens["syntax"] => { const syntax = editor(colorScheme).syntax return syntaxHighlightStyleColorTokens(syntax) diff --git a/styles/src/theme/tokens/layer.ts b/styles/src/theme/tokens/layer.ts index 3ee813b8c4..42a69b5a52 100644 --- a/styles/src/theme/tokens/layer.ts +++ b/styles/src/theme/tokens/layer.ts @@ -1,11 +1,11 @@ -import { SingleColorToken } from "@tokens-studio/types"; -import { Layer, Style, StyleSet } from "../colorScheme"; -import { colorToken } from "./token"; +import { SingleColorToken } from "@tokens-studio/types" +import { Layer, Style, StyleSet } from "../colorScheme" +import { colorToken } from "./token" interface StyleToken { - background: SingleColorToken, - border: SingleColorToken, - foreground: SingleColorToken, + background: SingleColorToken + border: SingleColorToken + foreground: SingleColorToken } interface StyleSetToken { @@ -37,24 +37,27 @@ export const styleToken = (style: Style, name: string): StyleToken => { return token } -export const styleSetToken = (styleSet: StyleSet, name: string): StyleSetToken => { - const token: StyleSetToken = {} as StyleSetToken; +export const styleSetToken = ( + styleSet: StyleSet, + name: string +): StyleSetToken => { + const token: StyleSetToken = {} as StyleSetToken for (const style in styleSet) { - const s = style as keyof StyleSet; - token[s] = styleToken(styleSet[s], `${name}${style}`); + const s = style as keyof StyleSet + token[s] = styleToken(styleSet[s], `${name}${style}`) } - return token; + return token } export const layerToken = (layer: Layer, name: string): LayerToken => { - const token: LayerToken = {} as LayerToken; + const token: LayerToken = {} as LayerToken for (const styleSet in layer) { - const s = styleSet as keyof Layer; - token[s] = styleSetToken(layer[s], `${name}${styleSet}`); + const s = styleSet as keyof Layer + token[s] = styleSetToken(layer[s], `${name}${styleSet}`) } - return token; + return token } diff --git a/styles/src/theme/tokens/players.ts b/styles/src/theme/tokens/players.ts index b5f5538b2e..94d05cd827 100644 --- a/styles/src/theme/tokens/players.ts +++ b/styles/src/theme/tokens/players.ts @@ -6,13 +6,21 @@ export type PlayerToken = Record<"selection" | "cursor", SingleColorToken> export type PlayersToken = Record -function buildPlayerToken(colorScheme: ColorScheme, index: number): PlayerToken { - +function buildPlayerToken( + colorScheme: ColorScheme, + index: number +): PlayerToken { const playerNumber = index.toString() as keyof Players return { - selection: colorToken(`player${index}Selection`, colorScheme.players[playerNumber].selection), - cursor: colorToken(`player${index}Cursor`, colorScheme.players[playerNumber].cursor), + selection: colorToken( + `player${index}Selection`, + colorScheme.players[playerNumber].selection + ), + cursor: colorToken( + `player${index}Cursor`, + colorScheme.players[playerNumber].cursor + ), } } @@ -24,5 +32,5 @@ export const playersToken = (colorScheme: ColorScheme): PlayersToken => ({ "4": buildPlayerToken(colorScheme, 4), "5": buildPlayerToken(colorScheme, 5), "6": buildPlayerToken(colorScheme, 6), - "7": buildPlayerToken(colorScheme, 7) + "7": buildPlayerToken(colorScheme, 7), }) diff --git a/styles/src/theme/tokens/token.ts b/styles/src/theme/tokens/token.ts index 3e5187dd64..2f1760778e 100644 --- a/styles/src/theme/tokens/token.ts +++ b/styles/src/theme/tokens/token.ts @@ -1,6 +1,10 @@ import { SingleColorToken, TokenTypes } from "@tokens-studio/types" -export function colorToken(name: string, value: string, description?: string): SingleColorToken { +export function colorToken( + name: string, + value: string, + description?: string +): SingleColorToken { const token: SingleColorToken = { name, type: TokenTypes.COLOR, @@ -8,7 +12,8 @@ export function colorToken(name: string, value: string, description?: string): S description, } - if (!token.value || token.value === '') throw new Error("Color token must have a value") + if (!token.value || token.value === "") + throw new Error("Color token must have a value") return token } diff --git a/styles/src/themes/rose-pine/common.ts b/styles/src/themes/rose-pine/common.ts new file mode 100644 index 0000000000..8b3b3b7000 --- /dev/null +++ b/styles/src/themes/rose-pine/common.ts @@ -0,0 +1,75 @@ +import { ThemeSyntax } from "../../common"; + +export const color = { + default: { + base: '#191724', + surface: '#1f1d2e', + overlay: '#26233a', + muted: '#6e6a86', + subtle: '#908caa', + text: '#e0def4', + love: '#eb6f92', + gold: '#f6c177', + rose: '#ebbcba', + pine: '#31748f', + foam: '#9ccfd8', + iris: '#c4a7e7', + highlightLow: '#21202e', + highlightMed: '#403d52', + highlightHigh: '#524f67', + }, + moon: { + base: '#232136', + surface: '#2a273f', + overlay: '#393552', + muted: '#6e6a86', + subtle: '#908caa', + text: '#e0def4', + love: '#eb6f92', + gold: '#f6c177', + rose: '#ea9a97', + pine: '#3e8fb0', + foam: '#9ccfd8', + iris: '#c4a7e7', + highlightLow: '#2a283e', + highlightMed: '#44415a', + highlightHigh: '#56526e', + }, + dawn: { + base: "#faf4ed", + surface: "#fffaf3", + overlay: "#f2e9e1", + muted: "#9893a5", + subtle: "#797593", + text: "#575279", + love: "#b4637a", + gold: "#ea9d34", + rose: "#d7827e", + pine: "#286983", + foam: "#56949f", + iris: "#907aa9", + highlightLow: "#f4ede8", + highlightMed: "#dfdad9", + highlightHigh: "#cecacd", + } +}; + +export const syntax = (c: typeof color.default): Partial => { + return { + comment: { color: c.muted }, + operator: { color: c.pine }, + punctuation: { color: c.subtle }, + variable: { color: c.text }, + string: { color: c.gold }, + type: { color: c.foam }, + "type.builtin": { color: c.foam }, + boolean: { color: c.rose }, + function: { color: c.rose }, + keyword: { color: c.pine }, + tag: { color: c.foam }, + "function.method": { color: c.rose }, + title: { color: c.gold }, + linkText: { color: c.foam, italic: false }, + linkUri: { color: c.rose }, + } +} diff --git a/styles/src/themes/rose-pine/rose-pine-dawn.ts b/styles/src/themes/rose-pine/rose-pine-dawn.ts index a373ed378c..3ec5d30bb6 100644 --- a/styles/src/themes/rose-pine/rose-pine-dawn.ts +++ b/styles/src/themes/rose-pine/rose-pine-dawn.ts @@ -6,6 +6,13 @@ import { ThemeConfig, } from "../../common" +import { color as c, syntax } from "./common"; + +const color = c.dawn + +const green = chroma.mix(color.foam, "#10b981", 0.6, 'lab'); +const magenta = chroma.mix(color.love, color.pine, 0.5, 'lab'); + export const theme: ThemeConfig = { name: "Rosé Pine Dawn", author: "edunfelt", @@ -14,26 +21,17 @@ export const theme: ThemeConfig = { licenseUrl: "https://github.com/edunfelt/base16-rose-pine-scheme", licenseFile: `${__dirname}/LICENSE`, inputColor: { - neutral: chroma - .scale([ - "#575279", - "#797593", - "#9893A5", - "#B5AFB8", - "#D3CCCC", - "#F2E9E1", - "#FFFAF3", - "#FAF4ED", - ]) - .domain([0, 0.35, 0.45, 0.65, 0.7, 0.8, 0.9, 1]), - red: colorRamp(chroma("#B4637A")), - orange: colorRamp(chroma("#D7827E")), - yellow: colorRamp(chroma("#EA9D34")), - green: colorRamp(chroma("#679967")), - cyan: colorRamp(chroma("#286983")), - blue: colorRamp(chroma("#56949F")), - violet: colorRamp(chroma("#907AA9")), - magenta: colorRamp(chroma("#79549F")), + neutral: chroma.scale([color.base, color.surface, color.highlightHigh, color.overlay, color.muted, color.subtle, color.text].reverse()).domain([0, 0.35, 0.45, 0.65, 0.7, 0.8, 0.9, 1]), + red: colorRamp(chroma(color.love)), + orange: colorRamp(chroma(color.iris)), + yellow: colorRamp(chroma(color.gold)), + green: colorRamp(chroma(green)), + cyan: colorRamp(chroma(color.pine)), + blue: colorRamp(chroma(color.foam)), + violet: colorRamp(chroma(color.iris)), + magenta: colorRamp(chroma(magenta)), }, - override: { syntax: {} }, + override: { + syntax: syntax(color) + } } diff --git a/styles/src/themes/rose-pine/rose-pine-moon.ts b/styles/src/themes/rose-pine/rose-pine-moon.ts index 94b8166cb3..4e70d2f0ad 100644 --- a/styles/src/themes/rose-pine/rose-pine-moon.ts +++ b/styles/src/themes/rose-pine/rose-pine-moon.ts @@ -6,6 +6,13 @@ import { ThemeConfig, } from "../../common" +import { color as c, syntax } from "./common"; + +const color = c.moon + +const green = chroma.mix(color.foam, "#10b981", 0.6, 'lab'); +const magenta = chroma.mix(color.love, color.pine, 0.5, 'lab'); + export const theme: ThemeConfig = { name: "Rosé Pine Moon", author: "edunfelt", @@ -14,26 +21,17 @@ export const theme: ThemeConfig = { licenseUrl: "https://github.com/edunfelt/base16-rose-pine-scheme", licenseFile: `${__dirname}/LICENSE`, inputColor: { - neutral: chroma - .scale([ - "#232136", - "#2A273F", - "#393552", - "#3E3A53", - "#56526C", - "#6E6A86", - "#908CAA", - "#E0DEF4", - ]) - .domain([0, 0.3, 0.55, 1]), - red: colorRamp(chroma("#EB6F92")), - orange: colorRamp(chroma("#EBBCBA")), - yellow: colorRamp(chroma("#F6C177")), - green: colorRamp(chroma("#8DBD8D")), - cyan: colorRamp(chroma("#409BBE")), - blue: colorRamp(chroma("#9CCFD8")), - violet: colorRamp(chroma("#C4A7E7")), - magenta: colorRamp(chroma("#AB6FE9")), + neutral: chroma.scale([color.base, color.surface, color.highlightHigh, color.overlay, color.muted, color.subtle, color.text]).domain([0, 0.3, 0.55, 1]), + red: colorRamp(chroma(color.love)), + orange: colorRamp(chroma(color.iris)), + yellow: colorRamp(chroma(color.gold)), + green: colorRamp(chroma(green)), + cyan: colorRamp(chroma(color.pine)), + blue: colorRamp(chroma(color.foam)), + violet: colorRamp(chroma(color.iris)), + magenta: colorRamp(chroma(magenta)), }, - override: { syntax: {} }, + override: { + syntax: syntax(color) + } } diff --git a/styles/src/themes/rose-pine/rose-pine.ts b/styles/src/themes/rose-pine/rose-pine.ts index 3aabe3f10e..6c4a6ac49b 100644 --- a/styles/src/themes/rose-pine/rose-pine.ts +++ b/styles/src/themes/rose-pine/rose-pine.ts @@ -5,6 +5,12 @@ import { ThemeLicenseType, ThemeConfig, } from "../../common" +import { color as c, syntax } from "./common"; + +const color = c.default + +const green = chroma.mix(color.foam, "#10b981", 0.6, 'lab'); +const magenta = chroma.mix(color.love, color.pine, 0.5, 'lab'); export const theme: ThemeConfig = { name: "Rosé Pine", @@ -14,24 +20,17 @@ export const theme: ThemeConfig = { licenseUrl: "https://github.com/edunfelt/base16-rose-pine-scheme", licenseFile: `${__dirname}/LICENSE`, inputColor: { - neutral: chroma.scale([ - "#191724", - "#1f1d2e", - "#26233A", - "#3E3A53", - "#56526C", - "#6E6A86", - "#908CAA", - "#E0DEF4", - ]), - red: colorRamp(chroma("#EB6F92")), - orange: colorRamp(chroma("#EBBCBA")), - yellow: colorRamp(chroma("#F6C177")), - green: colorRamp(chroma("#8DBD8D")), - cyan: colorRamp(chroma("#409BBE")), - blue: colorRamp(chroma("#9CCFD8")), - violet: colorRamp(chroma("#C4A7E7")), - magenta: colorRamp(chroma("#AB6FE9")), + neutral: chroma.scale([color.base, color.surface, color.highlightHigh, color.overlay, color.muted, color.subtle, color.text]), + red: colorRamp(chroma(color.love)), + orange: colorRamp(chroma(color.iris)), + yellow: colorRamp(chroma(color.gold)), + green: colorRamp(chroma(green)), + cyan: colorRamp(chroma(color.pine)), + blue: colorRamp(chroma(color.foam)), + violet: colorRamp(chroma(color.iris)), + magenta: colorRamp(chroma(magenta)), }, - override: { syntax: {} }, + override: { + syntax: syntax(color) + } } diff --git a/styles/src/utils/slugify.ts b/styles/src/utils/slugify.ts index 62b226cd10..b5c3b3c519 100644 --- a/styles/src/utils/slugify.ts +++ b/styles/src/utils/slugify.ts @@ -1 +1,10 @@ -export function slugify(t: string): string { return t.toString().toLowerCase().replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '').replace(/-+$/, '') } +export function slugify(t: string): string { + return t + .toString() + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\w\-]+/g, "") + .replace(/\-\-+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") +} diff --git a/styles/tsconfig.json b/styles/tsconfig.json index 6d24728a0a..7a8ec69927 100644 --- a/styles/tsconfig.json +++ b/styles/tsconfig.json @@ -20,7 +20,17 @@ "noFallthroughCasesInSwitch": false, "experimentalDecorators": true, "strictPropertyInitialization": false, - "skipLibCheck": true + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["./*"], + "@element/*": ["./src/element/*"], + "@component/*": ["./src/component/*"], + "@styleTree/*": ["./src/styleTree/*"], + "@theme/*": ["./src/theme/*"], + "@themes/*": ["./src/themes/*"], + "@util/*": ["./src/util/*"] + } }, "exclude": ["node_modules"] } diff --git a/styles/vitest.config.ts b/styles/vitest.config.ts new file mode 100644 index 0000000000..00f3a9852d --- /dev/null +++ b/styles/vitest.config.ts @@ -0,0 +1,8 @@ +import { configDefaults, defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude, "target/*"], + include: ["src/**/*.{spec,test}.ts"], + }, +})