diff --git a/.github/actions/check_formatting/action.yml b/.github/actions/check_formatting/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..7fef26407bd866babfb10c1aac6222968f54c1fd --- /dev/null +++ b/.github/actions/check_formatting/action.yml @@ -0,0 +1,15 @@ +name: 'Check formatting' +description: 'Checks code formatting use cargo fmt' + +runs: + using: "composite" + steps: + - name: Install Rust + shell: bash -euxo pipefail {0} + run: | + rustup set profile minimal + rustup update stable + + - name: cargo fmt + shell: bash -euxo pipefail {0} + run: cargo fmt --all -- --check diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..de5eadb61a4c78cc4c91e8e7c24f6d85b6745043 --- /dev/null +++ b/.github/actions/run_tests/action.yml @@ -0,0 +1,34 @@ +name: "Run tests" +description: "Runs the tests" + +runs: + using: "composite" + steps: + - name: Install Rust + shell: bash -euxo pipefail {0} + run: | + rustup set profile minimal + rustup update stable + rustup target add wasm32-wasi + cargo install cargo-nextest + + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: "18" + + - name: Limit target directory size + shell: bash -euxo pipefail {0} + run: script/clear-target-dir-if-larger-than 70 + + - name: Run check + env: + RUSTFLAGS: -D warnings + shell: bash -euxo pipefail {0} + run: cargo check --tests --workspace + + - name: Run tests + env: + RUSTFLAGS: -D warnings + shell: bash -euxo pipefail {0} + run: cargo nextest run --workspace --no-fail-fast diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60dc4c1f528a34ae2e74fc237650ab7a21516fb8..65475a41b90e473ac31e78517239b4692bcce98e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,19 +23,14 @@ jobs: - self-hosted - test steps: - - name: Install Rust - run: | - rustup set profile minimal - rustup update stable - - name: Checkout repo uses: actions/checkout@v3 with: clean: false submodules: "recursive" - - name: cargo fmt - run: cargo fmt --all -- --check + - name: Run rustfmt + uses: ./.github/actions/check_formatting tests: name: Run tests @@ -43,35 +38,15 @@ jobs: - self-hosted - test needs: rustfmt - env: - RUSTFLAGS: -D warnings steps: - - name: Install Rust - run: | - rustup set profile minimal - rustup update stable - rustup target add wasm32-wasi - cargo install cargo-nextest - - - name: Install Node - uses: actions/setup-node@v3 - with: - node-version: "18" - - name: Checkout repo uses: actions/checkout@v3 with: clean: false submodules: "recursive" - - name: Limit target directory size - run: script/clear-target-dir-if-larger-than 70 - - - name: Run check - run: cargo check --workspace - - name: Run tests - run: cargo nextest run --workspace --no-fail-fast + uses: ./.github/actions/run_tests - name: Build collab run: cargo build -p collab @@ -130,6 +105,8 @@ jobs: expected_tag_name="v${version}";; preview) expected_tag_name="v${version}-pre";; + nightly) + expected_tag_name="v${version}-nightly";; *) echo "can't publish a release on channel ${channel}" exit 1;; @@ -154,7 +131,9 @@ jobs: - uses: softprops/action-gh-release@v1 name: Upload app bundle to release - if: ${{ env.RELEASE_CHANNEL }} + # TODO kb seems that zed.dev relies on GitHub releases for release version tracking. + # Find alternatives for `nightly` or just go on with more releases? + if: ${{ env.RELEASE_CHANNEL == 'preview' || env.RELEASE_CHANNEL == 'stable' }} with: draft: true prerelease: ${{ env.RELEASE_CHANNEL == 'preview' }} diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml new file mode 100644 index 0000000000000000000000000000000000000000..447e928866d0bd877d1369a8da6c130bf2d9e8cd --- /dev/null +++ b/.github/workflows/release_nightly.yml @@ -0,0 +1,98 @@ +name: Release Nightly + +on: + schedule: + # Fire every night at 1:00am + - cron: "0 1 * * *" + push: + tags: + - "nightly" + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + +jobs: + rustfmt: + name: Check formatting + runs-on: + - self-hosted + - test + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" + + - name: Run rustfmt + uses: ./.github/actions/check_formatting + + tests: + name: Run tests + runs-on: + - self-hosted + - test + needs: rustfmt + steps: + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" + + - name: Run tests + uses: ./.github/actions/run_tests + + bundle: + name: Bundle app + runs-on: + - self-hosted + - bundle + needs: tests + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} + APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + DIGITALOCEAN_SPACES_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY }} + DIGITALOCEAN_SPACES_SECRET_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_KEY }} + steps: + - name: Install Rust + run: | + rustup set profile minimal + rustup update stable + rustup target add aarch64-apple-darwin + rustup target add x86_64-apple-darwin + rustup target add wasm32-wasi + + - name: Install Node + uses: actions/setup-node@v3 + with: + node-version: "18" + + - name: Checkout repo + uses: actions/checkout@v3 + with: + clean: false + submodules: "recursive" + + - name: Limit target directory size + run: script/clear-target-dir-if-larger-than 70 + + - name: Set release channel to nightly + run: | + set -eu + version=$(git rev-parse --short HEAD) + echo "Publishing version: ${version} on release channel nightly" + echo "nightly" > crates/zed/RELEASE_CHANNEL + + - name: Generate license file + run: script/generate-licenses + + - name: Create app bundle + run: script/bundle -2 + + - name: Upload Zed Nightly + run: script/upload-nightly diff --git a/Cargo.lock b/Cargo.lock index 1747eae2d25fb958fa096a748793a3b0f24ab02b..6aa94b08d05cd2cd2578f5f50f5c1ebf28ddf2f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,6 +724,30 @@ dependencies = [ "workspace", ] +[[package]] +name = "auto_update2" +version = "0.1.0" +dependencies = [ + "anyhow", + "client2", + "db2", + "gpui2", + "isahc", + "lazy_static", + "log", + "menu2", + "project2", + "serde", + "serde_derive", + "serde_json", + "settings2", + "smol", + "tempdir", + "theme2", + "util", + "workspace2", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -817,17 +841,6 @@ dependencies = [ "rustc-demangle", ] -[[package]] -name = "backtrace-on-stack-overflow" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd2d70527f3737a1ad17355e260706c1badebabd1fa06a7a053407380df841b" -dependencies = [ - "backtrace", - "libc", - "nix 0.23.2", -] - [[package]] name = "base64" version = "0.13.1" @@ -1374,7 +1387,7 @@ dependencies = [ "smol", "sum_tree", "tempfile", - "text", + "text2", "thiserror", "time", "tiny_http", @@ -1526,6 +1539,7 @@ dependencies = [ "anyhow", "async-recursion 0.3.2", "async-tungstenite", + "chrono", "collections", "db", "feature_flags", @@ -1562,6 +1576,7 @@ dependencies = [ "anyhow", "async-recursion 0.3.2", "async-tungstenite", + "chrono", "collections", "db2", "feature_flags2", @@ -1843,7 +1858,7 @@ dependencies = [ "editor2", "feature_flags2", "futures 0.3.28", - "fuzzy", + "fuzzy2", "gpui2", "language2", "lazy_static", @@ -2614,6 +2629,34 @@ dependencies = [ "workspace", ] +[[package]] +name = "diagnostics2" +version = "0.1.0" +dependencies = [ + "anyhow", + "client2", + "collections", + "editor2", + "futures 0.3.28", + "gpui2", + "language2", + "log", + "lsp2", + "postage", + "project2", + "schemars", + "serde", + "serde_derive", + "serde_json", + "settings2", + "smallvec", + "theme2", + "ui2", + "unindent", + "util", + "workspace2", +] + [[package]] name = "diff" version = "0.1.13" @@ -3759,7 +3802,7 @@ dependencies = [ "smol", "sqlez", "sum_tree", - "taffy", + "taffy 0.3.11 (git+https://github.com/DioxusLabs/taffy?rev=4fb530bdd71609bb1d3f76c6a8bde1ba82805d5e)", "thiserror", "time", "tiny-skia", @@ -3824,7 +3867,7 @@ dependencies = [ "smol", "sqlez", "sum_tree", - "taffy", + "taffy 0.3.11 (git+https://github.com/DioxusLabs/taffy?rev=1876f72bee5e376023eaa518aa7b8a34c769bd1b)", "thiserror", "time", "tiny-skia", @@ -3859,6 +3902,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eec1c01eb1de97451ee0d60de7d81cf1e72aabefb021616027f3d1c3ec1c723c" +[[package]] +name = "grid" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df00eed8d1f0db937f6be10e46e8072b0671accb504cf0f959c5c52c679f5b9" + [[package]] name = "h2" version = "0.3.21" @@ -4486,7 +4535,7 @@ dependencies = [ "anyhow", "chrono", "dirs 4.0.0", - "editor", + "editor2", "gpui2", "log", "schemars", @@ -5510,19 +5559,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "nix" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" -dependencies = [ - "bitflags 1.3.2", - "cc", - "cfg-if 1.0.0", - "libc", - "memoffset 0.6.5", -] - [[package]] name = "nix" version = "0.24.3" @@ -6703,7 +6739,6 @@ dependencies = [ "anyhow", "client2", "collections", - "context_menu", "db2", "editor2", "futures 0.3.28", @@ -7972,6 +8007,35 @@ dependencies = [ "workspace", ] +[[package]] +name = "search2" +version = "0.1.0" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "client2", + "collections", + "editor2", + "futures 0.3.28", + "gpui2", + "language2", + "log", + "menu2", + "postage", + "project2", + "serde", + "serde_derive", + "serde_json", + "settings2", + "smallvec", + "smol", + "theme2", + "ui2", + "unindent", + "util", + "workspace2", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -8795,45 +8859,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "storybook2" -version = "0.1.0" -dependencies = [ - "anyhow", - "backtrace-on-stack-overflow", - "chrono", - "clap 4.4.4", - "editor2", - "fuzzy2", - "gpui2", - "itertools 0.11.0", - "language2", - "log", - "menu2", - "picker2", - "rust-embed", - "serde", - "settings2", - "simplelog", - "smallvec", - "strum", - "theme", - "theme2", - "ui2", - "util", -] - -[[package]] -name = "storybook3" -version = "0.1.0" -dependencies = [ - "anyhow", - "gpui2", - "settings2", - "theme2", - "ui2", -] - [[package]] name = "stringprep" version = "0.1.4" @@ -9053,13 +9078,24 @@ dependencies = [ "winx", ] +[[package]] +name = "taffy" +version = "0.3.11" +source = "git+https://github.com/DioxusLabs/taffy?rev=1876f72bee5e376023eaa518aa7b8a34c769bd1b#1876f72bee5e376023eaa518aa7b8a34c769bd1b" +dependencies = [ + "arrayvec 0.7.4", + "grid 0.11.0", + "num-traits", + "slotmap", +] + [[package]] name = "taffy" version = "0.3.11" source = "git+https://github.com/DioxusLabs/taffy?rev=4fb530bdd71609bb1d3f76c6a8bde1ba82805d5e#4fb530bdd71609bb1d3f76c6a8bde1ba82805d5e" dependencies = [ "arrayvec 0.7.4", - "grid", + "grid 0.10.0", "num-traits", "slotmap", ] @@ -11543,6 +11579,7 @@ dependencies = [ "async-recursion 0.3.2", "async-tar", "async-trait", + "auto_update2", "backtrace", "call2", "chrono", @@ -11554,6 +11591,7 @@ dependencies = [ "copilot2", "ctor", "db2", + "diagnostics2", "editor2", "env_logger 0.9.3", "feature_flags2", @@ -11561,7 +11599,6 @@ dependencies = [ "fs2", "fsevent", "futures 0.3.28", - "fuzzy", "go_to_line2", "gpui2", "ignore", @@ -11571,7 +11608,6 @@ dependencies = [ "isahc", "journal2", "language2", - "language_tools", "lazy_static", "libc", "log", @@ -11590,6 +11626,7 @@ dependencies = [ "rsa 0.4.0", "rust-embed", "schemars", + "search2", "serde", "serde_derive", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f107dc5390af2ec57b62e2f3b6cf3ac16b9316c0..d7b9918f624a5f1461aef7768f3c98d9dfbef1af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/audio", "crates/audio2", "crates/auto_update", + "crates/auto_update2", "crates/breadcrumbs", "crates/call", "crates/call2", @@ -32,6 +33,7 @@ members = [ "crates/refineable", "crates/refineable/derive_refineable", "crates/diagnostics", + "crates/diagnostics2", "crates/drag_and_drop", "crates/editor", "crates/feature_flags", @@ -88,14 +90,15 @@ members = [ "crates/rpc", "crates/rpc2", "crates/search", + "crates/search2", "crates/settings", "crates/settings2", "crates/snippet", "crates/sqlez", "crates/sqlez_macros", "crates/rich_text", - "crates/storybook2", - "crates/storybook3", + # "crates/storybook2", + # "crates/storybook3", "crates/sum_tree", "crates/terminal", "crates/terminal2", diff --git a/assets/icons/warning.svg b/assets/icons/warning.svg index e581def0d050727647ccaf06a53406b389d57e71..c48a575a90dc224db13a4b7fda8629fb2cd464f4 100644 --- a/assets/icons/warning.svg +++ b/assets/icons/warning.svg @@ -1,6 +1 @@ - - - - - - + diff --git a/assets/settings/default.json b/assets/settings/default.json index 08d85dd723cc13ca98b0b239a199b263f738d99a..bf2acc708e9fe49653f02ab607059ea1b8a48725 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -268,6 +268,19 @@ // Whether to show warnings or not by default. "include_warnings": true }, + // Add files or globs of files that will be excluded by Zed entirely: + // they will be skipped during FS scan(s), file tree and file search + // will lack the corresponding file entries. + "file_scan_exclusions": [ + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store", + "**/Thumbs.db", + "**/.classpath", + "**/.settings" + ], // Git gutter behavior configuration. "git": { // Control whether the git gutter is shown. May take 2 values: diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 6ab96093a74e3f30ee44b21c396eb76a41a1e179..cac8bf6c54f8b335df756c13b08fb056b6378dd5 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -15,7 +15,7 @@ use ai::{ use ai::prompts::repository_context::PromptCodeSnippet; use anyhow::{anyhow, Result}; use chrono::{DateTime, Local}; -use client::{telemetry::AssistantKind, ClickhouseEvent, TelemetrySettings}; +use client::{telemetry::AssistantKind, TelemetrySettings}; use collections::{hash_map, HashMap, HashSet, VecDeque}; use editor::{ display_map::{ @@ -3803,12 +3803,12 @@ fn report_assistant_event( .default_open_ai_model .clone(); - let event = ClickhouseEvent::Assistant { - conversation_id, - kind: assistant_kind, - model: model.full_name(), - }; let telemetry_settings = *settings::get::(cx); - telemetry.report_clickhouse_event(event, telemetry_settings) + telemetry.report_assistant_event( + telemetry_settings, + conversation_id, + assistant_kind, + model.full_name(), + ) } diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 0d537b882a85fe5e7ce54f1270c8d7b28de1f9c4..cf285ac7cfede2b6adeb264fd6dfe566c2e12e0d 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -118,14 +118,18 @@ fn view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) { let auto_updater = auto_updater.read(cx); let server_url = &auto_updater.server_url; let current_version = auto_updater.current_version; - let latest_release_url = if cx.has_global::() - && *cx.global::() == ReleaseChannel::Preview - { - format!("{server_url}/releases/preview/{current_version}") - } else { - format!("{server_url}/releases/stable/{current_version}") - }; - cx.platform().open_url(&latest_release_url); + if cx.has_global::() { + match cx.global::() { + ReleaseChannel::Dev => {} + ReleaseChannel::Nightly => {} + ReleaseChannel::Preview => cx + .platform() + .open_url(&format!("{server_url}/releases/preview/{current_version}")), + ReleaseChannel::Stable => cx + .platform() + .open_url(&format!("{server_url}/releases/stable/{current_version}")), + } + } } } @@ -224,22 +228,19 @@ impl AutoUpdater { ) }); - let preview_param = cx.read(|cx| { + let mut url_string = format!( + "{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg" + ); + cx.read(|cx| { if cx.has_global::() { - if *cx.global::() == ReleaseChannel::Preview { - return "&preview=1"; + if let Some(param) = cx.global::().release_query_param() { + url_string += "&"; + url_string += param; } } - "" }); - let mut response = client - .get( - &format!("{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg{preview_param}"), - Default::default(), - true, - ) - .await?; + let mut response = client.get(&url_string, Default::default(), true).await?; let mut body = Vec::new(); response diff --git a/crates/auto_update2/Cargo.toml b/crates/auto_update2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..20eb1297462fcdad4c35ab09822d1cad40945e78 --- /dev/null +++ b/crates/auto_update2/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "auto_update2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/auto_update.rs" +doctest = false + +[dependencies] +db = { package = "db2", path = "../db2" } +client = { package = "client2", path = "../client2" } +gpui = { package = "gpui2", path = "../gpui2" } +menu = { package = "menu2", path = "../menu2" } +project = { package = "project2", path = "../project2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +workspace = { package = "workspace2", path = "../workspace2" } +util = { path = "../util" } +anyhow.workspace = true +isahc.workspace = true +lazy_static.workspace = true +log.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +smol.workspace = true +tempdir.workspace = true diff --git a/crates/auto_update2/src/auto_update.rs b/crates/auto_update2/src/auto_update.rs new file mode 100644 index 0000000000000000000000000000000000000000..aeff68965fd07ce7eda4cc0aac9bb8a7aaeb4649 --- /dev/null +++ b/crates/auto_update2/src/auto_update.rs @@ -0,0 +1,406 @@ +mod update_notification; + +use anyhow::{anyhow, Context, Result}; +use client::{Client, TelemetrySettings, ZED_APP_PATH, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN}; +use db::kvp::KEY_VALUE_STORE; +use db::RELEASE_CHANNEL; +use gpui::{ + actions, AppContext, AsyncAppContext, Context as _, Model, ModelContext, SemanticVersion, Task, + ViewContext, VisualContext, +}; +use isahc::AsyncBody; +use serde::Deserialize; +use serde_derive::Serialize; +use smol::io::AsyncReadExt; + +use settings::{Settings, SettingsStore}; +use smol::{fs::File, process::Command}; +use std::{ffi::OsString, sync::Arc, time::Duration}; +use update_notification::UpdateNotification; +use util::channel::{AppCommitSha, ReleaseChannel}; +use util::http::HttpClient; +use workspace::Workspace; + +const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification"; +const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60); + +//todo!(remove CheckThatAutoUpdaterWorks) +actions!( + Check, + DismissErrorMessage, + ViewReleaseNotes, + CheckThatAutoUpdaterWorks +); + +#[derive(Serialize)] +struct UpdateRequestBody { + installation_id: Option>, + release_channel: Option<&'static str>, + telemetry: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AutoUpdateStatus { + Idle, + Checking, + Downloading, + Installing, + Updated, + Errored, +} + +pub struct AutoUpdater { + status: AutoUpdateStatus, + current_version: SemanticVersion, + http_client: Arc, + pending_poll: Option>>, + server_url: String, +} + +#[derive(Deserialize)] +struct JsonRelease { + version: String, + url: String, +} + +struct AutoUpdateSetting(bool); + +impl Settings for AutoUpdateSetting { + const KEY: Option<&'static str> = Some("auto_update"); + + type FileContent = Option; + + fn load( + default_value: &Option, + user_values: &[&Option], + _: &mut AppContext, + ) -> Result { + Ok(Self( + Self::json_merge(default_value, user_values)?.ok_or_else(Self::missing_default)?, + )) + } +} + +pub fn init(http_client: Arc, server_url: String, cx: &mut AppContext) { + AutoUpdateSetting::register(cx); + + cx.observe_new_views(|wokrspace: &mut Workspace, _cx| { + wokrspace + .register_action(|_, action: &Check, cx| check(action, cx)) + .register_action(|_, _action: &CheckThatAutoUpdaterWorks, cx| { + let prompt = cx.prompt(gpui::PromptLevel::Info, "It does!", &["Ok"]); + cx.spawn(|_, _cx| async move { + prompt.await.ok(); + }) + .detach(); + }); + }) + .detach(); + + if let Some(version) = *ZED_APP_VERSION { + let auto_updater = cx.build_model(|cx| { + let updater = AutoUpdater::new(version, http_client, server_url); + + let mut update_subscription = AutoUpdateSetting::get_global(cx) + .0 + .then(|| updater.start_polling(cx)); + + cx.observe_global::(move |updater, cx| { + if AutoUpdateSetting::get_global(cx).0 { + if update_subscription.is_none() { + update_subscription = Some(updater.start_polling(cx)) + } + } else { + update_subscription.take(); + } + }) + .detach(); + + updater + }); + cx.set_global(Some(auto_updater)); + //todo!(action) + // cx.add_global_action(view_release_notes); + // cx.add_action(UpdateNotification::dismiss); + } +} + +pub fn check(_: &Check, cx: &mut AppContext) { + if let Some(updater) = AutoUpdater::get(cx) { + updater.update(cx, |updater, cx| updater.poll(cx)); + } +} + +fn _view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) { + if let Some(auto_updater) = AutoUpdater::get(cx) { + let auto_updater = auto_updater.read(cx); + let server_url = &auto_updater.server_url; + let current_version = auto_updater.current_version; + if cx.has_global::() { + match cx.global::() { + ReleaseChannel::Dev => {} + ReleaseChannel::Nightly => {} + ReleaseChannel::Preview => { + cx.open_url(&format!("{server_url}/releases/preview/{current_version}")) + } + ReleaseChannel::Stable => { + cx.open_url(&format!("{server_url}/releases/stable/{current_version}")) + } + } + } + } +} + +pub fn notify_of_any_new_update(cx: &mut ViewContext) -> Option<()> { + let updater = AutoUpdater::get(cx)?; + let version = updater.read(cx).current_version; + let should_show_notification = updater.read(cx).should_show_update_notification(cx); + + cx.spawn(|workspace, mut cx| async move { + let should_show_notification = should_show_notification.await?; + if should_show_notification { + workspace.update(&mut cx, |workspace, cx| { + workspace.show_notification(0, cx, |cx| { + cx.build_view(|_| UpdateNotification::new(version)) + }); + updater + .read(cx) + .set_should_show_update_notification(false, cx) + .detach_and_log_err(cx); + })?; + } + anyhow::Ok(()) + }) + .detach(); + + None +} + +impl AutoUpdater { + pub fn get(cx: &mut AppContext) -> Option> { + cx.default_global::>>().clone() + } + + fn new( + current_version: SemanticVersion, + http_client: Arc, + server_url: String, + ) -> Self { + Self { + status: AutoUpdateStatus::Idle, + current_version, + http_client, + server_url, + pending_poll: None, + } + } + + pub fn start_polling(&self, cx: &mut ModelContext) -> Task> { + cx.spawn(|this, mut cx| async move { + loop { + this.update(&mut cx, |this, cx| this.poll(cx))?; + cx.background_executor().timer(POLL_INTERVAL).await; + } + }) + } + + pub fn poll(&mut self, cx: &mut ModelContext) { + if self.pending_poll.is_some() || self.status == AutoUpdateStatus::Updated { + return; + } + + self.status = AutoUpdateStatus::Checking; + cx.notify(); + + self.pending_poll = Some(cx.spawn(|this, mut cx| async move { + let result = Self::update(this.upgrade()?, cx.clone()).await; + this.update(&mut cx, |this, cx| { + this.pending_poll = None; + if let Err(error) = result { + log::error!("auto-update failed: error:{:?}", error); + this.status = AutoUpdateStatus::Errored; + cx.notify(); + } + }) + .ok() + })); + } + + pub fn status(&self) -> AutoUpdateStatus { + self.status + } + + pub fn dismiss_error(&mut self, cx: &mut ModelContext) { + self.status = AutoUpdateStatus::Idle; + cx.notify(); + } + + async fn update(this: Model, mut cx: AsyncAppContext) -> Result<()> { + let (client, server_url, current_version) = this.read_with(&cx, |this, _| { + ( + this.http_client.clone(), + this.server_url.clone(), + this.current_version, + ) + })?; + + let mut url_string = format!( + "{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg" + ); + cx.update(|cx| { + if cx.has_global::() { + if let Some(param) = cx.global::().release_query_param() { + url_string += "&"; + url_string += param; + } + } + })?; + + let mut response = client.get(&url_string, Default::default(), true).await?; + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("error reading release")?; + let release: JsonRelease = + serde_json::from_slice(body.as_slice()).context("error deserializing release")?; + + let should_download = match *RELEASE_CHANNEL { + ReleaseChannel::Nightly => cx + .try_read_global::(|sha, _| release.version != sha.0) + .unwrap_or(true), + _ => release.version.parse::()? <= current_version, + }; + + if !should_download { + this.update(&mut cx, |this, cx| { + this.status = AutoUpdateStatus::Idle; + cx.notify(); + })?; + return Ok(()); + } + + this.update(&mut cx, |this, cx| { + this.status = AutoUpdateStatus::Downloading; + cx.notify(); + })?; + + let temp_dir = tempdir::TempDir::new("zed-auto-update")?; + let dmg_path = temp_dir.path().join("Zed.dmg"); + let mount_path = temp_dir.path().join("Zed"); + let running_app_path = ZED_APP_PATH + .clone() + .map_or_else(|| cx.update(|cx| cx.app_path())?, Ok)?; + let running_app_filename = running_app_path + .file_name() + .ok_or_else(|| anyhow!("invalid running app path"))?; + let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into(); + mounted_app_path.push("/"); + + let mut dmg_file = File::create(&dmg_path).await?; + + let (installation_id, release_channel, telemetry) = cx.update(|cx| { + let installation_id = cx.global::>().telemetry().installation_id(); + let release_channel = cx + .has_global::() + .then(|| cx.global::().display_name()); + let telemetry = TelemetrySettings::get_global(cx).metrics; + + (installation_id, release_channel, telemetry) + })?; + + let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody { + installation_id, + release_channel, + telemetry, + })?); + + let mut response = client.get(&release.url, request_body, true).await?; + smol::io::copy(response.body_mut(), &mut dmg_file).await?; + log::info!("downloaded update. path:{:?}", dmg_path); + + this.update(&mut cx, |this, cx| { + this.status = AutoUpdateStatus::Installing; + cx.notify(); + })?; + + let output = Command::new("hdiutil") + .args(&["attach", "-nobrowse"]) + .arg(&dmg_path) + .arg("-mountroot") + .arg(&temp_dir.path()) + .output() + .await?; + if !output.status.success() { + Err(anyhow!( + "failed to mount: {:?}", + String::from_utf8_lossy(&output.stderr) + ))?; + } + + let output = Command::new("rsync") + .args(&["-av", "--delete"]) + .arg(&mounted_app_path) + .arg(&running_app_path) + .output() + .await?; + if !output.status.success() { + Err(anyhow!( + "failed to copy app: {:?}", + String::from_utf8_lossy(&output.stderr) + ))?; + } + + let output = Command::new("hdiutil") + .args(&["detach"]) + .arg(&mount_path) + .output() + .await?; + if !output.status.success() { + Err(anyhow!( + "failed to unmount: {:?}", + String::from_utf8_lossy(&output.stderr) + ))?; + } + + this.update(&mut cx, |this, cx| { + this.set_should_show_update_notification(true, cx) + .detach_and_log_err(cx); + this.status = AutoUpdateStatus::Updated; + cx.notify(); + })?; + Ok(()) + } + + fn set_should_show_update_notification( + &self, + should_show: bool, + cx: &AppContext, + ) -> Task> { + cx.background_executor().spawn(async move { + if should_show { + KEY_VALUE_STORE + .write_kvp( + SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string(), + "".to_string(), + ) + .await?; + } else { + KEY_VALUE_STORE + .delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string()) + .await?; + } + Ok(()) + }) + } + + fn should_show_update_notification(&self, cx: &AppContext) -> Task> { + cx.background_executor().spawn(async move { + Ok(KEY_VALUE_STORE + .read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)? + .is_some()) + }) + } +} diff --git a/crates/auto_update2/src/update_notification.rs b/crates/auto_update2/src/update_notification.rs new file mode 100644 index 0000000000000000000000000000000000000000..e6a22b73248a8fce898c6871abb06d602a3a8e7a --- /dev/null +++ b/crates/auto_update2/src/update_notification.rs @@ -0,0 +1,87 @@ +use gpui::{div, Div, EventEmitter, ParentElement, Render, SemanticVersion, ViewContext}; +use menu::Cancel; +use workspace::notifications::NotificationEvent; + +pub struct UpdateNotification { + _version: SemanticVersion, +} + +impl EventEmitter for UpdateNotification {} + +impl Render for UpdateNotification { + type Element = Div; + + fn render(&mut self, _cx: &mut gpui::ViewContext) -> Self::Element { + div().child("Updated zed!") + // let theme = theme::current(cx).clone(); + // let theme = &theme.update_notification; + + // let app_name = cx.global::().display_name(); + + // MouseEventHandler::new::(0, cx, |state, cx| { + // Flex::column() + // .with_child( + // Flex::row() + // .with_child( + // Text::new( + // format!("Updated to {app_name} {}", self.version), + // theme.message.text.clone(), + // ) + // .contained() + // .with_style(theme.message.container) + // .aligned() + // .top() + // .left() + // .flex(1., true), + // ) + // .with_child( + // MouseEventHandler::new::(0, cx, |state, _| { + // let style = theme.dismiss_button.style_for(state); + // Svg::new("icons/x.svg") + // .with_color(style.color) + // .constrained() + // .with_width(style.icon_width) + // .aligned() + // .contained() + // .with_style(style.container) + // .constrained() + // .with_width(style.button_width) + // .with_height(style.button_width) + // }) + // .with_padding(Padding::uniform(5.)) + // .on_click(MouseButton::Left, move |_, this, cx| { + // this.dismiss(&Default::default(), cx) + // }) + // .aligned() + // .constrained() + // .with_height(cx.font_cache().line_height(theme.message.text.font_size)) + // .aligned() + // .top() + // .flex_float(), + // ), + // ) + // .with_child({ + // let style = theme.action_message.style_for(state); + // Text::new("View the release notes", style.text.clone()) + // .contained() + // .with_style(style.container) + // }) + // .contained() + // }) + // .with_cursor_style(CursorStyle::PointingHand) + // .on_click(MouseButton::Left, |_, _, cx| { + // crate::view_release_notes(&Default::default(), cx) + // }) + // .into_any_named("update notification") + } +} + +impl UpdateNotification { + pub fn new(version: SemanticVersion) -> Self { + Self { _version: version } + } + + pub fn _dismiss(&mut self, _: &Cancel, cx: &mut ViewContext) { + cx.emit(NotificationEvent::Dismiss); + } +} diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index ca1a60bd631c885924da7e6a8ca8b3c5ce2aa114..7959a8c7d18b24dac1259cde21a1abec4105f1ba 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -5,10 +5,7 @@ pub mod room; use anyhow::{anyhow, Result}; use audio::Audio; use call_settings::CallSettings; -use client::{ - proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore, - ZED_ALWAYS_ACTIVE, -}; +use client::{proto, Client, TelemetrySettings, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; use collections::HashSet; use futures::{channel::oneshot, future::Shared, Future, FutureExt}; use gpui::{ @@ -485,12 +482,8 @@ pub fn report_call_event_for_room( ) { let telemetry = client.telemetry(); let telemetry_settings = *settings::get::(cx); - let event = ClickhouseEvent::Call { - operation, - room_id: Some(room_id), - channel_id, - }; - telemetry.report_clickhouse_event(event, telemetry_settings); + + telemetry.report_call_event(telemetry_settings, operation, Some(room_id), channel_id) } pub fn report_call_event_for_channel( @@ -504,12 +497,12 @@ pub fn report_call_event_for_channel( let telemetry = client.telemetry(); let telemetry_settings = *settings::get::(cx); - let event = ClickhouseEvent::Call { + telemetry.report_call_event( + telemetry_settings, operation, - room_id: room.map(|r| r.read(cx).id()), - channel_id: Some(channel_id), - }; - telemetry.report_clickhouse_event(event, telemetry_settings); + room.map(|r| r.read(cx).id()), + Some(channel_id), + ) } #[cfg(test)] diff --git a/crates/call2/src/call2.rs b/crates/call2/src/call2.rs index 2fab3d40ce600c0b9c26cbc31b95398cc1ab9a96..1f11e0650ddf3808adce0997afbce58cdc389819 100644 --- a/crates/call2/src/call2.rs +++ b/crates/call2/src/call2.rs @@ -5,10 +5,7 @@ pub mod room; use anyhow::{anyhow, Result}; use audio::Audio; use call_settings::CallSettings; -use client::{ - proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore, - ZED_ALWAYS_ACTIVE, -}; +use client::{proto, Client, TelemetrySettings, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; use collections::HashSet; use futures::{channel::oneshot, future::Shared, Future, FutureExt}; use gpui::{ @@ -484,12 +481,8 @@ pub fn report_call_event_for_room( ) { let telemetry = client.telemetry(); let telemetry_settings = *TelemetrySettings::get_global(cx); - let event = ClickhouseEvent::Call { - operation, - room_id: Some(room_id), - channel_id, - }; - telemetry.report_clickhouse_event(event, telemetry_settings); + + telemetry.report_call_event(telemetry_settings, operation, Some(room_id), channel_id) } pub fn report_call_event_for_channel( @@ -504,12 +497,12 @@ pub fn report_call_event_for_channel( let telemetry_settings = *TelemetrySettings::get_global(cx); - let event = ClickhouseEvent::Call { + telemetry.report_call_event( + telemetry_settings, operation, - room_id: room.map(|r| r.read(cx).id()), - channel_id: Some(channel_id), - }; - telemetry.report_clickhouse_event(event, telemetry_settings); + room.map(|r| r.read(cx).id()), + Some(channel_id), + ) } #[cfg(test)] diff --git a/crates/channel2/Cargo.toml b/crates/channel2/Cargo.toml index c292d4e8ddbe50d0fe58c882d690181d58345b75..7af5aa1224d043895c3119e896dd12441f960d64 100644 --- a/crates/channel2/Cargo.toml +++ b/crates/channel2/Cargo.toml @@ -18,7 +18,7 @@ db = { package = "db2", path = "../db2" } gpui = { package = "gpui2", path = "../gpui2" } util = { path = "../util" } rpc = { package = "rpc2", path = "../rpc2" } -text = { path = "../text" } +text = { package = "text2", path = "../text2" } language = { package = "language2", path = "../language2" } settings = { package = "settings2", path = "../settings2" } feature_flags = { package = "feature_flags2", path = "../feature_flags2" } diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index c8085f807bd5786daa6e8579bdf263c978dd4e74..c24cbca35be25aeca198cd9178467bfb93db2969 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -12,6 +12,7 @@ doctest = false test-support = ["collections/test-support", "gpui/test-support", "rpc/test-support"] [dependencies] +chrono = { version = "0.4", features = ["serde"] } collections = { path = "../collections" } db = { path = "../db" } gpui = { path = "../gpui" } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 9f63d0e2bed327fd306692a0c28952ea18c854fd..a14088cc50066283771b50bd3a33d92f750ab8fc 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -987,9 +987,17 @@ impl Client { self.establish_websocket_connection(credentials, cx) } - async fn get_rpc_url(http: Arc, is_preview: bool) -> Result { - let preview_param = if is_preview { "?preview=1" } else { "" }; - let url = format!("{}/rpc{preview_param}", *ZED_SERVER_URL); + async fn get_rpc_url( + http: Arc, + release_channel: Option, + ) -> Result { + let mut url = format!("{}/rpc", *ZED_SERVER_URL); + if let Some(preview_param) = + release_channel.and_then(|channel| channel.release_query_param()) + { + url += "?"; + url += preview_param; + } let response = http.get(&url, Default::default(), false).await?; // Normally, ZED_SERVER_URL is set to the URL of zed.dev website. @@ -1024,11 +1032,11 @@ impl Client { credentials: &Credentials, cx: &AsyncAppContext, ) -> Task> { - let use_preview_server = cx.read(|cx| { + let release_channel = cx.read(|cx| { if cx.has_global::() { - *cx.global::() != ReleaseChannel::Stable + Some(*cx.global::()) } else { - false + None } }); @@ -1041,7 +1049,7 @@ impl Client { let http = self.http.clone(); cx.background().spawn(async move { - let mut rpc_url = Self::get_rpc_url(http, use_preview_server).await?; + let mut rpc_url = Self::get_rpc_url(http, release_channel).await?; let rpc_host = rpc_url .host_str() .zip(rpc_url.port_or_known_default()) @@ -1191,7 +1199,7 @@ impl Client { // Use the collab server's admin API to retrieve the id // of the impersonated user. - let mut url = Self::get_rpc_url(http.clone(), false).await?; + let mut url = Self::get_rpc_url(http.clone(), None).await?; url.set_path("/user"); url.set_query(Some(&format!("github_login={login}"))); let request = Request::get(url.as_str()) diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index fd93aaeec890437968d50854ca83534c62ed4d36..8f7fbeb83d3e974aadcbc64dbee0f3d5482242cd 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -1,4 +1,5 @@ use crate::{TelemetrySettings, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL}; +use chrono::{DateTime, Utc}; use gpui::{executor::Background, serde_json, AppContext, Task}; use lazy_static::lazy_static; use parking_lot::Mutex; @@ -20,7 +21,7 @@ pub struct Telemetry { #[derive(Default)] struct TelemetryState { metrics_id: Option>, // Per logged-in user - installation_id: Option>, // Per app installation (different for dev, preview, and stable) + installation_id: Option>, // Per app installation (different for dev, nightly, preview, and stable) session_id: Option>, // Per app launch app_version: Option>, release_channel: Option<&'static str>, @@ -31,6 +32,7 @@ struct TelemetryState { flush_clickhouse_events_task: Option>, log_file: Option, is_staff: Option, + first_event_datetime: Option>, } const CLICKHOUSE_EVENTS_URL_PATH: &'static str = "/api/events"; @@ -77,29 +79,35 @@ pub enum ClickhouseEvent { vim_mode: bool, copilot_enabled: bool, copilot_enabled_for_language: bool, + milliseconds_since_first_event: i64, }, Copilot { suggestion_id: Option, suggestion_accepted: bool, file_extension: Option, + milliseconds_since_first_event: i64, }, Call { operation: &'static str, room_id: Option, channel_id: Option, + milliseconds_since_first_event: i64, }, Assistant { conversation_id: Option, kind: AssistantKind, model: &'static str, + milliseconds_since_first_event: i64, }, Cpu { usage_as_percentage: f32, core_count: u32, + milliseconds_since_first_event: i64, }, Memory { memory_in_bytes: u64, virtual_memory_in_bytes: u64, + milliseconds_since_first_event: i64, }, } @@ -140,6 +148,7 @@ impl Telemetry { flush_clickhouse_events_task: Default::default(), log_file: None, is_staff: None, + first_event_datetime: None, }), }); @@ -195,20 +204,18 @@ impl Telemetry { return; }; - let memory_event = ClickhouseEvent::Memory { - memory_in_bytes: process.memory(), - virtual_memory_in_bytes: process.virtual_memory(), - }; - - let cpu_event = ClickhouseEvent::Cpu { - usage_as_percentage: process.cpu_usage(), - core_count: system.cpus().len() as u32, - }; - let telemetry_settings = cx.update(|cx| *settings::get::(cx)); - this.report_clickhouse_event(memory_event, telemetry_settings); - this.report_clickhouse_event(cpu_event, telemetry_settings); + this.report_memory_event( + telemetry_settings, + process.memory(), + process.virtual_memory(), + ); + this.report_cpu_event( + telemetry_settings, + process.cpu_usage(), + system.cpus().len() as u32, + ); } }) .detach(); @@ -231,7 +238,123 @@ impl Telemetry { drop(state); } - pub fn report_clickhouse_event( + pub fn report_editor_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + file_extension: Option, + vim_mode: bool, + operation: &'static str, + copilot_enabled: bool, + copilot_enabled_for_language: bool, + ) { + let event = ClickhouseEvent::Editor { + file_extension, + vim_mode, + operation, + copilot_enabled, + copilot_enabled_for_language, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_copilot_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + suggestion_id: Option, + suggestion_accepted: bool, + file_extension: Option, + ) { + let event = ClickhouseEvent::Copilot { + suggestion_id, + suggestion_accepted, + file_extension, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_assistant_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + conversation_id: Option, + kind: AssistantKind, + model: &'static str, + ) { + let event = ClickhouseEvent::Assistant { + conversation_id, + kind, + model, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_call_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + operation: &'static str, + room_id: Option, + channel_id: Option, + ) { + let event = ClickhouseEvent::Call { + operation, + room_id, + channel_id, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_cpu_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + usage_as_percentage: f32, + core_count: u32, + ) { + let event = ClickhouseEvent::Cpu { + usage_as_percentage, + core_count, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_memory_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + memory_in_bytes: u64, + virtual_memory_in_bytes: u64, + ) { + let event = ClickhouseEvent::Memory { + memory_in_bytes, + virtual_memory_in_bytes, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + fn milliseconds_since_first_event(&self) -> i64 { + let mut state = self.state.lock(); + match state.first_event_datetime { + Some(first_event_datetime) => { + let now: DateTime = Utc::now(); + now.timestamp_millis() - first_event_datetime.timestamp_millis() + } + None => { + state.first_event_datetime = Some(Utc::now()); + 0 + } + } + } + + fn report_clickhouse_event( self: &Arc, event: ClickhouseEvent, telemetry_settings: TelemetrySettings, @@ -275,6 +398,7 @@ impl Telemetry { fn flush_clickhouse_events(self: &Arc) { let mut state = self.state.lock(); + state.first_event_datetime = None; let mut events = mem::take(&mut state.clickhouse_events_queue); state.flush_clickhouse_events_task.take(); drop(state); diff --git a/crates/client2/Cargo.toml b/crates/client2/Cargo.toml index ace229bc210a41243d3c59f6c8add3d4a63ce729..b1c993e3a49c41ccfb0da866508c188241e45f3b 100644 --- a/crates/client2/Cargo.toml +++ b/crates/client2/Cargo.toml @@ -12,6 +12,7 @@ doctest = false test-support = ["collections/test-support", "gpui/test-support", "rpc/test-support"] [dependencies] +chrono = { version = "0.4", features = ["serde"] } collections = { path = "../collections" } db = { package = "db2", path = "../db2" } gpui = { package = "gpui2", path = "../gpui2" } diff --git a/crates/client2/src/client2.rs b/crates/client2/src/client2.rs index 93ec7f329bfab51f11d3689904cbc4d5edb62071..b4279b023ecd7412d8ea0c4a69ddc0215be97fb2 100644 --- a/crates/client2/src/client2.rs +++ b/crates/client2/src/client2.rs @@ -923,9 +923,17 @@ impl Client { self.establish_websocket_connection(credentials, cx) } - async fn get_rpc_url(http: Arc, is_preview: bool) -> Result { - let preview_param = if is_preview { "?preview=1" } else { "" }; - let url = format!("{}/rpc{preview_param}", *ZED_SERVER_URL); + async fn get_rpc_url( + http: Arc, + release_channel: Option, + ) -> Result { + let mut url = format!("{}/rpc", *ZED_SERVER_URL); + if let Some(preview_param) = + release_channel.and_then(|channel| channel.release_query_param()) + { + url += "?"; + url += preview_param; + } let response = http.get(&url, Default::default(), false).await?; // Normally, ZED_SERVER_URL is set to the URL of zed.dev website. @@ -960,9 +968,7 @@ impl Client { credentials: &Credentials, cx: &AsyncAppContext, ) -> Task> { - let use_preview_server = cx - .try_read_global(|channel: &ReleaseChannel, _| *channel != ReleaseChannel::Stable) - .unwrap_or(false); + let release_channel = cx.try_read_global(|channel: &ReleaseChannel, _| *channel); let request = Request::builder() .header( @@ -973,7 +979,7 @@ impl Client { let http = self.http.clone(); cx.background_executor().spawn(async move { - let mut rpc_url = Self::get_rpc_url(http, use_preview_server).await?; + let mut rpc_url = Self::get_rpc_url(http, release_channel).await?; let rpc_host = rpc_url .host_str() .zip(rpc_url.port_or_known_default()) @@ -1120,7 +1126,7 @@ impl Client { // Use the collab server's admin API to retrieve the id // of the impersonated user. - let mut url = Self::get_rpc_url(http.clone(), false).await?; + let mut url = Self::get_rpc_url(http.clone(), None).await?; url.set_path("/user"); url.set_query(Some(&format!("github_login={login}"))); let request = Request::get(url.as_str()) diff --git a/crates/client2/src/telemetry.rs b/crates/client2/src/telemetry.rs index 3723f7b906a03f881040c4a351b3ef55b23749dd..9c88d1102c255d6892212e6e28d5346e5b55b3fd 100644 --- a/crates/client2/src/telemetry.rs +++ b/crates/client2/src/telemetry.rs @@ -1,4 +1,5 @@ use crate::{TelemetrySettings, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL}; +use chrono::{DateTime, Utc}; use gpui::{serde_json, AppContext, AppMetadata, BackgroundExecutor, Task}; use lazy_static::lazy_static; use parking_lot::Mutex; @@ -20,7 +21,7 @@ pub struct Telemetry { struct TelemetryState { metrics_id: Option>, // Per logged-in user - installation_id: Option>, // Per app installation (different for dev, preview, and stable) + installation_id: Option>, // Per app installation (different for dev, nightly, preview, and stable) session_id: Option>, // Per app launch release_channel: Option<&'static str>, app_metadata: AppMetadata, @@ -29,6 +30,7 @@ struct TelemetryState { flush_clickhouse_events_task: Option>, log_file: Option, is_staff: Option, + first_event_datetime: Option>, } const CLICKHOUSE_EVENTS_URL_PATH: &'static str = "/api/events"; @@ -75,29 +77,35 @@ pub enum ClickhouseEvent { vim_mode: bool, copilot_enabled: bool, copilot_enabled_for_language: bool, + milliseconds_since_first_event: i64, }, Copilot { suggestion_id: Option, suggestion_accepted: bool, file_extension: Option, + milliseconds_since_first_event: i64, }, Call { operation: &'static str, room_id: Option, channel_id: Option, + milliseconds_since_first_event: i64, }, Assistant { conversation_id: Option, kind: AssistantKind, model: &'static str, + milliseconds_since_first_event: i64, }, Cpu { usage_as_percentage: f32, core_count: u32, + milliseconds_since_first_event: i64, }, Memory { memory_in_bytes: u64, virtual_memory_in_bytes: u64, + milliseconds_since_first_event: i64, }, } @@ -135,6 +143,7 @@ impl Telemetry { flush_clickhouse_events_task: Default::default(), log_file: None, is_staff: None, + first_event_datetime: None, }), }); @@ -190,16 +199,6 @@ impl Telemetry { return; }; - let memory_event = ClickhouseEvent::Memory { - memory_in_bytes: process.memory(), - virtual_memory_in_bytes: process.virtual_memory(), - }; - - let cpu_event = ClickhouseEvent::Cpu { - usage_as_percentage: process.cpu_usage(), - core_count: system.cpus().len() as u32, - }; - let telemetry_settings = if let Ok(telemetry_settings) = cx.update(|cx| *TelemetrySettings::get_global(cx)) { @@ -208,8 +207,16 @@ impl Telemetry { break; }; - this.report_clickhouse_event(memory_event, telemetry_settings); - this.report_clickhouse_event(cpu_event, telemetry_settings); + this.report_memory_event( + telemetry_settings, + process.memory(), + process.virtual_memory(), + ); + this.report_cpu_event( + telemetry_settings, + process.cpu_usage(), + system.cpus().len() as u32, + ); } }) .detach(); @@ -232,7 +239,123 @@ impl Telemetry { drop(state); } - pub fn report_clickhouse_event( + pub fn report_editor_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + file_extension: Option, + vim_mode: bool, + operation: &'static str, + copilot_enabled: bool, + copilot_enabled_for_language: bool, + ) { + let event = ClickhouseEvent::Editor { + file_extension, + vim_mode, + operation, + copilot_enabled, + copilot_enabled_for_language, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_copilot_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + suggestion_id: Option, + suggestion_accepted: bool, + file_extension: Option, + ) { + let event = ClickhouseEvent::Copilot { + suggestion_id, + suggestion_accepted, + file_extension, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_assistant_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + conversation_id: Option, + kind: AssistantKind, + model: &'static str, + ) { + let event = ClickhouseEvent::Assistant { + conversation_id, + kind, + model, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_call_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + operation: &'static str, + room_id: Option, + channel_id: Option, + ) { + let event = ClickhouseEvent::Call { + operation, + room_id, + channel_id, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_cpu_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + usage_as_percentage: f32, + core_count: u32, + ) { + let event = ClickhouseEvent::Cpu { + usage_as_percentage, + core_count, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + pub fn report_memory_event( + self: &Arc, + telemetry_settings: TelemetrySettings, + memory_in_bytes: u64, + virtual_memory_in_bytes: u64, + ) { + let event = ClickhouseEvent::Memory { + memory_in_bytes, + virtual_memory_in_bytes, + milliseconds_since_first_event: self.milliseconds_since_first_event(), + }; + + self.report_clickhouse_event(event, telemetry_settings) + } + + fn milliseconds_since_first_event(&self) -> i64 { + let mut state = self.state.lock(); + match state.first_event_datetime { + Some(first_event_datetime) => { + let now: DateTime = Utc::now(); + now.timestamp_millis() - first_event_datetime.timestamp_millis() + } + None => { + state.first_event_datetime = Some(Utc::now()); + 0 + } + } + } + + fn report_clickhouse_event( self: &Arc, event: ClickhouseEvent, telemetry_settings: TelemetrySettings, @@ -276,6 +399,7 @@ impl Telemetry { fn flush_clickhouse_events(self: &Arc) { let mut state = self.state.lock(); + state.first_event_datetime = None; let mut events = mem::take(&mut state.clickhouse_events_queue); state.flush_clickhouse_events_task.take(); drop(state); diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 550c3a2bd8ade7b0bb8e8df3481ae54549079af8..fa7c4fe67df4fed4645e8c6552e242b3d7662276 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -5052,7 +5052,7 @@ async fn test_project_search( let mut results = HashMap::default(); let mut search_rx = project_b.update(cx_b, |project, cx| { project.search( - SearchQuery::text("world", false, false, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text("world", false, false, false, Vec::new(), Vec::new()).unwrap(), cx, ) }); diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 6f9513c3253ebece9aaa553d8839b80ded113fff..42a2b7927581f26a6d341ed9ed1d0683b43c89f6 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -869,7 +869,8 @@ impl RandomizedTest for ProjectCollaborationTest { let mut search = project.update(cx, |project, cx| { project.search( - SearchQuery::text(query, false, false, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text(query, false, false, false, Vec::new(), Vec::new()) + .unwrap(), cx, ) }); diff --git a/crates/collab2/src/tests/integration_tests.rs b/crates/collab2/src/tests/integration_tests.rs index 121a98c1d2ce766bb2a5a3d7dcce5d31a007ebf8..f2a39f35113df98df00f42eba2ff5fce59059358 100644 --- a/crates/collab2/src/tests/integration_tests.rs +++ b/crates/collab2/src/tests/integration_tests.rs @@ -4599,7 +4599,7 @@ async fn test_project_search( let mut results = HashMap::default(); let mut search_rx = project_b.update(cx_b, |project, cx| { project.search( - SearchQuery::text("world", false, false, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text("world", false, false, false, Vec::new(), Vec::new()).unwrap(), cx, ) }); diff --git a/crates/collab2/src/tests/random_project_collaboration_tests.rs b/crates/collab2/src/tests/random_project_collaboration_tests.rs index 361ca00c33d65841078447b421a39f9f288e73de..47b936a6117df1873702cb1937614548aa03d796 100644 --- a/crates/collab2/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab2/src/tests/random_project_collaboration_tests.rs @@ -870,7 +870,8 @@ impl RandomizedTest for ProjectCollaborationTest { let mut search = project.update(cx, |project, cx| { project.search( - SearchQuery::text(query, false, false, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text(query, false, false, false, Vec::new(), Vec::new()) + .unwrap(), cx, ) }); diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 6dbe3aa204e9edf19d605ef880e52dacf4fe627d..9e6bfb553ebf1afd9c070162b25f589aa012762e 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -14,14 +14,8 @@ use std::{sync::Arc, time::Duration}; const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50); lazy_static! { - static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex( - "@[-_\\w]+", - false, - false, - Default::default(), - Default::default() - ) - .unwrap(); + static ref MENTIONS_SEARCH: SearchQuery = + SearchQuery::regex("@[-_\\w]+", false, false, false, Vec::new(), Vec::new()).unwrap(); } pub struct MessageEditor { diff --git a/crates/collab_ui2/Cargo.toml b/crates/collab_ui2/Cargo.toml index 4660880ecdc39378aada464a145412dbbdd7b943..c7c00d7696c567d27b231733c50bd33c0c1e314b 100644 --- a/crates/collab_ui2/Cargo.toml +++ b/crates/collab_ui2/Cargo.toml @@ -33,7 +33,7 @@ collections = { path = "../collections" } # drag_and_drop = { path = "../drag_and_drop" } editor = { package="editor2", path = "../editor2" } #feedback = { path = "../feedback" } -fuzzy = { path = "../fuzzy" } +fuzzy = { package = "fuzzy2", path = "../fuzzy2" } gpui = { package = "gpui2", path = "../gpui2" } language = { package = "language2", path = "../language2" } menu = { package = "menu2", path = "../menu2" } diff --git a/crates/collab_ui2/src/chat_panel/message_editor.rs b/crates/collab_ui2/src/chat_panel/message_editor.rs index 6dbe3aa204e9edf19d605ef880e52dacf4fe627d..9e6bfb553ebf1afd9c070162b25f589aa012762e 100644 --- a/crates/collab_ui2/src/chat_panel/message_editor.rs +++ b/crates/collab_ui2/src/chat_panel/message_editor.rs @@ -14,14 +14,8 @@ use std::{sync::Arc, time::Duration}; const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50); lazy_static! { - static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex( - "@[-_\\w]+", - false, - false, - Default::default(), - Default::default() - ) - .unwrap(); + static ref MENTIONS_SEARCH: SearchQuery = + SearchQuery::regex("@[-_\\w]+", false, false, false, Vec::new(), Vec::new()).unwrap(); } pub struct MessageEditor { diff --git a/crates/collab_ui2/src/collab_panel.rs b/crates/collab_ui2/src/collab_panel.rs index 20e77d7023db0caeb99893c6fff35f8695f9f3dd..6af188dfd200c82d21771a603b905a5e2377f182 100644 --- a/crates/collab_ui2/src/collab_panel.rs +++ b/crates/collab_ui2/src/collab_panel.rs @@ -160,7 +160,7 @@ use std::sync::Arc; use db::kvp::KEY_VALUE_STORE; use gpui::{ actions, div, serde_json, AppContext, AsyncWindowContext, Div, EventEmitter, FocusHandle, - Focusable, FocusableView, InteractiveComponent, ParentComponent, Render, View, ViewContext, + Focusable, FocusableView, InteractiveElement, ParentElement, Render, View, ViewContext, VisualContext, WeakView, }; use project::Fs; @@ -3295,7 +3295,7 @@ impl CollabPanel { // } impl Render for CollabPanel { - type Element = Focusable>; + type Element = Focusable
; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { div() diff --git a/crates/collab_ui2/src/collab_titlebar_item.rs b/crates/collab_ui2/src/collab_titlebar_item.rs index ed010cc500d6f05444c56fd9c1d9d83ee9f1fce4..94db2f4d9f60659cc2e520a2ed3533caa508f9af 100644 --- a/crates/collab_ui2/src/collab_titlebar_item.rs +++ b/crates/collab_ui2/src/collab_titlebar_item.rs @@ -31,13 +31,13 @@ use std::sync::Arc; use call::ActiveCall; use client::{Client, UserStore}; use gpui::{ - div, px, rems, AppContext, Component, Div, InteractiveComponent, Model, ParentComponent, - Render, Stateful, StatefulInteractiveComponent, Styled, Subscription, ViewContext, - VisualContext, WeakView, WindowBounds, + div, px, rems, AppContext, Div, InteractiveElement, Model, ParentElement, Render, RenderOnce, + Stateful, StatefulInteractiveElement, Styled, Subscription, ViewContext, VisualContext, + WeakView, WindowBounds, }; use project::Project; use theme::ActiveTheme; -use ui::{h_stack, Button, ButtonVariant, KeyBinding, Label, TextColor, Tooltip}; +use ui::{h_stack, Button, ButtonVariant, Color, KeyBinding, Label, Tooltip}; use workspace::Workspace; // const MAX_PROJECT_NAME_LENGTH: usize = 40; @@ -82,7 +82,7 @@ pub struct CollabTitlebarItem { } impl Render for CollabTitlebarItem { - type Element = Stateful>; + type Element = Stateful
; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { h_stack() @@ -100,7 +100,7 @@ impl Render for CollabTitlebarItem { |s| s.pl(px(68.)), ) .bg(cx.theme().colors().title_bar_background) - .on_click(|_, event, cx| { + .on_click(|event, cx| { if event.up.click_count == 2 { cx.zoom_window(); } @@ -115,16 +115,16 @@ impl Render for CollabTitlebarItem { .child( Button::new("player") .variant(ButtonVariant::Ghost) - .color(Some(TextColor::Player(0))), + .color(Some(Color::Player(0))), ) - .tooltip(move |_, cx| Tooltip::text("Toggle following", cx)), + .tooltip(move |cx| Tooltip::text("Toggle following", cx)), ) // TODO - Add project menu .child( div() .id("titlebar_project_menu_button") .child(Button::new("project_name").variant(ButtonVariant::Ghost)) - .tooltip(move |_, cx| Tooltip::text("Recent Projects", cx)), + .tooltip(move |cx| Tooltip::text("Recent Projects", cx)), ) // TODO - Add git menu .child( @@ -133,9 +133,9 @@ impl Render for CollabTitlebarItem { .child( Button::new("branch_name") .variant(ButtonVariant::Ghost) - .color(Some(TextColor::Muted)), + .color(Some(Color::Muted)), ) - .tooltip(move |_, cx| { + .tooltip(move |cx| { cx.build_view(|_| { Tooltip::new("Recent Branches") .key_binding(KeyBinding::new(gpui::KeyBinding::new( diff --git a/crates/command_palette2/src/command_palette.rs b/crates/command_palette2/src/command_palette.rs index 9463cab68ca1b76984f372573e1271cb6fac76fc..3c6f2fff92150fd302576635e7589eb238e5d01b 100644 --- a/crates/command_palette2/src/command_palette.rs +++ b/crates/command_palette2/src/command_palette.rs @@ -1,8 +1,8 @@ use collections::{CommandPaletteFilter, HashMap}; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - actions, div, prelude::*, Action, AppContext, Component, Dismiss, Div, FocusHandle, Keystroke, - ManagedView, ParentComponent, Render, Styled, View, ViewContext, VisualContext, WeakView, + actions, div, prelude::*, Action, AppContext, Div, EventEmitter, FocusHandle, FocusableView, + Keystroke, Manager, ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView, }; use picker::{Picker, PickerDelegate}; use std::{ @@ -68,14 +68,16 @@ impl CommandPalette { } } -impl ManagedView for CommandPalette { +impl EventEmitter for CommandPalette {} + +impl FocusableView for CommandPalette { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { self.picker.focus_handle(cx) } } impl Render for CommandPalette { - type Element = Div; + type Element = Div; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { v_stack().w_96().child(self.picker.clone()) @@ -114,6 +116,7 @@ impl Clone for Command { } } } + /// Hit count for each command in the palette. /// We only account for commands triggered directly via command palette and not by e.g. keystrokes because /// if an user already knows a keystroke for a command, they are unlikely to use a command palette to look for it. @@ -137,7 +140,7 @@ impl CommandPaletteDelegate { } impl PickerDelegate for CommandPaletteDelegate { - type ListItem = Div>; + type ListItem = Div; fn placeholder_text(&self) -> Arc { "Execute a command...".into() @@ -265,7 +268,7 @@ impl PickerDelegate for CommandPaletteDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.command_palette - .update(cx, |_, cx| cx.emit(Dismiss)) + .update(cx, |_, cx| cx.emit(Manager::Dismiss)) .log_err(); } diff --git a/crates/diagnostics2/Cargo.toml b/crates/diagnostics2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..45d40489424643b5c717e65609a8f272160baea1 --- /dev/null +++ b/crates/diagnostics2/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "diagnostics2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/diagnostics.rs" +doctest = false + +[dependencies] +collections = { path = "../collections" } +editor = { package = "editor2", path = "../editor2" } +gpui = { package = "gpui2", path = "../gpui2" } +ui = { package = "ui2", path = "../ui2" } +language = { package = "language2", path = "../language2" } +lsp = { package = "lsp2", path = "../lsp2" } +project = { package = "project2", path = "../project2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +util = { path = "../util" } +workspace = { package = "workspace2", path = "../workspace2" } + +log.workspace = true +anyhow.workspace = true +futures.workspace = true +schemars.workspace = true +serde.workspace = true +serde_derive.workspace = true +smallvec.workspace = true +postage.workspace = true + +[dev-dependencies] +client = { package = "client2", path = "../client2", features = ["test-support"] } +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +language = { package = "language2", path = "../language2", features = ["test-support"] } +lsp = { package = "lsp2", path = "../lsp2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } +theme = { package = "theme2", path = "../theme2", features = ["test-support"] } + +serde_json.workspace = true +unindent.workspace = true diff --git a/crates/diagnostics2/src/diagnostics.rs b/crates/diagnostics2/src/diagnostics.rs new file mode 100644 index 0000000000000000000000000000000000000000..7ff8cd84dbf072106d04337904350522b660a9e2 --- /dev/null +++ b/crates/diagnostics2/src/diagnostics.rs @@ -0,0 +1,1573 @@ +pub mod items; +mod project_diagnostics_settings; +mod toolbar_controls; + +use anyhow::{Context as _, Result}; +use collections::{HashMap, HashSet}; +use editor::{ + diagnostic_block_renderer, + display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock}, + highlight_diagnostic_message, + scroll::autoscroll::Autoscroll, + Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset, +}; +use futures::future::try_join_all; +use gpui::{ + actions, div, AnyElement, AnyView, AppContext, Context, Div, EventEmitter, FocusEvent, + FocusHandle, Focusable, FocusableElement, FocusableView, InteractiveElement, Model, + ParentElement, Render, RenderOnce, SharedString, Styled, Subscription, Task, View, ViewContext, + VisualContext, WeakView, WindowContext, +}; +use language::{ + Anchor, Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Point, Selection, + SelectionGoal, +}; +use lsp::LanguageServerId; +use project::{DiagnosticSummary, Project, ProjectPath}; +use project_diagnostics_settings::ProjectDiagnosticsSettings; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + cmp::Ordering, + mem, + ops::Range, + path::PathBuf, + sync::Arc, +}; +use theme::ActiveTheme; +pub use toolbar_controls::ToolbarControls; +use ui::{h_stack, Color, HighlightedLabel, Icon, IconElement, Label}; +use util::TryFutureExt; +use workspace::{ + item::{BreadcrumbText, Item, ItemEvent, ItemHandle}, + ItemNavHistory, Pane, ToolbarItemLocation, Workspace, +}; + +actions!(Deploy, ToggleWarnings); + +const CONTEXT_LINE_COUNT: u32 = 1; + +pub fn init(cx: &mut AppContext) { + ProjectDiagnosticsSettings::register(cx); + cx.observe_new_views(ProjectDiagnosticsEditor::register) + .detach(); +} + +struct ProjectDiagnosticsEditor { + project: Model, + workspace: WeakView, + focus_handle: FocusHandle, + editor: View, + summary: DiagnosticSummary, + excerpts: Model, + path_states: Vec, + paths_to_update: HashMap>, + current_diagnostics: HashMap>, + include_warnings: bool, + _subscriptions: Vec, +} + +struct PathState { + path: ProjectPath, + diagnostic_groups: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +struct Jump { + path: ProjectPath, + position: Point, + anchor: Anchor, +} + +struct DiagnosticGroupState { + language_server_id: LanguageServerId, + primary_diagnostic: DiagnosticEntry, + primary_excerpt_ix: usize, + excerpts: Vec, + blocks: HashSet, + block_count: usize, +} + +impl EventEmitter for ProjectDiagnosticsEditor {} + +impl Render for ProjectDiagnosticsEditor { + type Element = Focusable
; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let child = if self.path_states.is_empty() { + div() + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .child(Label::new("No problems in workspace")) + } else { + div().size_full().child(self.editor.clone()) + }; + + div() + .track_focus(&self.focus_handle) + .size_full() + .on_focus_in(cx.listener(Self::focus_in)) + .on_action(cx.listener(Self::toggle_warnings)) + .child(child) + } +} + +impl ProjectDiagnosticsEditor { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(Self::deploy); + } + + fn new( + project_handle: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + let project_event_subscription = + cx.subscribe(&project_handle, |this, _, event, cx| match event { + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + log::debug!("Disk based diagnostics finished for server {language_server_id}"); + this.update_excerpts(Some(*language_server_id), cx); + } + project::Event::DiagnosticsUpdated { + language_server_id, + path, + } => { + log::debug!("Adding path {path:?} to update for server {language_server_id}"); + this.paths_to_update + .entry(*language_server_id) + .or_default() + .insert(path.clone()); + if this.editor.read(cx).selections.all::(cx).is_empty() + && !this.is_dirty(cx) + { + this.update_excerpts(Some(*language_server_id), cx); + } + } + _ => {} + }); + + let excerpts = cx.build_model(|cx| MultiBuffer::new(project_handle.read(cx).replica_id())); + let editor = cx.build_view(|cx| { + let mut editor = + Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx); + editor.set_vertical_scroll_margin(5, cx); + editor + }); + let editor_event_subscription = + cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| { + Self::emit_item_event_for_editor_event(event, cx); + if event == &EditorEvent::Focused && this.path_states.is_empty() { + cx.focus(&this.focus_handle); + } + }); + + let project = project_handle.read(cx); + let summary = project.diagnostic_summary(cx); + let mut this = Self { + project: project_handle, + summary, + workspace, + excerpts, + focus_handle: cx.focus_handle(), + editor, + path_states: Default::default(), + paths_to_update: HashMap::default(), + include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings, + current_diagnostics: HashMap::default(), + _subscriptions: vec![project_event_subscription, editor_event_subscription], + }; + this.update_excerpts(None, cx); + this + } + + fn emit_item_event_for_editor_event(event: &EditorEvent, cx: &mut ViewContext) { + match event { + EditorEvent::Closed => cx.emit(ItemEvent::CloseItem), + + EditorEvent::Saved | EditorEvent::TitleChanged => { + cx.emit(ItemEvent::UpdateTab); + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::Reparsed => { + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::SelectionsChanged { local } if *local => { + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::DirtyChanged => { + cx.emit(ItemEvent::UpdateTab); + } + + EditorEvent::BufferEdited => { + cx.emit(ItemEvent::Edit); + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + + EditorEvent::ExcerptsAdded { .. } | EditorEvent::ExcerptsRemoved { .. } => { + cx.emit(ItemEvent::Edit); + } + + _ => {} + } + } + + fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + if let Some(existing) = workspace.item_of_type::(cx) { + workspace.activate_item(&existing, cx); + } else { + let workspace_handle = cx.view().downgrade(); + let diagnostics = cx.build_view(|cx| { + ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx) + }); + workspace.add_item(Box::new(diagnostics), cx); + } + } + + fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext) { + self.include_warnings = !self.include_warnings; + self.paths_to_update = self.current_diagnostics.clone(); + self.update_excerpts(None, cx); + cx.notify(); + } + + fn focus_in(&mut self, _: &FocusEvent, cx: &mut ViewContext) { + if self.focus_handle.is_focused(cx) && !self.path_states.is_empty() { + self.editor.focus_handle(cx).focus(cx) + } + } + + fn update_excerpts( + &mut self, + language_server_id: Option, + cx: &mut ViewContext, + ) { + log::debug!("Updating excerpts for server {language_server_id:?}"); + let mut paths_to_recheck = HashSet::default(); + let mut new_summaries: HashMap> = self + .project + .read(cx) + .diagnostic_summaries(cx) + .fold(HashMap::default(), |mut summaries, (path, server_id, _)| { + summaries.entry(server_id).or_default().insert(path); + summaries + }); + let mut old_diagnostics = if let Some(language_server_id) = language_server_id { + new_summaries.retain(|server_id, _| server_id == &language_server_id); + self.paths_to_update.retain(|server_id, paths| { + if server_id == &language_server_id { + paths_to_recheck.extend(paths.drain()); + false + } else { + true + } + }); + let mut old_diagnostics = HashMap::default(); + if let Some(new_paths) = new_summaries.get(&language_server_id) { + if let Some(old_paths) = self + .current_diagnostics + .insert(language_server_id, new_paths.clone()) + { + old_diagnostics.insert(language_server_id, old_paths); + } + } else { + if let Some(old_paths) = self.current_diagnostics.remove(&language_server_id) { + old_diagnostics.insert(language_server_id, old_paths); + } + } + old_diagnostics + } else { + paths_to_recheck.extend(self.paths_to_update.drain().flat_map(|(_, paths)| paths)); + mem::replace(&mut self.current_diagnostics, new_summaries.clone()) + }; + for (server_id, new_paths) in new_summaries { + match old_diagnostics.remove(&server_id) { + Some(mut old_paths) => { + paths_to_recheck.extend( + new_paths + .into_iter() + .filter(|new_path| !old_paths.remove(new_path)), + ); + paths_to_recheck.extend(old_paths); + } + None => paths_to_recheck.extend(new_paths), + } + } + paths_to_recheck.extend(old_diagnostics.into_iter().flat_map(|(_, paths)| paths)); + + if paths_to_recheck.is_empty() { + log::debug!("No paths to recheck for language server {language_server_id:?}"); + return; + } + log::debug!( + "Rechecking {} paths for language server {:?}", + paths_to_recheck.len(), + language_server_id + ); + let project = self.project.clone(); + cx.spawn(|this, mut cx| { + async move { + let _: Vec<()> = try_join_all(paths_to_recheck.into_iter().map(|path| { + let mut cx = cx.clone(); + let project = project.clone(); + let this = this.clone(); + async move { + let buffer = project + .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))? + .await + .with_context(|| format!("opening buffer for path {path:?}"))?; + this.update(&mut cx, |this, cx| { + this.populate_excerpts(path, language_server_id, buffer, cx); + }) + .context("missing project")?; + anyhow::Ok(()) + } + })) + .await + .context("rechecking diagnostics for paths")?; + + this.update(&mut cx, |this, cx| { + this.summary = this.project.read(cx).diagnostic_summary(cx); + cx.emit(ItemEvent::UpdateTab); + cx.emit(ItemEvent::UpdateBreadcrumbs); + })?; + anyhow::Ok(()) + } + .log_err() + }) + .detach(); + } + + fn populate_excerpts( + &mut self, + path: ProjectPath, + language_server_id: Option, + buffer: Model, + cx: &mut ViewContext, + ) { + let was_empty = self.path_states.is_empty(); + let snapshot = buffer.read(cx).snapshot(); + let path_ix = match self.path_states.binary_search_by_key(&&path, |e| &e.path) { + Ok(ix) => ix, + Err(ix) => { + self.path_states.insert( + ix, + PathState { + path: path.clone(), + diagnostic_groups: Default::default(), + }, + ); + ix + } + }; + + let mut prev_excerpt_id = if path_ix > 0 { + let prev_path_last_group = &self.path_states[path_ix - 1] + .diagnostic_groups + .last() + .unwrap(); + prev_path_last_group.excerpts.last().unwrap().clone() + } else { + ExcerptId::min() + }; + + let path_state = &mut self.path_states[path_ix]; + let mut groups_to_add = Vec::new(); + let mut group_ixs_to_remove = Vec::new(); + let mut blocks_to_add = Vec::new(); + let mut blocks_to_remove = HashSet::default(); + let mut first_excerpt_id = None; + let max_severity = if self.include_warnings { + DiagnosticSeverity::WARNING + } else { + DiagnosticSeverity::ERROR + }; + let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| { + let mut old_groups = path_state.diagnostic_groups.iter().enumerate().peekable(); + let mut new_groups = snapshot + .diagnostic_groups(language_server_id) + .into_iter() + .filter(|(_, group)| { + group.entries[group.primary_ix].diagnostic.severity <= max_severity + }) + .peekable(); + loop { + let mut to_insert = None; + let mut to_remove = None; + let mut to_keep = None; + match (old_groups.peek(), new_groups.peek()) { + (None, None) => break, + (None, Some(_)) => to_insert = new_groups.next(), + (Some((_, old_group)), None) => { + if language_server_id.map_or(true, |id| id == old_group.language_server_id) + { + to_remove = old_groups.next(); + } else { + to_keep = old_groups.next(); + } + } + (Some((_, old_group)), Some((_, new_group))) => { + let old_primary = &old_group.primary_diagnostic; + let new_primary = &new_group.entries[new_group.primary_ix]; + match compare_diagnostics(old_primary, new_primary, &snapshot) { + Ordering::Less => { + if language_server_id + .map_or(true, |id| id == old_group.language_server_id) + { + to_remove = old_groups.next(); + } else { + to_keep = old_groups.next(); + } + } + Ordering::Equal => { + to_keep = old_groups.next(); + new_groups.next(); + } + Ordering::Greater => to_insert = new_groups.next(), + } + } + } + + if let Some((language_server_id, group)) = to_insert { + let mut group_state = DiagnosticGroupState { + language_server_id, + primary_diagnostic: group.entries[group.primary_ix].clone(), + primary_excerpt_ix: 0, + excerpts: Default::default(), + blocks: Default::default(), + block_count: 0, + }; + let mut pending_range: Option<(Range, usize)> = None; + let mut is_first_excerpt_for_group = true; + for (ix, entry) in group.entries.iter().map(Some).chain([None]).enumerate() { + let resolved_entry = entry.map(|e| e.resolve::(&snapshot)); + if let Some((range, start_ix)) = &mut pending_range { + if let Some(entry) = resolved_entry.as_ref() { + if entry.range.start.row + <= range.end.row + 1 + CONTEXT_LINE_COUNT * 2 + { + range.end = range.end.max(entry.range.end); + continue; + } + } + + let excerpt_start = + Point::new(range.start.row.saturating_sub(CONTEXT_LINE_COUNT), 0); + let excerpt_end = snapshot.clip_point( + Point::new(range.end.row + CONTEXT_LINE_COUNT, u32::MAX), + Bias::Left, + ); + let excerpt_id = excerpts + .insert_excerpts_after( + prev_excerpt_id, + buffer.clone(), + [ExcerptRange { + context: excerpt_start..excerpt_end, + primary: Some(range.clone()), + }], + excerpts_cx, + ) + .pop() + .unwrap(); + + prev_excerpt_id = excerpt_id.clone(); + first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone()); + group_state.excerpts.push(excerpt_id.clone()); + let header_position = (excerpt_id.clone(), language::Anchor::MIN); + + if is_first_excerpt_for_group { + is_first_excerpt_for_group = false; + let mut primary = + group.entries[group.primary_ix].diagnostic.clone(); + primary.message = + primary.message.split('\n').next().unwrap().to_string(); + group_state.block_count += 1; + blocks_to_add.push(BlockProperties { + position: header_position, + height: 2, + style: BlockStyle::Sticky, + render: diagnostic_header_renderer(primary), + disposition: BlockDisposition::Above, + }); + } + + for entry in &group.entries[*start_ix..ix] { + let mut diagnostic = entry.diagnostic.clone(); + if diagnostic.is_primary { + group_state.primary_excerpt_ix = group_state.excerpts.len() - 1; + diagnostic.message = + entry.diagnostic.message.split('\n').skip(1).collect(); + } + + if !diagnostic.message.is_empty() { + group_state.block_count += 1; + blocks_to_add.push(BlockProperties { + position: (excerpt_id.clone(), entry.range.start), + height: diagnostic.message.matches('\n').count() as u8 + 1, + style: BlockStyle::Fixed, + render: diagnostic_block_renderer(diagnostic, true), + disposition: BlockDisposition::Below, + }); + } + } + + pending_range.take(); + } + + if let Some(entry) = resolved_entry { + pending_range = Some((entry.range.clone(), ix)); + } + } + + groups_to_add.push(group_state); + } else if let Some((group_ix, group_state)) = to_remove { + excerpts.remove_excerpts(group_state.excerpts.iter().copied(), excerpts_cx); + group_ixs_to_remove.push(group_ix); + blocks_to_remove.extend(group_state.blocks.iter().copied()); + } else if let Some((_, group)) = to_keep { + prev_excerpt_id = group.excerpts.last().unwrap().clone(); + first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone()); + } + } + + excerpts.snapshot(excerpts_cx) + }); + + self.editor.update(cx, |editor, 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; + BlockProperties { + position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor), + height: block.height, + style: block.style, + render: block.render, + disposition: block.disposition, + } + }), + Some(Autoscroll::fit()), + cx, + ); + + let mut block_ids = block_ids.into_iter(); + for group_state in &mut groups_to_add { + group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect(); + } + }); + + for ix in group_ixs_to_remove.into_iter().rev() { + path_state.diagnostic_groups.remove(ix); + } + path_state.diagnostic_groups.extend(groups_to_add); + path_state.diagnostic_groups.sort_unstable_by(|a, b| { + let range_a = &a.primary_diagnostic.range; + let range_b = &b.primary_diagnostic.range; + range_a + .start + .cmp(&range_b.start, &snapshot) + .then_with(|| range_a.end.cmp(&range_b.end, &snapshot)) + }); + + if path_state.diagnostic_groups.is_empty() { + self.path_states.remove(path_ix); + } + + self.editor.update(cx, |editor, cx| { + let groups; + let mut selections; + let new_excerpt_ids_by_selection_id; + if was_empty { + groups = self.path_states.first()?.diagnostic_groups.as_slice(); + new_excerpt_ids_by_selection_id = [(0, ExcerptId::min())].into_iter().collect(); + selections = vec![Selection { + id: 0, + start: 0, + end: 0, + reversed: false, + goal: SelectionGoal::None, + }]; + } else { + groups = self.path_states.get(path_ix)?.diagnostic_groups.as_slice(); + new_excerpt_ids_by_selection_id = + editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.refresh()); + selections = editor.selections.all::(cx); + } + + // If any selection has lost its position, move it to start of the next primary diagnostic. + let snapshot = editor.snapshot(cx); + for selection in &mut selections { + if let Some(new_excerpt_id) = new_excerpt_ids_by_selection_id.get(&selection.id) { + let group_ix = match groups.binary_search_by(|probe| { + probe + .excerpts + .last() + .unwrap() + .cmp(new_excerpt_id, &snapshot.buffer_snapshot) + }) { + Ok(ix) | Err(ix) => ix, + }; + if let Some(group) = groups.get(group_ix) { + let offset = excerpts_snapshot + .anchor_in_excerpt( + group.excerpts[group.primary_excerpt_ix].clone(), + group.primary_diagnostic.range.start, + ) + .to_offset(&excerpts_snapshot); + selection.start = offset; + selection.end = offset; + } + } + } + editor.change_selections(None, cx, |s| { + s.select(selections); + }); + Some(()) + }); + + if self.path_states.is_empty() { + if self.editor.focus_handle(cx).is_focused(cx) { + cx.focus(&self.focus_handle); + } + } else if self.focus_handle.is_focused(cx) { + let focus_handle = self.editor.focus_handle(cx); + cx.focus(&focus_handle); + } + cx.notify(); + } +} + +impl FocusableView for ProjectDiagnosticsEditor { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for ProjectDiagnosticsEditor { + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| editor.deactivated(cx)); + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some("Project Diagnostics".into()) + } + + fn tab_content(&self, _detail: Option, _: &WindowContext) -> AnyElement { + render_summary(&self.summary) + } + + fn for_each_project_item( + &self, + cx: &AppContext, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn clone_on_split( + &self, + _workspace_id: workspace::WorkspaceId, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.build_view(|cx| { + ProjectDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx) + })) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &AppContext) -> bool { + true + } + + fn save(&mut self, project: Model, cx: &mut ViewContext) -> Task> { + self.editor.save(project, cx) + } + + fn save_as( + &mut self, + _: Model, + _: PathBuf, + _: &mut ViewContext, + ) -> Task> { + unreachable!() + } + + fn reload(&mut self, project: Model, cx: &mut ViewContext) -> Task> { + self.editor.reload(project, cx) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a View, + _: &'a AppContext, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn breadcrumb_location(&self) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); + } + + fn serialized_item_kind() -> Option<&'static str> { + Some("diagnostics") + } + + fn deserialize( + project: Model, + workspace: WeakView, + _workspace_id: workspace::WorkspaceId, + _item_id: workspace::ItemId, + cx: &mut ViewContext, + ) -> Task>> { + Task::ready(Ok(cx.build_view(|cx| Self::new(project, workspace, cx)))) + } +} + +fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { + let (message, highlights) = highlight_diagnostic_message(Vec::new(), &diagnostic.message); + Arc::new(move |_| { + h_stack() + .id("diagnostic header") + .gap_3() + .bg(gpui::red()) + .map(|stack| { + let icon = if diagnostic.severity == DiagnosticSeverity::ERROR { + IconElement::new(Icon::XCircle).color(Color::Error) + } else { + IconElement::new(Icon::ExclamationTriangle).color(Color::Warning) + }; + + stack.child(div().pl_8().child(icon)) + }) + .when_some(diagnostic.source.as_ref(), |stack, source| { + stack.child(Label::new(format!("{source}:")).color(Color::Accent)) + }) + .child(HighlightedLabel::new(message.clone(), highlights.clone())) + .when_some(diagnostic.code.as_ref(), |stack, code| { + stack.child(Label::new(code.clone())) + }) + .render_into_any() + }) +} + +pub(crate) fn render_summary(summary: &DiagnosticSummary) -> AnyElement { + if summary.error_count == 0 && summary.warning_count == 0 { + let label = Label::new("No problems"); + label.render_into_any() + } else { + h_stack() + .bg(gpui::red()) + .child(IconElement::new(Icon::XCircle)) + .child(Label::new(summary.error_count.to_string())) + .child(IconElement::new(Icon::ExclamationTriangle)) + .child(Label::new(summary.warning_count.to_string())) + .render_into_any() + } +} + +fn compare_diagnostics( + lhs: &DiagnosticEntry, + rhs: &DiagnosticEntry, + snapshot: &language::BufferSnapshot, +) -> Ordering { + lhs.range + .start + .to_offset(snapshot) + .cmp(&rhs.range.start.to_offset(snapshot)) + .then_with(|| { + lhs.range + .end + .to_offset(snapshot) + .cmp(&rhs.range.end.to_offset(snapshot)) + }) + .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message)) +} + +#[cfg(test)] +mod tests { + use super::*; + use editor::{ + display_map::{BlockContext, TransformBlock}, + DisplayPoint, + }; + use gpui::{px, TestAppContext, VisualTestContext, WindowContext}; + use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped}; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use unindent::Unindent as _; + + #[gpui::test] + async fn test_diagnostics(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + "consts.rs": " + const a: i32 = 'a'; + const b: i32 = c; + " + .unindent(), + + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + // comment 1 + // comment 2 + c(y); + d(x); + } + " + .unindent(), + }), + ) + .await; + + let language_server_id = LanguageServerId(0); + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let workspace = window.root(cx).unwrap(); + + // Create some diagnostics + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/main.rs"), + None, + vec![ + DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)), + diagnostic: Diagnostic { + message: + "move occurs because `x` has type `Vec`, which does not implement the `Copy` trait" + .to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)), + diagnostic: Diagnostic { + message: + "move occurs because `y` has type `Vec`, which does not implement the `Copy` trait" + .to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)), + diagnostic: Diagnostic { + message: "value moved here".to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)), + diagnostic: Diagnostic { + message: "value moved here".to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)), + diagnostic: Diagnostic { + message: "use of moved value\nvalue used here after move".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)), + diagnostic: Diagnostic { + message: "use of moved value\nvalue used here after move".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + ], + cx, + ) + .unwrap(); + }); + + // Open the project diagnostics view while there are already diagnostics. + let view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx) + }); + + view.next_notification(cx).await; + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (15, "collapsed context".into()), + (16, "diagnostic header".into()), + (25, "collapsed context".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + + // Cursor is at the first diagnostic + view.editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)] + ); + }); + }); + + // Diagnostics are added for another earlier path. + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(language_server_id, cx); + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/consts.rs"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)), + diagnostic: Diagnostic { + message: "mismatched types\nexpected `usize`, found `char`".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(language_server_id, cx); + }); + + view.next_notification(cx).await; + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "path header block".into()), + (9, "diagnostic header".into()), + (22, "collapsed context".into()), + (23, "diagnostic header".into()), + (32, "collapsed context".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // consts.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "\n", // supporting diagnostic + "const b: i32 = c;\n", + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // filename + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + + // Cursor keeps its position. + view.editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)] + ); + }); + }); + + // Diagnostics are added to the first path + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(language_server_id, cx); + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/consts.rs"), + None, + vec![ + DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 15)) + ..Unclipped(PointUtf16::new(0, 15)), + diagnostic: Diagnostic { + message: "mismatched types\nexpected `usize`, found `char`" + .to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 15)) + ..Unclipped(PointUtf16::new(1, 15)), + diagnostic: Diagnostic { + message: "unresolved name `c`".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + ], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(language_server_id, cx); + }); + + view.next_notification(cx).await; + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + (13, "path header block".into()), + (15, "diagnostic header".into()), + (28, "collapsed context".into()), + (29, "diagnostic header".into()), + (38, "collapsed context".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // consts.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "\n", // supporting diagnostic + "const b: i32 = c;\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "const b: i32 = c;\n", + "\n", // supporting diagnostic + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // filename + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + }); + } + + #[gpui::test] + async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + "main.js": " + a(); + b(); + c(); + d(); + e(); + ".unindent() + }), + ) + .await; + + let server_id_1 = LanguageServerId(100); + let server_id_2 = LanguageServerId(101); + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let workspace = window.root(cx).unwrap(); + + let view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx) + }); + + // Two language servers start updating diagnostics + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(server_id_1, cx); + project.disk_based_diagnostics_started(server_id_2, cx); + project + .update_diagnostic_entries( + server_id_1, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)), + diagnostic: Diagnostic { + message: "error 1".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + }); + + // The first language server finishes + project.update(cx, |project, cx| { + project.disk_based_diagnostics_finished(server_id_1, cx); + }); + + // Only the first language server's diagnostics are shown. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // + "b();", + ) + ); + }); + + // The second language server finishes + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)), + diagnostic: Diagnostic { + message: "warning 1".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 2, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_2, cx); + }); + + // Both language server's diagnostics are shown. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (6, "collapsed context".into()), + (7, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // location + "b();\n", // + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "a();\n", // context + "b();\n", // + "c();", // context + ) + ); + }); + + // Both language servers start updating diagnostics, and the first server finishes. + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(server_id_1, cx); + project.disk_based_diagnostics_started(server_id_2, cx); + project + .update_diagnostic_entries( + server_id_1, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)), + diagnostic: Diagnostic { + message: "warning 2".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.rs"), + None, + vec![], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_1, cx); + }); + + // Only the first language server's diagnostics are updated. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // location + "b();\n", // + "c();\n", // context + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "b();\n", // context + "c();\n", // + "d();", // context + ) + ); + }); + + // The second language server finishes. + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)), + diagnostic: Diagnostic { + message: "warning 2".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_2, cx); + }); + + // Both language servers' diagnostics are updated. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "b();\n", // location + "c();\n", // + "d();\n", // context + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "c();\n", // context + "d();\n", // + "e();", // context + ) + ); + }); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + client::init_settings(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + crate::init(cx); + }); + } + + fn editor_blocks(editor: &View, cx: &mut WindowContext) -> Vec<(u32, SharedString)> { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + snapshot + .blocks_in_range(0..snapshot.max_point().row()) + .enumerate() + .filter_map(|(ix, (row, block))| { + let name = match block { + TransformBlock::Custom(block) => block + .render(&mut BlockContext { + view_context: cx, + anchor_x: px(0.), + gutter_padding: px(0.), + gutter_width: px(0.), + line_height: px(0.), + em_width: px(0.), + block_id: ix, + editor_style: &editor::EditorStyle::default(), + }) + .element_id()? + .try_into() + .ok()?, + + TransformBlock::ExcerptHeader { + starts_new_buffer, .. + } => { + if *starts_new_buffer { + "path header block".into() + } else { + "collapsed context".into() + } + } + }; + + Some((row, name)) + }) + .collect() + }) + } +} diff --git a/crates/diagnostics2/src/items.rs b/crates/diagnostics2/src/items.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbcfa748d447d66b19df5e2417a11eca1156aa49 --- /dev/null +++ b/crates/diagnostics2/src/items.rs @@ -0,0 +1,151 @@ +use collections::HashSet; +use editor::{Editor, GoToDiagnostic}; +use gpui::{ + rems, Div, EventEmitter, InteractiveElement, ParentElement, Render, Stateful, + StatefulInteractiveElement, Styled, Subscription, View, ViewContext, WeakView, +}; +use language::Diagnostic; +use lsp::LanguageServerId; +use theme::ActiveTheme; +use ui::{h_stack, Color, Icon, IconElement, Label, Tooltip}; +use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace}; + +use crate::ProjectDiagnosticsEditor; + +pub struct DiagnosticIndicator { + summary: project::DiagnosticSummary, + active_editor: Option>, + workspace: WeakView, + current_diagnostic: Option, + in_progress_checks: HashSet, + _observe_active_editor: Option, +} + +impl Render for DiagnosticIndicator { + type Element = Stateful
; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let diagnostic_indicator = match (self.summary.error_count, self.summary.warning_count) { + (0, 0) => h_stack().child(IconElement::new(Icon::Check).color(Color::Success)), + (0, warning_count) => h_stack() + .gap_1() + .child(IconElement::new(Icon::ExclamationTriangle).color(Color::Warning)) + .child(Label::new(warning_count.to_string())), + (error_count, 0) => h_stack() + .gap_1() + .child(IconElement::new(Icon::XCircle).color(Color::Error)) + .child(Label::new(error_count.to_string())), + (error_count, warning_count) => h_stack() + .gap_1() + .child(IconElement::new(Icon::XCircle).color(Color::Error)) + .child(Label::new(error_count.to_string())) + .child(IconElement::new(Icon::ExclamationTriangle).color(Color::Warning)) + .child(Label::new(warning_count.to_string())), + }; + + h_stack() + .id(cx.entity_id()) + .on_action(cx.listener(Self::go_to_next_diagnostic)) + .rounded_md() + .flex_none() + .h(rems(1.375)) + .px_1() + .cursor_pointer() + .bg(cx.theme().colors().ghost_element_background) + .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .tooltip(|cx| Tooltip::text("Project Diagnostics", cx)) + .on_click(cx.listener(|this, _, cx| { + if let Some(workspace) = this.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + ProjectDiagnosticsEditor::deploy(workspace, &Default::default(), cx) + }) + } + })) + .child(diagnostic_indicator) + } +} + +impl DiagnosticIndicator { + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { + let project = workspace.project(); + cx.subscribe(project, |this, project, event, cx| match event { + project::Event::DiskBasedDiagnosticsStarted { language_server_id } => { + this.in_progress_checks.insert(*language_server_id); + cx.notify(); + } + + project::Event::DiskBasedDiagnosticsFinished { language_server_id } + | project::Event::LanguageServerRemoved(language_server_id) => { + this.summary = project.read(cx).diagnostic_summary(cx); + this.in_progress_checks.remove(language_server_id); + cx.notify(); + } + + project::Event::DiagnosticsUpdated { .. } => { + this.summary = project.read(cx).diagnostic_summary(cx); + cx.notify(); + } + + _ => {} + }) + .detach(); + + Self { + summary: project.read(cx).diagnostic_summary(cx), + in_progress_checks: project + .read(cx) + .language_servers_running_disk_based_diagnostics() + .collect(), + active_editor: None, + workspace: workspace.weak_handle(), + current_diagnostic: None, + _observe_active_editor: None, + } + } + + fn go_to_next_diagnostic(&mut self, _: &GoToDiagnostic, cx: &mut ViewContext) { + if let Some(editor) = self.active_editor.as_ref().and_then(|e| e.upgrade()) { + editor.update(cx, |editor, cx| { + editor.go_to_diagnostic_impl(editor::Direction::Next, cx); + }) + } + } + + fn update(&mut self, editor: View, cx: &mut ViewContext) { + let editor = editor.read(cx); + let buffer = editor.buffer().read(cx); + let cursor_position = editor.selections.newest::(cx).head(); + let new_diagnostic = buffer + .snapshot(cx) + .diagnostics_in_range::<_, usize>(cursor_position..cursor_position, false) + .filter(|entry| !entry.range.is_empty()) + .min_by_key(|entry| (entry.diagnostic.severity, entry.range.len())) + .map(|entry| entry.diagnostic); + if new_diagnostic != self.current_diagnostic { + self.current_diagnostic = new_diagnostic; + cx.notify(); + } + } +} + +impl EventEmitter for DiagnosticIndicator {} + +impl StatusItemView for DiagnosticIndicator { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) { + if let Some(editor) = active_pane_item.and_then(|item| item.downcast::()) { + self.active_editor = Some(editor.downgrade()); + self._observe_active_editor = Some(cx.observe(&editor, Self::update)); + self.update(editor, cx); + } else { + self.active_editor = None; + self.current_diagnostic = None; + self._observe_active_editor = None; + } + cx.notify(); + } +} diff --git a/crates/diagnostics2/src/project_diagnostics_settings.rs b/crates/diagnostics2/src/project_diagnostics_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..f762d2b1e626f8be1394ccecf2b2d683ca7ea437 --- /dev/null +++ b/crates/diagnostics2/src/project_diagnostics_settings.rs @@ -0,0 +1,28 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Debug)] +pub struct ProjectDiagnosticsSettings { + pub include_warnings: bool, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct ProjectDiagnosticsSettingsContent { + include_warnings: Option, +} + +impl settings::Settings for ProjectDiagnosticsSettings { + const KEY: Option<&'static str> = Some("diagnostics"); + type FileContent = ProjectDiagnosticsSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _cx: &mut gpui::AppContext, + ) -> anyhow::Result + where + Self: Sized, + { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/diagnostics2/src/toolbar_controls.rs b/crates/diagnostics2/src/toolbar_controls.rs new file mode 100644 index 0000000000000000000000000000000000000000..e513076ec829322d4393babc35b159c6b2bc3312 --- /dev/null +++ b/crates/diagnostics2/src/toolbar_controls.rs @@ -0,0 +1,66 @@ +use crate::ProjectDiagnosticsEditor; +use gpui::{div, Div, EventEmitter, ParentElement, Render, ViewContext, WeakView}; +use ui::{Icon, IconButton, Tooltip}; +use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; + +pub struct ToolbarControls { + editor: Option>, +} + +impl Render for ToolbarControls { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let include_warnings = self + .editor + .as_ref() + .and_then(|editor| editor.upgrade()) + .map(|editor| editor.read(cx).include_warnings) + .unwrap_or(false); + + let tooltip = if include_warnings { + "Exclude Warnings" + } else { + "Include Warnings" + }; + + div().child( + IconButton::new("toggle-warnings", Icon::ExclamationTriangle) + .tooltip(move |cx| Tooltip::text(tooltip, cx)) + .on_click(cx.listener(|this, _, cx| { + if let Some(editor) = this.editor.as_ref().and_then(|editor| editor.upgrade()) { + editor.update(cx, |editor, cx| { + editor.toggle_warnings(&Default::default(), cx); + }); + } + })), + ) + } +} + +impl EventEmitter for ToolbarControls {} + +impl ToolbarItemView for ToolbarControls { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut ViewContext, + ) -> ToolbarItemLocation { + if let Some(pane_item) = active_pane_item.as_ref() { + if let Some(editor) = pane_item.downcast::() { + self.editor = Some(editor.downgrade()); + ToolbarItemLocation::PrimaryRight + } else { + ToolbarItemLocation::Hidden + } + } else { + ToolbarItemLocation::Hidden + } + } +} + +impl ToolbarControls { + pub fn new() -> Self { + ToolbarControls { editor: None } + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4e449bb7f7732c8d65968c3723d800e29278e748..2558aec12139843135cc59c7b6ff516750c89a42 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -24,7 +24,7 @@ use ::git::diff::DiffHunk; use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context, Result}; use blink_manager::BlinkManager; -use client::{ClickhouseEvent, Client, Collaborator, ParticipantIndex, TelemetrySettings}; +use client::{Client, Collaborator, ParticipantIndex, TelemetrySettings}; use clock::{Global, ReplicaId}; use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; @@ -8946,12 +8946,12 @@ impl Editor { let telemetry = project.read(cx).client().telemetry().clone(); let telemetry_settings = *settings::get::(cx); - let event = ClickhouseEvent::Copilot { + telemetry.report_copilot_event( + telemetry_settings, suggestion_id, suggestion_accepted, file_extension, - }; - telemetry.report_clickhouse_event(event, telemetry_settings); + ) } #[cfg(any(test, feature = "test-support"))] @@ -8998,14 +8998,14 @@ impl Editor { .show_copilot_suggestions; let telemetry = project.read(cx).client().telemetry().clone(); - let event = ClickhouseEvent::Editor { + telemetry.report_editor_event( + telemetry_settings, file_extension, vim_mode, operation, copilot_enabled, copilot_enabled_for_language, - }; - telemetry.report_clickhouse_event(event, telemetry_settings) + ) } /// Copy the highlighted chunks to the clipboard as JSON. The format is an array of lines, diff --git a/crates/editor2/src/display_map/block_map.rs b/crates/editor2/src/display_map/block_map.rs index 05106dd2a1f1416529689750f77b2e264f4d5e83..00778c2eddc8eec3cccf3a3a2a9fe89355d26ded 100644 --- a/crates/editor2/src/display_map/block_map.rs +++ b/crates/editor2/src/display_map/block_map.rs @@ -50,7 +50,7 @@ struct BlockRow(u32); #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] struct WrapRow(u32); -pub type RenderBlock = Arc AnyElement>; +pub type RenderBlock = Arc AnyElement>; pub struct Block { id: BlockId, @@ -69,7 +69,7 @@ where pub position: P, pub height: u8, pub style: BlockStyle, - pub render: Arc AnyElement>, + pub render: Arc AnyElement>, pub disposition: BlockDisposition, } @@ -947,7 +947,7 @@ impl DerefMut for BlockContext<'_, '_> { } impl Block { - pub fn render(&self, cx: &mut BlockContext) -> AnyElement { + pub fn render(&self, cx: &mut BlockContext) -> AnyElement { self.render.lock()(cx) } diff --git a/crates/editor2/src/editor.rs b/crates/editor2/src/editor.rs index 1e8714cc657218beb0c58ffd83b8b4fc6ccfe66f..3801b965c2a1a10cb867c3bf853b1c156b0342ba 100644 --- a/crates/editor2/src/editor.rs +++ b/crates/editor2/src/editor.rs @@ -24,7 +24,7 @@ use ::git::diff::DiffHunk; use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context as _, Result}; use blink_manager::BlinkManager; -use client::{ClickhouseEvent, Client, Collaborator, ParticipantIndex, TelemetrySettings}; +use client::{Client, Collaborator, ParticipantIndex, TelemetrySettings}; use clock::ReplicaId; use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; @@ -42,9 +42,9 @@ use gpui::{ actions, div, point, prelude::*, px, relative, rems, size, uniform_list, Action, AnyElement, AppContext, AsyncWindowContext, BackgroundExecutor, Bounds, ClipboardItem, Component, Context, EventEmitter, FocusHandle, FocusableView, FontFeatures, FontStyle, FontWeight, HighlightStyle, - Hsla, InputHandler, KeyContext, Model, MouseButton, ParentComponent, Pixels, Render, Styled, - Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, - WeakView, WindowContext, + Hsla, InputHandler, KeyContext, Model, MouseButton, ParentElement, Pixels, Render, + SharedString, Styled, Subscription, Task, TextStyle, UniformListScrollHandle, View, + ViewContext, VisualContext, WeakView, WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -585,7 +585,7 @@ pub enum SoftWrap { Column(u32), } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct EditorStyle { pub background: Hsla, pub local_player: PlayerColor, @@ -907,7 +907,7 @@ impl ContextMenu { style: &EditorStyle, workspace: Option>, cx: &mut ViewContext, - ) -> (DisplayPoint, AnyElement) { + ) -> (DisplayPoint, AnyElement) { match self { ContextMenu::Completions(menu) => (cursor_position, menu.render(style, workspace, cx)), ContextMenu::CodeActions(menu) => menu.render(cursor_position, style, cx), @@ -1223,9 +1223,7 @@ impl CompletionsMenu { style: &EditorStyle, workspace: Option>, cx: &mut ViewContext, - ) -> AnyElement { - // enum CompletionTag {} - + ) -> AnyElement { let settings = EditorSettings::get_global(cx); let show_completion_documentation = settings.show_completion_documentation; @@ -1253,121 +1251,126 @@ impl CompletionsMenu { let matches = self.matches.clone(); let selected_item = self.selected_item; - let list = uniform_list("completions", matches.len(), move |editor, range, cx| { - let start_ix = range.start; - let completions_guard = completions.read(); + let list = uniform_list( + cx.view().clone(), + "completions", + matches.len(), + move |editor, range, cx| { + let start_ix = range.start; + let completions_guard = completions.read(); - matches[range] - .iter() - .enumerate() - .map(|(ix, mat)| { - let item_ix = start_ix + ix; - let candidate_id = mat.candidate_id; - let completion = &completions_guard[candidate_id]; + matches[range] + .iter() + .enumerate() + .map(|(ix, mat)| { + let item_ix = start_ix + ix; + let candidate_id = mat.candidate_id; + let completion = &completions_guard[candidate_id]; - let documentation = if show_completion_documentation { - &completion.documentation - } else { - &None - }; + let documentation = if show_completion_documentation { + &completion.documentation + } else { + &None + }; - // todo!("highlights") - // let highlights = combine_syntax_and_fuzzy_match_highlights( - // &completion.label.text, - // style.text.color.into(), - // styled_runs_for_code_label(&completion.label, &style.syntax), - // &mat.positions, - // ) - - // todo!("documentation") - // MouseEventHandler::new::(mat.candidate_id, cx, |state, _| { - // let completion_label = HighlightedLabel::new( - // completion.label.text.clone(), - // combine_syntax_and_fuzzy_match_highlights( - // &completion.label.text, - // style.text.color.into(), - // styled_runs_for_code_label(&completion.label, &style.syntax), - // &mat.positions, - // ), - // ); - // Text::new(completion.label.text.clone(), style.text.clone()) - // .with_soft_wrap(false) - // .with_highlights(); - - // if let Some(Documentation::SingleLine(text)) = documentation { - // h_stack() - // .child(completion_label) - // .with_children((|| { - // let text_style = TextStyle { - // color: style.autocomplete.inline_docs_color, - // font_size: style.text.font_size - // * style.autocomplete.inline_docs_size_percent, - // ..style.text.clone() - // }; - - // let label = Text::new(text.clone(), text_style) - // .aligned() - // .constrained() - // .dynamically(move |constraint, _, _| gpui::SizeConstraint { - // min: constraint.min, - // max: vec2f(constraint.max.x(), constraint.min.y()), - // }); - - // if Some(item_ix) == widest_completion_ix { - // Some( - // label - // .contained() - // .with_style(style.autocomplete.inline_docs_container) - // .into_any(), - // ) - // } else { - // Some(label.flex_float().into_any()) - // } - // })()) - // .into_any() - // } else { - // completion_label.into_any() - // } - // .contained() - // .with_style(item_style) - // .constrained() - // .dynamically(move |constraint, _, _| { - // if Some(item_ix) == widest_completion_ix { - // constraint - // } else { - // gpui::SizeConstraint { - // min: constraint.min, - // max: constraint.min, - // } - // } - // }) - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .on_down(MouseButton::Left, move |_, this, cx| { - // this.confirm_completion( - // &ConfirmCompletion { - // item_ix: Some(item_ix), - // }, - // cx, - // ) - // .map(|task| task.detach()); - // }) - // .constrained() - // - div() - .id(mat.candidate_id) - .bg(gpui::green()) - .hover(|style| style.bg(gpui::blue())) - .when(item_ix == selected_item, |div| div.bg(gpui::blue())) - .child(completion.label.text.clone()) - .min_w(px(300.)) - .max_w(px(700.)) - }) - .collect() - }) + // todo!("highlights") + // let highlights = combine_syntax_and_fuzzy_match_highlights( + // &completion.label.text, + // style.text.color.into(), + // styled_runs_for_code_label(&completion.label, &style.syntax), + // &mat.positions, + // ) + + // todo!("documentation") + // MouseEventHandler::new::(mat.candidate_id, cx, |state, _| { + // let completion_label = HighlightedLabel::new( + // completion.label.text.clone(), + // combine_syntax_and_fuzzy_match_highlights( + // &completion.label.text, + // style.text.color.into(), + // styled_runs_for_code_label(&completion.label, &style.syntax), + // &mat.positions, + // ), + // ); + // Text::new(completion.label.text.clone(), style.text.clone()) + // .with_soft_wrap(false) + // .with_highlights(); + + // if let Some(Documentation::SingleLine(text)) = documentation { + // h_stack() + // .child(completion_label) + // .with_children((|| { + // let text_style = TextStyle { + // color: style.autocomplete.inline_docs_color, + // font_size: style.text.font_size + // * style.autocomplete.inline_docs_size_percent, + // ..style.text.clone() + // }; + + // let label = Text::new(text.clone(), text_style) + // .aligned() + // .constrained() + // .dynamically(move |constraint, _, _| gpui::SizeConstraint { + // min: constraint.min, + // max: vec2f(constraint.max.x(), constraint.min.y()), + // }); + + // if Some(item_ix) == widest_completion_ix { + // Some( + // label + // .contained() + // .with_style(style.autocomplete.inline_docs_container) + // .into_any(), + // ) + // } else { + // Some(label.flex_float().into_any()) + // } + // })()) + // .into_any() + // } else { + // completion_label.into_any() + // } + // .contained() + // .with_style(item_style) + // .constrained() + // .dynamically(move |constraint, _, _| { + // if Some(item_ix) == widest_completion_ix { + // constraint + // } else { + // gpui::SizeConstraint { + // min: constraint.min, + // max: constraint.min, + // } + // } + // }) + // }) + // .with_cursor_style(CursorStyle::PointingHand) + // .on_down(MouseButton::Left, move |_, this, cx| { + // this.confirm_completion( + // &ConfirmCompletion { + // item_ix: Some(item_ix), + // }, + // cx, + // ) + // .map(|task| task.detach()); + // }) + // .constrained() + // + div() + .id(mat.candidate_id) + .bg(gpui::green()) + .hover(|style| style.bg(gpui::blue())) + .when(item_ix == selected_item, |div| div.bg(gpui::blue())) + .child(SharedString::from(completion.label.text.clone())) + .min_w(px(300.)) + .max_w(px(700.)) + }) + .collect() + }, + ) .with_width_from_item(widest_completion_ix); - list.render() + list.render_into_any() // todo!("multiline documentation") // enum MultiLineDocumentation {} @@ -1529,13 +1532,15 @@ impl CodeActionsMenu { mut cursor_position: DisplayPoint, style: &EditorStyle, cx: &mut ViewContext, - ) -> (DisplayPoint, AnyElement) { + ) -> (DisplayPoint, AnyElement) { let actions = self.actions.clone(); let selected_item = self.selected_item; + let element = uniform_list( + cx.view().clone(), "code_actions_menu", self.actions.len(), - move |editor, range, cx| { + move |this, range, cx| { actions[range.clone()] .iter() .enumerate() @@ -1557,18 +1562,22 @@ impl CodeActionsMenu { .bg(colors.element_hover) .text_color(colors.text_accent) }) - .on_mouse_down(MouseButton::Left, move |editor: &mut Editor, _, cx| { - cx.stop_propagation(); - editor - .confirm_code_action( - &ConfirmCodeAction { - item_ix: Some(item_ix), - }, - cx, - ) - .map(|task| task.detach_and_log_err(cx)); - }) - .child(action.lsp_action.title.clone()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |editor, _, cx| { + cx.stop_propagation(); + editor + .confirm_code_action( + &ConfirmCodeAction { + item_ix: Some(item_ix), + }, + cx, + ) + .map(|task| task.detach_and_log_err(cx)); + }), + ) + // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here. + .child(SharedString::from(action.lsp_action.title.clone())) }) .collect() }, @@ -1583,7 +1592,7 @@ impl CodeActionsMenu { .max_by_key(|(_, action)| action.lsp_action.title.chars().count()) .map(|(ix, _)| ix), ) - .render(); + .render_into_any(); if self.deployed_from_indicator { *cursor_position.column_mut() = 0; @@ -2306,7 +2315,8 @@ impl Editor { } self.blink_manager.update(cx, BlinkManager::pause_blinking); - cx.emit(Event::SelectionsChanged { local }); + cx.emit(EditorEvent::SelectionsChanged { local }); + cx.emit(SearchEvent::MatchesInvalidated); if self.selections.disjoint_anchors().len() == 1 { cx.emit(SearchEvent::ActiveMatchChanged) @@ -4230,7 +4240,7 @@ impl Editor { self.report_copilot_event(Some(completion.uuid.clone()), true, cx) } - cx.emit(Event::InputHandled { + cx.emit(EditorEvent::InputHandled { utf16_range_to_replace: None, text: suggestion.text.to_string().into(), }); @@ -4341,19 +4351,19 @@ impl Editor { style: &EditorStyle, is_active: bool, cx: &mut ViewContext, - ) -> Option> { + ) -> Option { if self.available_code_actions.is_some() { Some( - IconButton::new("code_actions_indicator", ui::Icon::Bolt) - .on_click(|editor: &mut Editor, cx| { + IconButton::new("code_actions_indicator", ui::Icon::Bolt).on_click(cx.listener( + |editor, e, cx| { editor.toggle_code_actions( &ToggleCodeActions { deployed_from_indicator: true, }, cx, ); - }) - .render(), + }, + )), ) } else { None @@ -4368,7 +4378,7 @@ impl Editor { line_height: Pixels, gutter_margin: Pixels, cx: &mut ViewContext, - ) -> Vec>> { + ) -> Vec> { fold_data .iter() .enumerate() @@ -4381,15 +4391,15 @@ impl Editor { FoldStatus::Foldable => ui::Icon::ChevronDown, }; IconButton::new(ix as usize, icon) - .on_click(move |editor: &mut Editor, cx| match fold_status { + .on_click(cx.listener(move |editor, e, cx| match fold_status { FoldStatus::Folded => { editor.unfold_at(&UnfoldAt { buffer_row }, cx); } FoldStatus::Foldable => { editor.fold_at(&FoldAt { buffer_row }, cx); } - }) - .render() + })) + .color(ui::Color::Muted) }) }) .flatten() @@ -4409,7 +4419,7 @@ impl Editor { cursor_position: DisplayPoint, style: &EditorStyle, cx: &mut ViewContext, - ) -> Option<(DisplayPoint, AnyElement)> { + ) -> Option<(DisplayPoint, AnyElement)> { self.context_menu.read().as_ref().map(|menu| { menu.render( cursor_position, @@ -5627,7 +5637,7 @@ impl Editor { self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(cx); self.refresh_copilot_suggestions(true, cx); - cx.emit(Event::Edited); + cx.emit(EditorEvent::Edited); } } @@ -5642,7 +5652,7 @@ impl Editor { self.request_autoscroll(Autoscroll::fit(), cx); self.unmark_text(cx); self.refresh_copilot_suggestions(true, cx); - cx.emit(Event::Edited); + cx.emit(EditorEvent::Edited); } } @@ -7768,7 +7778,7 @@ impl Editor { } div() .pl(cx.anchor_x) - .child(rename_editor.render_with(EditorElement::new( + .child(EditorElement::new( &rename_editor, EditorStyle { background: cx.theme().system().transparent, @@ -7776,11 +7786,13 @@ impl Editor { text: text_style, scrollbar_width: cx.editor_style.scrollbar_width, syntax: cx.editor_style.syntax.clone(), - diagnostic_style: - cx.editor_style.diagnostic_style.clone(), + diagnostic_style: cx + .editor_style + .diagnostic_style + .clone(), }, - ))) - .render() + )) + .render_into_any() } }), disposition: BlockDisposition::Below, @@ -8111,7 +8123,7 @@ impl Editor { log::error!("unexpectedly ended a transaction that wasn't started by this editor"); } - cx.emit(Event::Edited); + cx.emit(EditorEvent::Edited); Some(tx_id) } else { None @@ -8699,7 +8711,7 @@ impl Editor { if self.has_active_copilot_suggestion(cx) { self.update_visible_copilot_suggestion(cx); } - cx.emit(Event::BufferEdited); + cx.emit(EditorEvent::BufferEdited); cx.emit(ItemEvent::Edit); cx.emit(ItemEvent::UpdateBreadcrumbs); cx.emit(SearchEvent::MatchesInvalidated); @@ -8738,7 +8750,7 @@ impl Editor { predecessor, excerpts, } => { - cx.emit(Event::ExcerptsAdded { + cx.emit(EditorEvent::ExcerptsAdded { buffer: buffer.clone(), predecessor: *predecessor, excerpts: excerpts.clone(), @@ -8747,7 +8759,7 @@ impl Editor { } multi_buffer::Event::ExcerptsRemoved { ids } => { self.refresh_inlay_hints(InlayHintRefreshReason::ExcerptsRemoved(ids.clone()), cx); - cx.emit(Event::ExcerptsRemoved { ids: ids.clone() }) + cx.emit(EditorEvent::ExcerptsRemoved { ids: ids.clone() }) } multi_buffer::Event::Reparsed => { cx.emit(ItemEvent::UpdateBreadcrumbs); @@ -8761,7 +8773,7 @@ impl Editor { cx.emit(ItemEvent::UpdateTab); cx.emit(ItemEvent::UpdateBreadcrumbs); } - multi_buffer::Event::DiffBaseChanged => cx.emit(Event::DiffBaseChanged), + multi_buffer::Event::DiffBaseChanged => cx.emit(EditorEvent::DiffBaseChanged), multi_buffer::Event::Closed => cx.emit(ItemEvent::CloseItem), multi_buffer::Event::DiagnosticsUpdated => { self.refresh_active_diagnostics(cx); @@ -8955,12 +8967,12 @@ impl Editor { let telemetry = project.read(cx).client().telemetry().clone(); let telemetry_settings = *TelemetrySettings::get_global(cx); - let event = ClickhouseEvent::Copilot { + telemetry.report_copilot_event( + telemetry_settings, suggestion_id, suggestion_accepted, file_extension, - }; - telemetry.report_clickhouse_event(event, telemetry_settings); + ) } #[cfg(any(test, feature = "test-support"))] @@ -9007,14 +9019,14 @@ impl Editor { .show_copilot_suggestions; let telemetry = project.read(cx).client().telemetry().clone(); - let event = ClickhouseEvent::Editor { + telemetry.report_editor_event( + telemetry_settings, file_extension, vim_mode, operation, copilot_enabled, copilot_enabled_for_language, - }; - telemetry.report_clickhouse_event(event, telemetry_settings) + ) } /// Copy the highlighted chunks to the clipboard as JSON. The format is an array of lines, @@ -9101,7 +9113,7 @@ impl Editor { cx: &mut ViewContext, ) { if !self.input_enabled { - cx.emit(Event::InputIgnored { text: text.into() }); + cx.emit(EditorEvent::InputIgnored { text: text.into() }); return; } if let Some(relative_utf16_range) = relative_utf16_range { @@ -9161,7 +9173,7 @@ impl Editor { } fn handle_focus(&mut self, cx: &mut ViewContext) { - cx.emit(Event::Focused); + cx.emit(EditorEvent::Focused); if let Some(rename) = self.pending_rename.as_ref() { let rename_editor_focus_handle = rename.editor.read(cx).focus_handle.clone(); @@ -9191,7 +9203,7 @@ impl Editor { .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); self.hide_context_menu(cx); hide_hover(self, cx); - cx.emit(Event::Blurred); + cx.emit(EditorEvent::Blurred); cx.notify(); } } @@ -9314,7 +9326,7 @@ impl Deref for EditorSnapshot { } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum Event { +pub enum EditorEvent { InputIgnored { text: Arc, }, @@ -9332,8 +9344,12 @@ pub enum Event { }, BufferEdited, Edited, + Reparsed, Focused, Blurred, + DirtyChanged, + Saved, + TitleChanged, DiffBaseChanged, SelectionsChanged { local: bool, @@ -9342,6 +9358,7 @@ pub enum Event { local: bool, autoscroll: bool, }, + Closed, } pub struct EditorFocused(pub View); @@ -9356,7 +9373,7 @@ pub struct EditorReleased(pub WeakView); // } // } // -impl EventEmitter for Editor {} +impl EventEmitter for Editor {} impl FocusableView for Editor { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { @@ -9559,7 +9576,7 @@ impl InputHandler for Editor { cx: &mut ViewContext, ) { if !self.input_enabled { - cx.emit(Event::InputIgnored { text: text.into() }); + cx.emit(EditorEvent::InputIgnored { text: text.into() }); return; } @@ -9589,7 +9606,7 @@ impl InputHandler for Editor { }) }); - cx.emit(Event::InputHandled { + cx.emit(EditorEvent::InputHandled { utf16_range_to_replace: range_to_replace, text: text.into(), }); @@ -9620,7 +9637,7 @@ impl InputHandler for Editor { cx: &mut ViewContext, ) { if !self.input_enabled { - cx.emit(Event::InputIgnored { text: text.into() }); + cx.emit(EditorEvent::InputIgnored { text: text.into() }); return; } @@ -9663,7 +9680,7 @@ impl InputHandler for Editor { }) }); - cx.emit(Event::InputHandled { + cx.emit(EditorEvent::InputHandled { utf16_range_to_replace: range_to_replace, text: text.into(), }); @@ -9978,11 +9995,11 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend .ml(cx.anchor_x) })) .cursor_pointer() - .on_click(move |_, _, cx| { + .on_click(cx.listener(move |_, _, cx| { cx.write_to_clipboard(ClipboardItem::new(message.clone())); - }) - .tooltip(|_, cx| Tooltip::text("Copy diagnostic message", cx)) - .render() + })) + .tooltip(|cx| Tooltip::text("Copy diagnostic message", cx)) + .render_into_any() }) } diff --git a/crates/editor2/src/editor_tests.rs b/crates/editor2/src/editor_tests.rs index bd69e7acdf5c01875c9908a7d56665c6260fb0ce..f0609fc9a8507a4adf5db179c90ff1d519378dd8 100644 --- a/crates/editor2/src/editor_tests.rs +++ b/crates/editor2/src/editor_tests.rs @@ -3048,7 +3048,7 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { position: snapshot.anchor_after(Point::new(2, 0)), disposition: BlockDisposition::Below, height: 1, - render: Arc::new(|_| div().render()), + render: Arc::new(|_| div().into_any()), }], Some(Autoscroll::fit()), cx, @@ -3853,7 +3853,7 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) { let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); let (view, cx) = cx.add_window_view(|cx| build_editor(buffer, cx)); - view.condition::(&cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + view.condition::(&cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) .await; view.update(cx, |view, cx| { @@ -4019,7 +4019,7 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx)); editor - .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) + .condition::(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx)) .await; editor.update(cx, |editor, cx| { @@ -4583,7 +4583,7 @@ async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) { }); let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); let (view, cx) = cx.add_window_view(|cx| build_editor(buffer, cx)); - view.condition::(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + view.condition::(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) .await; view.update(cx, |view, cx| { @@ -4734,7 +4734,7 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) { let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx)); editor - .condition::(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + .condition::(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) .await; editor.update(cx, |editor, cx| { @@ -6295,7 +6295,7 @@ async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) { }); let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); let (view, cx) = cx.add_window_view(|cx| build_editor(buffer, cx)); - view.condition::(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) + view.condition::(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx)) .await; view.update(cx, |view, cx| { diff --git a/crates/editor2/src/element.rs b/crates/editor2/src/element.rs index 3de5389b1f8f83f8046fb4cef090de5994ea63d3..add9c9ad338541321999701d909a79d31e88f34a 100644 --- a/crates/editor2/src/element.rs +++ b/crates/editor2/src/element.rs @@ -20,10 +20,10 @@ use collections::{BTreeMap, HashMap}; use gpui::{ div, point, px, relative, size, transparent_black, Action, AnyElement, AvailableSpace, BorrowWindow, Bounds, Component, ContentMask, Corners, DispatchPhase, Edges, Element, - ElementId, ElementInputHandler, Entity, EntityId, Hsla, InteractiveComponent, LineLayout, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentComponent, Pixels, - ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveComponent, Style, Styled, - TextRun, TextStyle, View, ViewContext, WindowContext, WrappedLine, + ElementId, ElementInputHandler, Entity, EntityId, Hsla, InteractiveElement, LineLayout, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, RenderOnce, + ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, + TextRun, TextStyle, View, ViewContext, WeakView, WindowContext, WrappedLine, }; use itertools::Itertools; use language::language_settings::ShowWhitespaceSetting; @@ -112,14 +112,14 @@ impl SelectionLayout { } pub struct EditorElement { - editor_id: EntityId, + editor: View, style: EditorStyle, } impl EditorElement { pub fn new(editor: &View, style: EditorStyle) -> Self { Self { - editor_id: editor.entity_id(), + editor: editor.clone(), style, } } @@ -349,7 +349,7 @@ impl EditorElement { gutter_bounds: Bounds, text_bounds: Bounds, layout: &LayoutState, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let bounds = gutter_bounds.union(&text_bounds); let scroll_top = @@ -460,7 +460,7 @@ impl EditorElement { bounds: Bounds, layout: &mut LayoutState, editor: &mut Editor, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let line_height = layout.position_map.line_height; @@ -488,13 +488,14 @@ impl EditorElement { } } - for (ix, fold_indicator) in layout.fold_indicators.iter_mut().enumerate() { - if let Some(fold_indicator) = fold_indicator.as_mut() { + for (ix, fold_indicator) in layout.fold_indicators.drain(..).enumerate() { + if let Some(mut fold_indicator) = fold_indicator { + let mut fold_indicator = fold_indicator.render_into_any(); let available_space = size( AvailableSpace::MinContent, AvailableSpace::Definite(line_height * 0.55), ); - let fold_indicator_size = fold_indicator.measure(available_space, editor, cx); + let fold_indicator_size = fold_indicator.measure(available_space, cx); let position = point( bounds.size.width - layout.gutter_padding, @@ -505,32 +506,29 @@ impl EditorElement { (line_height - fold_indicator_size.height) / 2., ); let origin = bounds.origin + position + centering_offset; - fold_indicator.draw(origin, available_space, editor, cx); + fold_indicator.draw(origin, available_space, cx); } } - if let Some(indicator) = layout.code_actions_indicator.as_mut() { + if let Some(indicator) = layout.code_actions_indicator.take() { + let mut button = indicator.button.render_into_any(); let available_space = size( AvailableSpace::MinContent, AvailableSpace::Definite(line_height), ); - let indicator_size = indicator.element.measure(available_space, editor, cx); + let indicator_size = button.measure(available_space, cx); + let mut x = Pixels::ZERO; let mut y = indicator.row as f32 * line_height - scroll_top; // Center indicator. x += ((layout.gutter_padding + layout.gutter_margin) - indicator_size.width) / 2.; y += (line_height - indicator_size.height) / 2.; - indicator - .element - .draw(bounds.origin + point(x, y), available_space, editor, cx); + + button.draw(bounds.origin + point(x, y), available_space, cx); } } - fn paint_diff_hunks( - bounds: Bounds, - layout: &LayoutState, - cx: &mut ViewContext, - ) { + fn paint_diff_hunks(bounds: Bounds, layout: &LayoutState, cx: &mut WindowContext) { // todo!() // let diff_style = &theme::current(cx).editor.diff.clone(); // let line_height = layout.position_map.line_height; @@ -619,7 +617,7 @@ impl EditorElement { text_bounds: Bounds, layout: &mut LayoutState, editor: &mut Editor, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let scroll_position = layout.position_map.snapshot.scroll_position(); let start_row = layout.visible_display_row_range.start; @@ -674,20 +672,22 @@ impl EditorElement { div() .id(fold.id) .size_full() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_click(move |editor: &mut Editor, _, cx| { - editor.unfold_ranges( - [fold_range.start..fold_range.end], - true, - false, - cx, - ); - cx.stop_propagation(); - }) + .on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation()) + .on_click(cx.listener_for( + &self.editor, + move |editor: &mut Editor, _, cx| { + editor.unfold_ranges( + [fold_range.start..fold_range.end], + true, + false, + cx, + ); + cx.stop_propagation(); + }, + )) .draw( fold_bounds.origin, fold_bounds.size, - editor, cx, |fold_element_state, cx| { if fold_element_state.is_active() { @@ -840,7 +840,7 @@ impl EditorElement { } }); - if let Some((position, context_menu)) = layout.context_menu.as_mut() { + if let Some((position, mut context_menu)) = layout.context_menu.take() { cx.with_z_index(1, |cx| { let line_height = self.style.text.line_height_in_pixels(cx.rem_size()); let available_space = size( @@ -850,7 +850,7 @@ impl EditorElement { .min((text_bounds.size.height - line_height) / 2.), ), ); - let context_menu_size = context_menu.measure(available_space, editor, cx); + let context_menu_size = context_menu.measure(available_space, cx); let cursor_row_layout = &layout.position_map.line_layouts [(position.row() - start_row) as usize] @@ -874,7 +874,7 @@ impl EditorElement { list_origin.y -= layout.position_map.line_height - list_height; } - context_menu.draw(list_origin, available_space, editor, cx); + context_menu.draw(list_origin, available_space, cx); }) } @@ -1165,7 +1165,7 @@ impl EditorElement { layout: &LayoutState, content_origin: gpui::Point, bounds: Bounds, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let start_row = layout.visible_display_row_range.start; let end_row = layout.visible_display_row_range.end; @@ -1218,13 +1218,13 @@ impl EditorElement { bounds: Bounds, layout: &mut LayoutState, editor: &mut Editor, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let scroll_position = layout.position_map.snapshot.scroll_position(); let scroll_left = scroll_position.x * layout.position_map.em_width; let scroll_top = scroll_position.y * layout.position_map.line_height; - for block in &mut layout.blocks { + for block in layout.blocks.drain(..) { let mut origin = bounds.origin + point( Pixels::ZERO, @@ -1233,9 +1233,7 @@ impl EditorElement { if !matches!(block.style, BlockStyle::Sticky) { origin += point(-scroll_left, Pixels::ZERO); } - block - .element - .draw(origin, block.available_space, editor, cx); + block.element.draw(origin, block.available_space, cx); } } @@ -1810,7 +1808,7 @@ impl EditorElement { .render_code_actions_indicator(&style, active, cx) .map(|element| CodeActionsIndicator { row: newest_selection_head.row(), - element, + button: element, }); } } @@ -1970,6 +1968,7 @@ impl EditorElement { TransformBlock::ExcerptHeader { .. } => false, TransformBlock::Custom(block) => block.style() == BlockStyle::Fixed, }); + let mut render_block = |block: &TransformBlock, available_space: Size, block_id: usize, @@ -2003,6 +2002,7 @@ impl EditorElement { editor_style: &self.style, }) } + TransformBlock::ExcerptHeader { buffer, range, @@ -2026,12 +2026,10 @@ impl EditorElement { let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); IconButton::new(block_id, ui::Icon::ArrowUpRight) - .on_click(move |editor: &mut Editor, cx| { + .on_click(cx.listener_for(&self.editor, move |editor, e, cx| { editor.jump(jump_path.clone(), jump_position, jump_anchor, cx); - }) - .tooltip(move |_, cx| { - Tooltip::for_action("Jump to Buffer", &OpenExcerpts, cx) - }) + })) + .tooltip(|cx| Tooltip::for_action("Jump to Buffer", &OpenExcerpts, cx)) }); let element = if *starts_new_buffer { @@ -2041,29 +2039,36 @@ impl EditorElement { // 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() + "/"); + parent_path = path + .parent() + .map(|p| SharedString::from(p.to_string_lossy().to_string() + "/")); } h_stack() + .id("path header block") .size_full() .bg(gpui::red()) - .child(filename.unwrap_or_else(|| "untitled".to_string())) + .child( + filename + .map(SharedString::from) + .unwrap_or_else(|| "untitled".into()), + ) .children(parent_path) .children(jump_icon) // .p_x(gutter_padding) } else { let text_style = style.text.clone(); h_stack() + .id("collapsed context") .size_full() .bg(gpui::red()) .child("⋯") .children(jump_icon) // .p_x(gutter_padding) }; - element.render() + element.into_any() } }; - let size = element.measure(available_space, editor, cx); + let size = element.measure(available_space, cx); (element, size) }; @@ -2122,47 +2127,61 @@ impl EditorElement { gutter_bounds: Bounds, text_bounds: Bounds, layout: &LayoutState, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let content_origin = text_bounds.origin + point(layout.gutter_margin, Pixels::ZERO); cx.on_mouse_event({ let position_map = layout.position_map.clone(); - move |editor, event: &ScrollWheelEvent, phase, cx| { + let editor = self.editor.clone(); + + move |event: &ScrollWheelEvent, phase, cx| { if phase != DispatchPhase::Bubble { return; } - if Self::scroll(editor, event, &position_map, bounds, cx) { + let should_cancel = editor.update(cx, |editor, cx| { + Self::scroll(editor, event, &position_map, bounds, cx) + }); + if should_cancel { cx.stop_propagation(); } } }); + cx.on_mouse_event({ let position_map = layout.position_map.clone(); - move |editor, event: &MouseDownEvent, phase, cx| { + let editor = self.editor.clone(); + + move |event: &MouseDownEvent, phase, cx| { if phase != DispatchPhase::Bubble { return; } - if Self::mouse_down(editor, event, &position_map, text_bounds, gutter_bounds, cx) { + let should_cancel = editor.update(cx, |editor, cx| { + Self::mouse_down(editor, event, &position_map, text_bounds, gutter_bounds, cx) + }); + + if should_cancel { cx.stop_propagation() } } }); + cx.on_mouse_event({ let position_map = layout.position_map.clone(); - move |editor, event: &MouseUpEvent, phase, cx| { - if phase != DispatchPhase::Bubble { - return; - } + let editor = self.editor.clone(); + move |event: &MouseUpEvent, phase, cx| { + let should_cancel = editor.update(cx, |editor, cx| { + Self::mouse_up(editor, event, &position_map, text_bounds, cx) + }); - if Self::mouse_up(editor, event, &position_map, text_bounds, cx) { + if should_cancel { cx.stop_propagation() } } }); - // todo!() + //todo!() // on_down(MouseButton::Right, { // let position_map = layout.position_map.clone(); // move |event, editor, cx| { @@ -2179,12 +2198,17 @@ impl EditorElement { // }); cx.on_mouse_event({ let position_map = layout.position_map.clone(); - move |editor, event: &MouseMoveEvent, phase, cx| { + let editor = self.editor.clone(); + move |event: &MouseMoveEvent, phase, cx| { if phase != DispatchPhase::Bubble { return; } - if Self::mouse_moved(editor, event, &position_map, text_bounds, gutter_bounds, cx) { + let stop_propogating = editor.update(cx, |editor, cx| { + Self::mouse_moved(editor, event, &position_map, text_bounds, gutter_bounds, cx) + }); + + if stop_propogating { cx.stop_propagation() } } @@ -2313,7 +2337,7 @@ impl LineWithInvisibles { content_origin: gpui::Point, whitespace_setting: ShowWhitespaceSetting, selection_ranges: &[Range], - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let line_height = layout.position_map.line_height; let line_y = line_height * row as f32 - layout.position_map.scroll_position.y; @@ -2345,7 +2369,7 @@ impl LineWithInvisibles { row: u32, line_height: Pixels, whitespace_setting: ShowWhitespaceSetting, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { let allowed_invisibles_regions = match whitespace_setting { ShowWhitespaceSetting::None => return, @@ -2388,87 +2412,102 @@ enum Invisible { Whitespace { line_offset: usize }, } -impl Element for EditorElement { - type ElementState = (); - - fn element_id(&self) -> Option { - Some(self.editor_id.into()) - } +impl Element for EditorElement { + type State = (); fn layout( &mut self, - editor: &mut Editor, - element_state: Option, - cx: &mut gpui::ViewContext, - ) -> (gpui::LayoutId, Self::ElementState) { - editor.style = Some(self.style.clone()); // Long-term, we'd like to eliminate this. - - let rem_size = cx.rem_size(); - let mut style = Style::default(); - style.size.width = relative(1.).into(); - style.size.height = match editor.mode { - EditorMode::SingleLine => self.style.text.line_height_in_pixels(cx.rem_size()).into(), - EditorMode::AutoHeight { .. } => todo!(), - EditorMode::Full => relative(1.).into(), - }; - let layout_id = cx.request_layout(&style, None); - (layout_id, ()) + element_state: Option, + cx: &mut gpui::WindowContext, + ) -> (gpui::LayoutId, Self::State) { + self.editor.update(cx, |editor, cx| { + editor.style = Some(self.style.clone()); // Long-term, we'd like to eliminate this. + + let rem_size = cx.rem_size(); + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = match editor.mode { + EditorMode::SingleLine => { + self.style.text.line_height_in_pixels(cx.rem_size()).into() + } + EditorMode::AutoHeight { .. } => todo!(), + EditorMode::Full => relative(1.).into(), + }; + let layout_id = cx.request_layout(&style, None); + + (layout_id, ()) + }) } fn paint( - &mut self, + mut self, bounds: Bounds, - editor: &mut Editor, - element_state: &mut Self::ElementState, - cx: &mut gpui::ViewContext, + element_state: &mut Self::State, + cx: &mut gpui::WindowContext, ) { - let mut layout = self.compute_layout(editor, cx, bounds); - let gutter_bounds = Bounds { - origin: bounds.origin, - size: layout.gutter_size, - }; - let text_bounds = Bounds { - origin: gutter_bounds.upper_right(), - size: layout.text_size, - }; - - let dispatch_context = editor.dispatch_context(cx); - cx.with_key_dispatch( - dispatch_context, - Some(editor.focus_handle.clone()), - |_, cx| { - register_actions(cx); + let editor = self.editor.clone(); + editor.update(cx, |editor, cx| { + let mut layout = self.compute_layout(editor, cx, bounds); + let gutter_bounds = Bounds { + origin: bounds.origin, + size: layout.gutter_size, + }; + let text_bounds = Bounds { + origin: gutter_bounds.upper_right(), + size: layout.text_size, + }; - // We call with_z_index to establish a new stacking context. - cx.with_z_index(0, |cx| { - cx.with_content_mask(Some(ContentMask { bounds }), |cx| { - // Paint mouse listeners first, so any elements we paint on top of the editor - // take precedence. - self.paint_mouse_listeners(bounds, gutter_bounds, text_bounds, &layout, cx); - let input_handler = ElementInputHandler::new(bounds, cx); - cx.handle_input(&editor.focus_handle, input_handler); - - self.paint_background(gutter_bounds, text_bounds, &layout, cx); - if layout.gutter_size.width > Pixels::ZERO { - self.paint_gutter(gutter_bounds, &mut layout, editor, cx); - } - self.paint_text(text_bounds, &mut layout, editor, cx); + let dispatch_context = editor.dispatch_context(cx); + let editor_handle = cx.view().clone(); + cx.with_key_dispatch( + dispatch_context, + Some(editor.focus_handle.clone()), + |_, cx| { + register_actions(&editor_handle, cx); + + // We call with_z_index to establish a new stacking context. + cx.with_z_index(0, |cx| { + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + // Paint mouse listeners first, so any elements we paint on top of the editor + // take precedence. + self.paint_mouse_listeners( + bounds, + gutter_bounds, + text_bounds, + &layout, + cx, + ); + let input_handler = ElementInputHandler::new(bounds, editor_handle, cx); + cx.handle_input(&editor.focus_handle, input_handler); + + self.paint_background(gutter_bounds, text_bounds, &layout, cx); + if layout.gutter_size.width > Pixels::ZERO { + self.paint_gutter(gutter_bounds, &mut layout, editor, cx); + } + self.paint_text(text_bounds, &mut layout, editor, cx); - if !layout.blocks.is_empty() { - cx.with_element_id(Some("editor_blocks"), |cx| { - self.paint_blocks(bounds, &mut layout, editor, cx); - }) - } + if !layout.blocks.is_empty() { + cx.with_element_id(Some("editor_blocks"), |cx| { + self.paint_blocks(bounds, &mut layout, editor, cx); + }) + } + }); }); - }); - }, - ) + }, + ) + }) } } -impl Component for EditorElement { - fn render(self) -> AnyElement { - AnyElement::new(self) +impl RenderOnce for EditorElement { + type Element = Self; + + fn element_id(&self) -> Option { + self.editor.element_id() + } + + fn render_once(self) -> Self::Element { + self } } @@ -3093,17 +3132,17 @@ pub struct LayoutState { show_scrollbars: bool, is_singleton: bool, max_row: u32, - context_menu: Option<(DisplayPoint, AnyElement)>, + context_menu: Option<(DisplayPoint, AnyElement)>, code_actions_indicator: Option, - // hover_popovers: Option<(DisplayPoint, Vec>)>, - fold_indicators: Vec>>, + // hover_popovers: Option<(DisplayPoint, Vec)>, + fold_indicators: Vec>, tab_invisible: ShapedLine, space_invisible: ShapedLine, } struct CodeActionsIndicator { row: u32, - element: AnyElement, + button: IconButton, } struct PositionMap { @@ -3188,7 +3227,7 @@ impl PositionMap { struct BlockLayout { row: u32, - element: AnyElement, + element: AnyElement, available_space: Size, style: BlockStyle, } @@ -3893,187 +3932,191 @@ fn scale_horizontal_mouse_autoscroll_delta(delta: Pixels) -> f32 { // } // } -fn register_actions(cx: &mut ViewContext) { - register_action(cx, Editor::move_left); - register_action(cx, Editor::move_right); - register_action(cx, Editor::move_down); - register_action(cx, Editor::move_up); +fn register_actions(view: &View, cx: &mut WindowContext) { + register_action(view, cx, Editor::move_left); + register_action(view, cx, Editor::move_right); + register_action(view, cx, Editor::move_down); + register_action(view, cx, Editor::move_up); // on_action(cx, Editor::new_file); todo!() // on_action(cx, Editor::new_file_in_direction); todo!() - register_action(cx, Editor::cancel); - register_action(cx, Editor::newline); - register_action(cx, Editor::newline_above); - register_action(cx, Editor::newline_below); - register_action(cx, Editor::backspace); - register_action(cx, Editor::delete); - register_action(cx, Editor::tab); - register_action(cx, Editor::tab_prev); - register_action(cx, Editor::indent); - register_action(cx, Editor::outdent); - register_action(cx, Editor::delete_line); - register_action(cx, Editor::join_lines); - register_action(cx, Editor::sort_lines_case_sensitive); - register_action(cx, Editor::sort_lines_case_insensitive); - register_action(cx, Editor::reverse_lines); - register_action(cx, Editor::shuffle_lines); - register_action(cx, Editor::convert_to_upper_case); - register_action(cx, Editor::convert_to_lower_case); - register_action(cx, Editor::convert_to_title_case); - register_action(cx, Editor::convert_to_snake_case); - register_action(cx, Editor::convert_to_kebab_case); - register_action(cx, Editor::convert_to_upper_camel_case); - register_action(cx, Editor::convert_to_lower_camel_case); - register_action(cx, Editor::delete_to_previous_word_start); - register_action(cx, Editor::delete_to_previous_subword_start); - register_action(cx, Editor::delete_to_next_word_end); - register_action(cx, Editor::delete_to_next_subword_end); - register_action(cx, Editor::delete_to_beginning_of_line); - register_action(cx, Editor::delete_to_end_of_line); - register_action(cx, Editor::cut_to_end_of_line); - register_action(cx, Editor::duplicate_line); - register_action(cx, Editor::move_line_up); - register_action(cx, Editor::move_line_down); - register_action(cx, Editor::transpose); - register_action(cx, Editor::cut); - register_action(cx, Editor::copy); - register_action(cx, Editor::paste); - register_action(cx, Editor::undo); - register_action(cx, Editor::redo); - register_action(cx, Editor::move_page_up); - register_action(cx, Editor::move_page_down); - register_action(cx, Editor::next_screen); - register_action(cx, Editor::scroll_cursor_top); - register_action(cx, Editor::scroll_cursor_center); - register_action(cx, Editor::scroll_cursor_bottom); - register_action(cx, |editor, _: &LineDown, cx| { + register_action(view, cx, Editor::cancel); + register_action(view, cx, Editor::newline); + register_action(view, cx, Editor::newline_above); + register_action(view, cx, Editor::newline_below); + register_action(view, cx, Editor::backspace); + register_action(view, cx, Editor::delete); + register_action(view, cx, Editor::tab); + register_action(view, cx, Editor::tab_prev); + register_action(view, cx, Editor::indent); + register_action(view, cx, Editor::outdent); + register_action(view, cx, Editor::delete_line); + register_action(view, cx, Editor::join_lines); + register_action(view, cx, Editor::sort_lines_case_sensitive); + register_action(view, cx, Editor::sort_lines_case_insensitive); + register_action(view, cx, Editor::reverse_lines); + register_action(view, cx, Editor::shuffle_lines); + register_action(view, cx, Editor::convert_to_upper_case); + register_action(view, cx, Editor::convert_to_lower_case); + register_action(view, cx, Editor::convert_to_title_case); + register_action(view, cx, Editor::convert_to_snake_case); + register_action(view, cx, Editor::convert_to_kebab_case); + register_action(view, cx, Editor::convert_to_upper_camel_case); + register_action(view, cx, Editor::convert_to_lower_camel_case); + register_action(view, cx, Editor::delete_to_previous_word_start); + register_action(view, cx, Editor::delete_to_previous_subword_start); + register_action(view, cx, Editor::delete_to_next_word_end); + register_action(view, cx, Editor::delete_to_next_subword_end); + register_action(view, cx, Editor::delete_to_beginning_of_line); + register_action(view, cx, Editor::delete_to_end_of_line); + register_action(view, cx, Editor::cut_to_end_of_line); + register_action(view, cx, Editor::duplicate_line); + register_action(view, cx, Editor::move_line_up); + register_action(view, cx, Editor::move_line_down); + register_action(view, cx, Editor::transpose); + register_action(view, cx, Editor::cut); + register_action(view, cx, Editor::copy); + register_action(view, cx, Editor::paste); + register_action(view, cx, Editor::undo); + register_action(view, cx, Editor::redo); + register_action(view, cx, Editor::move_page_up); + register_action(view, cx, Editor::move_page_down); + register_action(view, cx, Editor::next_screen); + register_action(view, cx, Editor::scroll_cursor_top); + register_action(view, cx, Editor::scroll_cursor_center); + register_action(view, cx, Editor::scroll_cursor_bottom); + register_action(view, cx, |editor, _: &LineDown, cx| { editor.scroll_screen(&ScrollAmount::Line(1.), cx) }); - register_action(cx, |editor, _: &LineUp, cx| { + register_action(view, cx, |editor, _: &LineUp, cx| { editor.scroll_screen(&ScrollAmount::Line(-1.), cx) }); - register_action(cx, |editor, _: &HalfPageDown, cx| { + register_action(view, cx, |editor, _: &HalfPageDown, cx| { editor.scroll_screen(&ScrollAmount::Page(0.5), cx) }); - register_action(cx, |editor, _: &HalfPageUp, cx| { + register_action(view, cx, |editor, _: &HalfPageUp, cx| { editor.scroll_screen(&ScrollAmount::Page(-0.5), cx) }); - register_action(cx, |editor, _: &PageDown, cx| { + register_action(view, cx, |editor, _: &PageDown, cx| { editor.scroll_screen(&ScrollAmount::Page(1.), cx) }); - register_action(cx, |editor, _: &PageUp, cx| { + register_action(view, cx, |editor, _: &PageUp, cx| { editor.scroll_screen(&ScrollAmount::Page(-1.), cx) }); - register_action(cx, Editor::move_to_previous_word_start); - register_action(cx, Editor::move_to_previous_subword_start); - register_action(cx, Editor::move_to_next_word_end); - register_action(cx, Editor::move_to_next_subword_end); - register_action(cx, Editor::move_to_beginning_of_line); - register_action(cx, Editor::move_to_end_of_line); - register_action(cx, Editor::move_to_start_of_paragraph); - register_action(cx, Editor::move_to_end_of_paragraph); - register_action(cx, Editor::move_to_beginning); - register_action(cx, Editor::move_to_end); - register_action(cx, Editor::select_up); - register_action(cx, Editor::select_down); - register_action(cx, Editor::select_left); - register_action(cx, Editor::select_right); - register_action(cx, Editor::select_to_previous_word_start); - register_action(cx, Editor::select_to_previous_subword_start); - register_action(cx, Editor::select_to_next_word_end); - register_action(cx, Editor::select_to_next_subword_end); - register_action(cx, Editor::select_to_beginning_of_line); - register_action(cx, Editor::select_to_end_of_line); - register_action(cx, Editor::select_to_start_of_paragraph); - register_action(cx, Editor::select_to_end_of_paragraph); - register_action(cx, Editor::select_to_beginning); - register_action(cx, Editor::select_to_end); - register_action(cx, Editor::select_all); - register_action(cx, |editor, action, cx| { + register_action(view, cx, Editor::move_to_previous_word_start); + register_action(view, cx, Editor::move_to_previous_subword_start); + register_action(view, cx, Editor::move_to_next_word_end); + register_action(view, cx, Editor::move_to_next_subword_end); + register_action(view, cx, Editor::move_to_beginning_of_line); + register_action(view, cx, Editor::move_to_end_of_line); + register_action(view, cx, Editor::move_to_start_of_paragraph); + register_action(view, cx, Editor::move_to_end_of_paragraph); + register_action(view, cx, Editor::move_to_beginning); + register_action(view, cx, Editor::move_to_end); + register_action(view, cx, Editor::select_up); + register_action(view, cx, Editor::select_down); + register_action(view, cx, Editor::select_left); + register_action(view, cx, Editor::select_right); + register_action(view, cx, Editor::select_to_previous_word_start); + register_action(view, cx, Editor::select_to_previous_subword_start); + register_action(view, cx, Editor::select_to_next_word_end); + register_action(view, cx, Editor::select_to_next_subword_end); + register_action(view, cx, Editor::select_to_beginning_of_line); + register_action(view, cx, Editor::select_to_end_of_line); + register_action(view, cx, Editor::select_to_start_of_paragraph); + register_action(view, cx, Editor::select_to_end_of_paragraph); + register_action(view, cx, Editor::select_to_beginning); + register_action(view, cx, Editor::select_to_end); + register_action(view, cx, Editor::select_all); + register_action(view, cx, |editor, action, cx| { editor.select_all_matches(action, cx).log_err(); }); - register_action(cx, Editor::select_line); - register_action(cx, Editor::split_selection_into_lines); - register_action(cx, Editor::add_selection_above); - register_action(cx, Editor::add_selection_below); - register_action(cx, |editor, action, cx| { + register_action(view, cx, Editor::select_line); + register_action(view, cx, Editor::split_selection_into_lines); + register_action(view, cx, Editor::add_selection_above); + register_action(view, cx, Editor::add_selection_below); + register_action(view, cx, |editor, action, cx| { editor.select_next(action, cx).log_err(); }); - register_action(cx, |editor, action, cx| { + register_action(view, cx, |editor, action, cx| { editor.select_previous(action, cx).log_err(); }); - register_action(cx, Editor::toggle_comments); - register_action(cx, Editor::select_larger_syntax_node); - register_action(cx, Editor::select_smaller_syntax_node); - register_action(cx, Editor::move_to_enclosing_bracket); - register_action(cx, Editor::undo_selection); - register_action(cx, Editor::redo_selection); - register_action(cx, Editor::go_to_diagnostic); - register_action(cx, Editor::go_to_prev_diagnostic); - register_action(cx, Editor::go_to_hunk); - register_action(cx, Editor::go_to_prev_hunk); - register_action(cx, Editor::go_to_definition); - register_action(cx, Editor::go_to_definition_split); - register_action(cx, Editor::go_to_type_definition); - register_action(cx, Editor::go_to_type_definition_split); - register_action(cx, Editor::fold); - register_action(cx, Editor::fold_at); - register_action(cx, Editor::unfold_lines); - register_action(cx, Editor::unfold_at); - register_action(cx, Editor::fold_selected_ranges); - register_action(cx, Editor::show_completions); - register_action(cx, Editor::toggle_code_actions); + register_action(view, cx, Editor::toggle_comments); + register_action(view, cx, Editor::select_larger_syntax_node); + register_action(view, cx, Editor::select_smaller_syntax_node); + register_action(view, cx, Editor::move_to_enclosing_bracket); + register_action(view, cx, Editor::undo_selection); + register_action(view, cx, Editor::redo_selection); + register_action(view, cx, Editor::go_to_diagnostic); + register_action(view, cx, Editor::go_to_prev_diagnostic); + register_action(view, cx, Editor::go_to_hunk); + register_action(view, cx, Editor::go_to_prev_hunk); + register_action(view, cx, Editor::go_to_definition); + register_action(view, cx, Editor::go_to_definition_split); + register_action(view, cx, Editor::go_to_type_definition); + register_action(view, cx, Editor::go_to_type_definition_split); + register_action(view, cx, Editor::fold); + register_action(view, cx, Editor::fold_at); + register_action(view, cx, Editor::unfold_lines); + register_action(view, cx, Editor::unfold_at); + register_action(view, cx, Editor::fold_selected_ranges); + register_action(view, cx, Editor::show_completions); + register_action(view, cx, Editor::toggle_code_actions); // on_action(cx, Editor::open_excerpts); todo!() - register_action(cx, Editor::toggle_soft_wrap); - register_action(cx, Editor::toggle_inlay_hints); - register_action(cx, Editor::reveal_in_finder); - register_action(cx, Editor::copy_path); - register_action(cx, Editor::copy_relative_path); - register_action(cx, Editor::copy_highlight_json); - register_action(cx, |editor, action, cx| { + register_action(view, cx, Editor::toggle_soft_wrap); + register_action(view, cx, Editor::toggle_inlay_hints); + register_action(view, cx, Editor::reveal_in_finder); + register_action(view, cx, Editor::copy_path); + register_action(view, cx, Editor::copy_relative_path); + register_action(view, cx, Editor::copy_highlight_json); + register_action(view, cx, |editor, action, cx| { editor .format(action, cx) .map(|task| task.detach_and_log_err(cx)); }); - register_action(cx, Editor::restart_language_server); - register_action(cx, Editor::show_character_palette); + register_action(view, cx, Editor::restart_language_server); + register_action(view, cx, Editor::show_character_palette); // on_action(cx, Editor::confirm_completion); todo!() - register_action(cx, |editor, action, cx| { + register_action(view, cx, |editor, action, cx| { editor .confirm_code_action(action, cx) .map(|task| task.detach_and_log_err(cx)); }); - register_action(cx, |editor, action, cx| { + register_action(view, cx, |editor, action, cx| { editor .rename(action, cx) .map(|task| task.detach_and_log_err(cx)); }); - register_action(cx, |editor, action, cx| { + register_action(view, cx, |editor, action, cx| { editor .confirm_rename(action, cx) .map(|task| task.detach_and_log_err(cx)); }); - register_action(cx, |editor, action, cx| { + register_action(view, cx, |editor, action, cx| { editor .find_all_references(action, cx) .map(|task| task.detach_and_log_err(cx)); }); - register_action(cx, Editor::next_copilot_suggestion); - register_action(cx, Editor::previous_copilot_suggestion); - register_action(cx, Editor::copilot_suggest); - register_action(cx, Editor::context_menu_first); - register_action(cx, Editor::context_menu_prev); - register_action(cx, Editor::context_menu_next); - register_action(cx, Editor::context_menu_last); + register_action(view, cx, Editor::next_copilot_suggestion); + register_action(view, cx, Editor::previous_copilot_suggestion); + register_action(view, cx, Editor::copilot_suggest); + register_action(view, cx, Editor::context_menu_first); + register_action(view, cx, Editor::context_menu_prev); + register_action(view, cx, Editor::context_menu_next); + register_action(view, cx, Editor::context_menu_last); } fn register_action( - cx: &mut ViewContext, + view: &View, + cx: &mut WindowContext, listener: impl Fn(&mut Editor, &T, &mut ViewContext) + 'static, ) { - cx.on_action(TypeId::of::(), move |editor, action, phase, cx| { + let view = view.clone(); + cx.on_action(TypeId::of::(), move |action, phase, cx| { let action = action.downcast_ref().unwrap(); if phase == DispatchPhase::Bubble { - listener(editor, action, cx); + view.update(cx, |editor, cx| { + listener(editor, action, cx); + }) } }) } diff --git a/crates/editor2/src/hover_popover.rs b/crates/editor2/src/hover_popover.rs index 5c8f403d4f7d768d764f5665f45e31863b00e733..07d108cd6525babd12bf55404ba1b561cf2d67f4 100644 --- a/crates/editor2/src/hover_popover.rs +++ b/crates/editor2/src/hover_popover.rs @@ -422,7 +422,7 @@ impl HoverState { visible_rows: Range, workspace: Option>, cx: &mut ViewContext, - ) -> Option<(DisplayPoint, Vec>)> { + ) -> Option<(DisplayPoint, Vec)> { todo!("old version below") } // // If there is a diagnostic, position the popovers based on that. @@ -504,7 +504,7 @@ pub struct DiagnosticPopover { } impl DiagnosticPopover { - pub fn render(&self, style: &EditorStyle, cx: &mut ViewContext) -> AnyElement { + pub fn render(&self, style: &EditorStyle, cx: &mut ViewContext) -> AnyElement { todo!() // enum PrimaryDiagnostic {} diff --git a/crates/editor2/src/items.rs b/crates/editor2/src/items.rs index cf2bf5b6dc218eb94f33c9c290a62829114f6b8d..eca3b99d7807b455ba48ed99ef0287a9ee084abf 100644 --- a/crates/editor2/src/items.rs +++ b/crates/editor2/src/items.rs @@ -1,7 +1,7 @@ use crate::{ editor_settings::SeedQuerySetting, link_go_to_definition::hide_link_definition, movement::surrounding_word, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll, Editor, - EditorSettings, Event, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, + EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, NavigationData, ToPoint as _, }; use anyhow::{anyhow, Context, Result}; @@ -9,8 +9,8 @@ use collections::HashSet; use futures::future::try_join_all; use gpui::{ div, point, AnyElement, AppContext, AsyncAppContext, Entity, EntityId, EventEmitter, - FocusHandle, Model, ParentComponent, Pixels, SharedString, Styled, Subscription, Task, View, - ViewContext, VisualContext, WeakView, + FocusHandle, Model, ParentElement, Pixels, SharedString, Styled, Subscription, Task, View, + ViewContext, VisualContext, WeakView, WindowContext, }; use language::{ proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt, @@ -30,7 +30,7 @@ use std::{ }; use text::Selection; use theme::{ActiveTheme, Theme}; -use ui::{Label, TextColor}; +use ui::{Color, Label}; use util::{paths::PathExt, ResultExt, TryFutureExt}; use workspace::item::{BreadcrumbText, FollowEvent, FollowableEvents, FollowableItemHandle}; use workspace::{ @@ -41,11 +41,12 @@ use workspace::{ pub const MAX_TAB_TITLE_LEN: usize = 24; -impl FollowableEvents for Event { +impl FollowableEvents for EditorEvent { fn to_follow_event(&self) -> Option { match self { - Event::Edited => Some(FollowEvent::Unfollow), - Event::SelectionsChanged { local } | Event::ScrollPositionChanged { local, .. } => { + EditorEvent::Edited => Some(FollowEvent::Unfollow), + EditorEvent::SelectionsChanged { local } + | EditorEvent::ScrollPositionChanged { local, .. } => { if *local { Some(FollowEvent::Unfollow) } else { @@ -60,7 +61,7 @@ impl FollowableEvents for Event { impl EventEmitter for Editor {} impl FollowableItem for Editor { - type FollowableEvent = Event; + type FollowableEvent = EditorEvent; fn remote_id(&self) -> Option { self.remote_id } @@ -248,7 +249,7 @@ impl FollowableItem for Editor { match update { proto::update_view::Variant::Editor(update) => match event { - Event::ExcerptsAdded { + EditorEvent::ExcerptsAdded { buffer, predecessor, excerpts, @@ -269,20 +270,20 @@ impl FollowableItem for Editor { } true } - Event::ExcerptsRemoved { ids } => { + EditorEvent::ExcerptsRemoved { ids } => { update .deleted_excerpts .extend(ids.iter().map(ExcerptId::to_proto)); true } - Event::ScrollPositionChanged { .. } => { + EditorEvent::ScrollPositionChanged { .. } => { let scroll_anchor = self.scroll_manager.anchor(); update.scroll_top_anchor = Some(serialize_anchor(&scroll_anchor.anchor)); update.scroll_x = scroll_anchor.offset.x; update.scroll_y = scroll_anchor.offset.y; true } - Event::SelectionsChanged { .. } => { + EditorEvent::SelectionsChanged { .. } => { update.selections = self .selections .disjoint_anchors() @@ -583,7 +584,7 @@ impl Item for Editor { Some(path.to_string_lossy().to_string().into()) } - fn tab_content(&self, detail: Option, cx: &AppContext) -> AnyElement { + fn tab_content(&self, detail: Option, cx: &WindowContext) -> AnyElement { let theme = cx.theme(); AnyElement::new( @@ -603,7 +604,7 @@ impl Item for Editor { &description, MAX_TAB_TITLE_LEN, )) - .color(TextColor::Muted), + .color(Color::Muted), ), ) })), @@ -760,7 +761,7 @@ impl Item for Editor { } fn breadcrumb_location(&self) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft { flex: None } + ToolbarItemLocation::PrimaryLeft } fn breadcrumbs(&self, variant: &Theme, cx: &AppContext) -> Option> { @@ -906,17 +907,15 @@ impl SearchableItem for Editor { type Match = Range; fn clear_matches(&mut self, cx: &mut ViewContext) { - todo!() - // self.clear_background_highlights::(cx); + self.clear_background_highlights::(cx); } fn update_matches(&mut self, matches: Vec>, cx: &mut ViewContext) { - todo!() - // self.highlight_background::( - // matches, - // |theme| theme.search.match_background, - // cx, - // ); + self.highlight_background::( + matches, + |theme| theme.title_bar_background, // todo: update theme + cx, + ); } fn query_suggestion(&mut self, cx: &mut ViewContext) -> String { @@ -951,22 +950,20 @@ impl SearchableItem for Editor { matches: Vec>, cx: &mut ViewContext, ) { - todo!() - // self.unfold_ranges([matches[index].clone()], false, true, cx); - // let range = self.range_for_match(&matches[index]); - // self.change_selections(Some(Autoscroll::fit()), cx, |s| { - // s.select_ranges([range]); - // }) + self.unfold_ranges([matches[index].clone()], false, true, cx); + let range = self.range_for_match(&matches[index]); + self.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges([range]); + }) } fn select_matches(&mut self, matches: Vec, cx: &mut ViewContext) { - todo!() - // self.unfold_ranges(matches.clone(), false, false, cx); - // let mut ranges = Vec::new(); - // for m in &matches { - // ranges.push(self.range_for_match(&m)) - // } - // self.change_selections(None, cx, |s| s.select_ranges(ranges)); + self.unfold_ranges(matches.clone(), false, false, cx); + let mut ranges = Vec::new(); + for m in &matches { + ranges.push(self.range_for_match(&m)) + } + self.change_selections(None, cx, |s| s.select_ranges(ranges)); } fn replace( &mut self, diff --git a/crates/editor2/src/scroll.rs b/crates/editor2/src/scroll.rs index 360c1e3c36814d02dda6b20807796a3fa53385d9..d73f6a4d6aecd6517a2e9666a96bd09d7091b159 100644 --- a/crates/editor2/src/scroll.rs +++ b/crates/editor2/src/scroll.rs @@ -6,8 +6,8 @@ use crate::{ display_map::{DisplaySnapshot, ToDisplayPoint}, hover_popover::hide_hover, persistence::DB, - Anchor, DisplayPoint, Editor, EditorMode, Event, InlayHintRefreshReason, MultiBufferSnapshot, - ToPoint, + Anchor, DisplayPoint, Editor, EditorEvent, EditorMode, InlayHintRefreshReason, + MultiBufferSnapshot, ToPoint, }; use gpui::{point, px, AppContext, Entity, Pixels, Styled, Task, ViewContext}; use language::{Bias, Point}; @@ -224,7 +224,7 @@ impl ScrollManager { cx: &mut ViewContext, ) { self.anchor = anchor; - cx.emit(Event::ScrollPositionChanged { local, autoscroll }); + cx.emit(EditorEvent::ScrollPositionChanged { local, autoscroll }); self.show_scrollbar(cx); self.autoscroll_request.take(); if let Some(workspace_id) = workspace_id { diff --git a/crates/editor2/src/test/editor_test_context.rs b/crates/editor2/src/test/editor_test_context.rs index c865538b0c5cf3876ea1e955ac4d4eb64d6a2b67..1d497b483745dbb9c835c7e9959ebcc91ebe749d 100644 --- a/crates/editor2/src/test/editor_test_context.rs +++ b/crates/editor2/src/test/editor_test_context.rs @@ -71,7 +71,8 @@ impl<'a> EditorTestContext<'a> { &self, predicate: impl FnMut(&Editor, &AppContext) -> bool, ) -> impl Future { - self.editor.condition::(&self.cx, predicate) + self.editor + .condition::(&self.cx, predicate) } #[track_caller] diff --git a/crates/file_finder2/src/file_finder.rs b/crates/file_finder2/src/file_finder.rs index 0fee5102e6d0314d134848eb3abf5697d71003d5..0b9157bb4ffbcbca8a8d84ee57594ed553dbb342 100644 --- a/crates/file_finder2/src/file_finder.rs +++ b/crates/file_finder2/src/file_finder.rs @@ -2,9 +2,9 @@ use collections::HashMap; use editor::{scroll::autoscroll::Autoscroll, Bias, Editor}; use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; use gpui::{ - actions, div, AppContext, Component, Dismiss, Div, FocusHandle, InteractiveComponent, - ManagedView, Model, ParentComponent, Render, Styled, Task, View, ViewContext, VisualContext, - WeakView, + actions, div, AppContext, Div, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + Manager, Model, ParentElement, Render, RenderOnce, Styled, Task, View, ViewContext, + VisualContext, WeakView, }; use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; @@ -111,13 +111,14 @@ impl FileFinder { } } -impl ManagedView for FileFinder { +impl EventEmitter for FileFinder {} +impl FocusableView for FileFinder { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { self.picker.focus_handle(cx) } } impl Render for FileFinder { - type Element = Div; + type Element = Div; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { v_stack().w_96().child(self.picker.clone()) @@ -529,7 +530,7 @@ impl FileFinderDelegate { } impl PickerDelegate for FileFinderDelegate { - type ListItem = Div>; + type ListItem = Div; fn placeholder_text(&self) -> Arc { "Search project files...".into() @@ -688,7 +689,9 @@ impl PickerDelegate for FileFinderDelegate { .log_err(); } } - finder.update(&mut cx, |_, cx| cx.emit(Dismiss)).ok()?; + finder + .update(&mut cx, |_, cx| cx.emit(Manager::Dismiss)) + .ok()?; Some(()) }) @@ -699,7 +702,7 @@ impl PickerDelegate for FileFinderDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.file_finder - .update(cx, |_, cx| cx.emit(Dismiss)) + .update(cx, |_, cx| cx.emit(Manager::Dismiss)) .log_err(); } diff --git a/crates/go_to_line2/src/go_to_line.rs b/crates/go_to_line2/src/go_to_line.rs index 565afb5e939f01225341ae84e1628ead5daf5cbd..61f5742750a08ea35ffec2592c526e47f8b0a377 100644 --- a/crates/go_to_line2/src/go_to_line.rs +++ b/crates/go_to_line2/src/go_to_line.rs @@ -1,11 +1,11 @@ use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Editor}; use gpui::{ - actions, div, prelude::*, AppContext, Dismiss, Div, FocusHandle, ManagedView, ParentComponent, + actions, div, prelude::*, AppContext, Div, EventEmitter, FocusHandle, FocusableView, Manager, Render, SharedString, Styled, Subscription, View, ViewContext, VisualContext, WindowContext, }; use text::{Bias, Point}; use theme::ActiveTheme; -use ui::{h_stack, v_stack, Label, StyledExt, TextColor}; +use ui::{h_stack, v_stack, Color, Label, StyledExt}; use util::paths::FILE_ROW_COLUMN_DELIMITER; use workspace::Workspace; @@ -23,11 +23,12 @@ pub struct GoToLine { _subscriptions: Vec, } -impl ManagedView for GoToLine { +impl FocusableView for GoToLine { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.line_editor.focus_handle(cx) + self.active_editor.focus_handle(cx) } } +impl EventEmitter for GoToLine {} impl GoToLine { fn register(workspace: &mut Workspace, _: &mut ViewContext) { @@ -82,13 +83,13 @@ impl GoToLine { fn on_line_editor_event( &mut self, _: View, - event: &editor::Event, + event: &editor::EditorEvent, cx: &mut ViewContext, ) { match event { // todo!() this isn't working... - editor::Event::Blurred => cx.emit(Dismiss), - editor::Event::BufferEdited { .. } => self.highlight_current_line(cx), + editor::EditorEvent::Blurred => cx.emit(Manager::Dismiss), + editor::EditorEvent::BufferEdited { .. } => self.highlight_current_line(cx), _ => {} } } @@ -122,7 +123,7 @@ impl GoToLine { } fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - cx.emit(Dismiss); + cx.emit(Manager::Dismiss); } fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { @@ -139,19 +140,19 @@ impl GoToLine { self.prev_scroll_position.take(); } - cx.emit(Dismiss); + cx.emit(Manager::Dismiss); } } impl Render for GoToLine { - type Element = Div; + type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { div() .elevation_2(cx) .key_context("GoToLine") - .on_action(Self::cancel) - .on_action(Self::confirm) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) .w_96() .child( v_stack() @@ -175,7 +176,7 @@ impl Render for GoToLine { .justify_between() .px_2() .py_1() - .child(Label::new(self.current_text.clone()).color(TextColor::Muted)), + .child(Label::new(self.current_text.clone()).color(Color::Muted)), ), ) } diff --git a/crates/gpui2/Cargo.toml b/crates/gpui2/Cargo.toml index 1bec9d43dc34f8ebcd13b65ec962e67b16bf363c..afb5d3ea0ce7080830bbcb1eb86aad62b8f7432c 100644 --- a/crates/gpui2/Cargo.toml +++ b/crates/gpui2/Cargo.toml @@ -47,7 +47,7 @@ serde_derive.workspace = true serde_json.workspace = true smallvec.workspace = true smol.workspace = true -taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "4fb530bdd71609bb1d3f76c6a8bde1ba82805d5e" } +taffy = { git = "https://github.com/DioxusLabs/taffy", rev = "1876f72bee5e376023eaa518aa7b8a34c769bd1b" } thiserror.workspace = true time.workspace = true tiny-skia = "0.5" diff --git a/crates/gpui2/src/app.rs b/crates/gpui2/src/app.rs index b5083b97c2432d0d3d025b08b5b09cebbf21779a..f64512970656909cf3f1a003a9b04acd3cc58eda 100644 --- a/crates/gpui2/src/app.rs +++ b/crates/gpui2/src/app.rs @@ -14,7 +14,7 @@ use smallvec::SmallVec; pub use test_context::*; use crate::{ - current_platform, image_cache::ImageCache, Action, ActionRegistry, AnyBox, AnyView, + current_platform, image_cache::ImageCache, Action, ActionRegistry, Any, AnyView, AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context, DispatchPhase, DisplayId, Entity, EventEmitter, FocusEvent, FocusHandle, FocusId, ForegroundExecutor, KeyBinding, Keymap, LayoutId, PathPromptOptions, Pixels, Platform, @@ -28,7 +28,7 @@ use futures::{channel::oneshot, future::LocalBoxFuture, Future}; use parking_lot::Mutex; use slotmap::SlotMap; use std::{ - any::{type_name, Any, TypeId}, + any::{type_name, TypeId}, cell::{Ref, RefCell, RefMut}, marker::PhantomData, mem, @@ -194,7 +194,7 @@ pub struct AppContext { asset_source: Arc, pub(crate) image_cache: ImageCache, pub(crate) text_style_stack: Vec, - pub(crate) globals_by_type: HashMap, + pub(crate) globals_by_type: HashMap>, pub(crate) entities: EntityMap, pub(crate) new_view_observers: SubscriberSet, pub(crate) windows: SlotMap>, @@ -424,7 +424,7 @@ impl AppContext { /// Opens a new window with the given option and the root view returned by the given function. /// The function is invoked with a `WindowContext`, which can be used to interact with window-specific /// functionality. - pub fn open_window( + pub fn open_window( &mut self, options: crate::WindowOptions, build_root_view: impl FnOnce(&mut WindowContext) -> View, @@ -492,6 +492,10 @@ impl AppContext { self.platform.open_url(url); } + pub fn app_path(&self) -> Result { + self.platform.app_path() + } + pub fn path_for_auxiliary_executable(&self, name: &str) -> Result { self.platform.path_for_auxiliary_executable(name) } @@ -1100,12 +1104,12 @@ pub(crate) enum Effect { /// Wraps a global variable value during `update_global` while the value has been moved to the stack. pub(crate) struct GlobalLease { - global: AnyBox, + global: Box, global_type: PhantomData, } impl GlobalLease { - fn new(global: AnyBox) -> Self { + fn new(global: Box) -> Self { GlobalLease { global, global_type: PhantomData, diff --git a/crates/gpui2/src/app/async_context.rs b/crates/gpui2/src/app/async_context.rs index 83b3ccebe7dc0ebb169a0647fcdc2602ad4ea107..cc3b0ace57b37d639656ef06fbc5a2c2344b7877 100644 --- a/crates/gpui2/src/app/async_context.rs +++ b/crates/gpui2/src/app/async_context.rs @@ -1,6 +1,6 @@ use crate::{ AnyView, AnyWindowHandle, AppCell, AppContext, BackgroundExecutor, Context, FocusableView, - ForegroundExecutor, Model, ModelContext, Render, Result, Task, View, ViewContext, + ForegroundExecutor, Manager, Model, ModelContext, Render, Result, Task, View, ViewContext, VisualContext, WindowContext, WindowHandle, }; use anyhow::{anyhow, Context as _}; @@ -115,7 +115,7 @@ impl AsyncAppContext { build_root_view: impl FnOnce(&mut WindowContext) -> View, ) -> Result> where - V: Render, + V: 'static + Render, { let app = self .app @@ -306,7 +306,7 @@ impl VisualContext for AsyncWindowContext { build_view: impl FnOnce(&mut ViewContext<'_, V>) -> V, ) -> Self::Result> where - V: Render, + V: 'static + Render, { self.window .update(self, |_, cx| cx.replace_root_view(build_view)) @@ -320,4 +320,13 @@ impl VisualContext for AsyncWindowContext { view.read(cx).focus_handle(cx).clone().focus(cx); }) } + + fn dismiss_view(&mut self, view: &View) -> Self::Result<()> + where + V: crate::ManagedView, + { + self.window.update(self, |_, cx| { + view.update(cx, |_, cx| cx.emit(Manager::Dismiss)) + }) + } } diff --git a/crates/gpui2/src/app/entity_map.rs b/crates/gpui2/src/app/entity_map.rs index 4a3cca040b7a9982101e8bdf10816d7f15a1bff2..a34582f4f4024c5eeab0363d45896be7eaa2ee95 100644 --- a/crates/gpui2/src/app/entity_map.rs +++ b/crates/gpui2/src/app/entity_map.rs @@ -1,10 +1,10 @@ -use crate::{private::Sealed, AnyBox, AppContext, Context, Entity, ModelContext}; +use crate::{private::Sealed, AppContext, Context, Entity, ModelContext}; use anyhow::{anyhow, Result}; use derive_more::{Deref, DerefMut}; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; use slotmap::{SecondaryMap, SlotMap}; use std::{ - any::{type_name, TypeId}, + any::{type_name, Any, TypeId}, fmt::{self, Display}, hash::{Hash, Hasher}, marker::PhantomData, @@ -31,7 +31,7 @@ impl Display for EntityId { } pub(crate) struct EntityMap { - entities: SecondaryMap, + entities: SecondaryMap>, ref_counts: Arc>, } @@ -71,11 +71,12 @@ impl EntityMap { #[track_caller] pub fn lease<'a, T>(&mut self, model: &'a Model) -> Lease<'a, T> { self.assert_valid_context(model); - let entity = Some( - self.entities - .remove(model.entity_id) - .expect("Circular entity lease. Is the entity already being updated?"), - ); + let entity = Some(self.entities.remove(model.entity_id).unwrap_or_else(|| { + panic!( + "Circular entity lease of {}. Is it already being updated?", + std::any::type_name::() + ) + })); Lease { model, entity, @@ -101,7 +102,7 @@ impl EntityMap { ); } - pub fn take_dropped(&mut self) -> Vec<(EntityId, AnyBox)> { + pub fn take_dropped(&mut self) -> Vec<(EntityId, Box)> { let mut ref_counts = self.ref_counts.write(); let dropped_entity_ids = mem::take(&mut ref_counts.dropped_entity_ids); @@ -121,7 +122,7 @@ impl EntityMap { } pub struct Lease<'a, T> { - entity: Option, + entity: Option>, pub model: &'a Model, entity_type: PhantomData, } diff --git a/crates/gpui2/src/app/test_context.rs b/crates/gpui2/src/app/test_context.rs index 940492573f0ed504defc711f559375fc3686c0ce..2bd3a069caa28c22deb84c156ee590c14029d67d 100644 --- a/crates/gpui2/src/app/test_context.rs +++ b/crates/gpui2/src/app/test_context.rs @@ -1,8 +1,9 @@ use crate::{ div, Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, - BackgroundExecutor, Context, Div, EventEmitter, ForegroundExecutor, InputEvent, KeyDownEvent, - Keystroke, Model, ModelContext, Render, Result, Task, TestDispatcher, TestPlatform, TestWindow, - View, ViewContext, VisualContext, WindowContext, WindowHandle, WindowOptions, + BackgroundExecutor, Context, Div, Entity, EventEmitter, ForegroundExecutor, InputEvent, + KeyDownEvent, Keystroke, Model, ModelContext, Render, Result, Task, TestDispatcher, + TestPlatform, TestWindow, View, ViewContext, VisualContext, WindowContext, WindowHandle, + WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt}; @@ -126,7 +127,7 @@ impl TestAppContext { pub fn add_window(&mut self, build_window: F) -> WindowHandle where F: FnOnce(&mut ViewContext) -> V, - V: Render, + V: 'static + Render, { let mut cx = self.app.borrow_mut(); cx.open_window(WindowOptions::default(), |cx| cx.build_view(build_window)) @@ -143,7 +144,7 @@ impl TestAppContext { pub fn add_window_view(&mut self, build_window: F) -> (View, &mut VisualTestContext) where F: FnOnce(&mut ViewContext) -> V, - V: Render, + V: 'static + Render, { let mut cx = self.app.borrow_mut(); let window = cx.open_window(WindowOptions::default(), |cx| cx.build_view(build_window)); @@ -296,21 +297,19 @@ impl TestAppContext { .unwrap() } - pub fn notifications(&mut self, entity: &Model) -> impl Stream { + pub fn notifications(&mut self, entity: &impl Entity) -> impl Stream { let (tx, rx) = futures::channel::mpsc::unbounded(); - - entity.update(self, move |_, cx: &mut ModelContext| { + self.update(|cx| { cx.observe(entity, { let tx = tx.clone(); - move |_, _, _| { + move |_, _| { let _ = tx.unbounded_send(()); } }) .detach(); - - cx.on_release(move |_, _| tx.close_channel()).detach(); + cx.observe_release(entity, move |_, _| tx.close_channel()) + .detach() }); - rx } @@ -386,6 +385,32 @@ impl Model { } } +impl View { + pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + use postage::prelude::{Sink as _, Stream as _}; + + let (mut tx, mut rx) = postage::mpsc::channel(1); + let mut cx = cx.app.app.borrow_mut(); + let subscription = cx.observe(self, move |_, _| { + tx.try_send(()).ok(); + }); + + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + async move { + let notification = crate::util::timeout(duration, rx.recv()) + .await + .expect("next notification timed out"); + drop(subscription); + notification.expect("model dropped while test was waiting for its next notification") + } + } +} + impl View { pub fn condition( &self, @@ -565,7 +590,7 @@ impl<'a> VisualContext for VisualTestContext<'a> { build_view: impl FnOnce(&mut ViewContext<'_, V>) -> V, ) -> Self::Result> where - V: Render, + V: 'static + Render, { self.window .update(self.cx, |_, cx| cx.replace_root_view(build_view)) @@ -579,6 +604,17 @@ impl<'a> VisualContext for VisualTestContext<'a> { }) .unwrap() } + + fn dismiss_view(&mut self, view: &View) -> Self::Result<()> + where + V: crate::ManagedView, + { + self.window + .update(self.cx, |_, cx| { + view.update(cx, |_, cx| cx.emit(crate::Manager::Dismiss)) + }) + .unwrap() + } } impl AnyWindowHandle { @@ -594,7 +630,7 @@ impl AnyWindowHandle { pub struct EmptyView {} impl Render for EmptyView { - type Element = Div; + type Element = Div; fn render(&mut self, _cx: &mut crate::ViewContext) -> Self::Element { div() diff --git a/crates/gpui2/src/element.rs b/crates/gpui2/src/element.rs index b4b1af630e981a2170c9fa6b1cdd5a6857ff2349..5cd015503d2ccb2e9792d0e797b19c12b534b9fc 100644 --- a/crates/gpui2/src/element.rs +++ b/crates/gpui2/src/element.rs @@ -1,231 +1,331 @@ use crate::{ AvailableSpace, BorrowWindow, Bounds, ElementId, LayoutId, Pixels, Point, Size, ViewContext, + WindowContext, }; use derive_more::{Deref, DerefMut}; pub(crate) use smallvec::SmallVec; -use std::{any::Any, fmt::Debug, mem}; +use std::{any::Any, fmt::Debug}; -pub trait Element { - type ElementState: 'static; +pub trait Render: 'static + Sized { + type Element: Element + 'static; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element; +} + +pub trait RenderOnce: Sized { + type Element: Element + 'static; fn element_id(&self) -> Option; - fn layout( - &mut self, - view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState); + fn render_once(self) -> Self::Element; - fn paint( - &mut self, - bounds: Bounds, - view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, - ); + fn render_into_any(self) -> AnyElement { + self.render_once().into_any() + } fn draw( self, origin: Point, available_space: Size, - view_state: &mut V, - cx: &mut ViewContext, - f: impl FnOnce(&Self::ElementState, &mut ViewContext) -> R, + cx: &mut WindowContext, + f: impl FnOnce(&mut ::State, &mut WindowContext) -> R, ) -> R where - Self: Sized, T: Clone + Default + Debug + Into, { - let mut element = RenderedElement { - element: self, - phase: ElementRenderPhase::Start, + let element = self.render_once(); + let element_id = element.element_id(); + let element = DrawableElement { + element: Some(element), + phase: ElementDrawPhase::Start, }; - element.draw(origin, available_space.map(Into::into), view_state, cx); - if let ElementRenderPhase::Painted { frame_state } = &element.phase { - if let Some(frame_state) = frame_state.as_ref() { - f(&frame_state, cx) + + let frame_state = + DrawableElement::draw(element, origin, available_space.map(Into::into), cx); + + if let Some(mut frame_state) = frame_state { + f(&mut frame_state, cx) + } else { + cx.with_element_state(element_id.unwrap(), |element_state, cx| { + let mut element_state = element_state.unwrap(); + let result = f(&mut element_state, cx); + (result, element_state) + }) + } + } + + fn map(self, f: impl FnOnce(Self) -> U) -> U + where + Self: Sized, + U: RenderOnce, + { + f(self) + } + + fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| if condition { then(this) } else { this }) + } + + fn when_some(self, option: Option, then: impl FnOnce(Self, T) -> Self) -> Self + where + Self: Sized, + { + self.map(|this| { + if let Some(value) = option { + then(this, value) } else { - let element_id = element - .element - .element_id() - .expect("we either have some frame_state or some element_id"); - cx.with_element_state(element_id, |element_state, cx| { - let element_state = element_state.unwrap(); - let result = f(&element_state, cx); - (result, element_state) - }) + this } - } else { - unreachable!() + }) + } +} + +pub trait Element: 'static + RenderOnce { + type State: 'static; + + fn layout( + &mut self, + state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State); + + fn paint(self, bounds: Bounds, state: &mut Self::State, cx: &mut WindowContext); + + fn into_any(self) -> AnyElement { + AnyElement::new(self) + } +} + +pub trait Component: 'static { + type Rendered: RenderOnce; + + fn render(self, cx: &mut WindowContext) -> Self::Rendered; +} + +pub struct CompositeElement { + component: Option, +} + +pub struct CompositeElementState { + rendered_element: Option<::Element>, + rendered_element_state: <::Element as Element>::State, +} + +impl CompositeElement { + pub fn new(component: C) -> Self { + CompositeElement { + component: Some(component), } } } +impl Element for CompositeElement { + type State = CompositeElementState; + + fn layout( + &mut self, + state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + let mut element = self.component.take().unwrap().render(cx).render_once(); + let (layout_id, state) = element.layout(state.map(|s| s.rendered_element_state), cx); + let state = CompositeElementState { + rendered_element: Some(element), + rendered_element_state: state, + }; + (layout_id, state) + } + + fn paint(self, bounds: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + state + .rendered_element + .take() + .unwrap() + .paint(bounds, &mut state.rendered_element_state, cx); + } +} + +impl RenderOnce for CompositeElement { + type Element = Self; + + fn element_id(&self) -> Option { + None + } + + fn render_once(self) -> Self::Element { + self + } +} + #[derive(Deref, DerefMut, Default, Clone, Debug, Eq, PartialEq, Hash)] pub struct GlobalElementId(SmallVec<[ElementId; 32]>); -pub trait ParentComponent { - fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]>; +pub trait ParentElement { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]>; - fn child(mut self, child: impl Component) -> Self + fn child(mut self, child: impl RenderOnce) -> Self where Self: Sized, { - self.children_mut().push(child.render()); + self.children_mut().push(child.render_once().into_any()); self } - fn children(mut self, iter: impl IntoIterator>) -> Self + fn children(mut self, children: impl IntoIterator) -> Self where Self: Sized, { - self.children_mut() - .extend(iter.into_iter().map(|item| item.render())); + self.children_mut().extend( + children + .into_iter() + .map(|child| child.render_once().into_any()), + ); self } } -trait ElementObject { - fn layout(&mut self, view_state: &mut V, cx: &mut ViewContext) -> LayoutId; - fn paint(&mut self, view_state: &mut V, cx: &mut ViewContext); +trait ElementObject { + fn element_id(&self) -> Option; + + fn layout(&mut self, cx: &mut WindowContext) -> LayoutId; + + fn paint(&mut self, cx: &mut WindowContext); + fn measure( &mut self, available_space: Size, - view_state: &mut V, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Size; + fn draw( &mut self, origin: Point, available_space: Size, - view_state: &mut V, - cx: &mut ViewContext, + cx: &mut WindowContext, ); } -struct RenderedElement> { - element: E, - phase: ElementRenderPhase, +pub struct DrawableElement { + element: Option, + phase: ElementDrawPhase, } #[derive(Default)] -enum ElementRenderPhase { +enum ElementDrawPhase { #[default] Start, LayoutRequested { layout_id: LayoutId, - frame_state: Option, + frame_state: Option, }, LayoutComputed { layout_id: LayoutId, available_space: Size, - frame_state: Option, - }, - Painted { - frame_state: Option, + frame_state: Option, }, } -/// Internal struct that wraps an element to store Layout and ElementState after the element is rendered. -/// It's allocated as a trait object to erase the element type and wrapped in AnyElement for -/// improved usability. -impl> RenderedElement { +/// A wrapper around an implementer of [Element] that allows it to be drawn in a window. +impl DrawableElement { fn new(element: E) -> Self { - RenderedElement { - element, - phase: ElementRenderPhase::Start, + DrawableElement { + element: Some(element), + phase: ElementDrawPhase::Start, } } -} -impl ElementObject for RenderedElement -where - E: Element, - E::ElementState: 'static, -{ - fn layout(&mut self, state: &mut V, cx: &mut ViewContext) -> LayoutId { - let (layout_id, frame_state) = match mem::take(&mut self.phase) { - ElementRenderPhase::Start => { - if let Some(id) = self.element.element_id() { - let layout_id = cx.with_element_state(id, |element_state, cx| { - self.element.layout(state, element_state, cx) - }); - (layout_id, None) - } else { - let (layout_id, frame_state) = self.element.layout(state, None, cx); - (layout_id, Some(frame_state)) - } - } - ElementRenderPhase::LayoutRequested { .. } - | ElementRenderPhase::LayoutComputed { .. } - | ElementRenderPhase::Painted { .. } => { - panic!("element rendered twice") - } + fn element_id(&self) -> Option { + self.element.as_ref()?.element_id() + } + + fn layout(&mut self, cx: &mut WindowContext) -> LayoutId { + let (layout_id, frame_state) = if let Some(id) = self.element.as_ref().unwrap().element_id() + { + let layout_id = cx.with_element_state(id, |element_state, cx| { + self.element.as_mut().unwrap().layout(element_state, cx) + }); + (layout_id, None) + } else { + let (layout_id, frame_state) = self.element.as_mut().unwrap().layout(None, cx); + (layout_id, Some(frame_state)) }; - self.phase = ElementRenderPhase::LayoutRequested { + self.phase = ElementDrawPhase::LayoutRequested { layout_id, frame_state, }; layout_id } - fn paint(&mut self, view_state: &mut V, cx: &mut ViewContext) { - self.phase = match mem::take(&mut self.phase) { - ElementRenderPhase::LayoutRequested { + fn paint(mut self, cx: &mut WindowContext) -> Option { + match self.phase { + ElementDrawPhase::LayoutRequested { layout_id, - mut frame_state, + frame_state, } - | ElementRenderPhase::LayoutComputed { + | ElementDrawPhase::LayoutComputed { layout_id, - mut frame_state, + frame_state, .. } => { let bounds = cx.layout_bounds(layout_id); - if let Some(id) = self.element.element_id() { - cx.with_element_state(id, |element_state, cx| { + + if let Some(mut frame_state) = frame_state { + self.element + .take() + .unwrap() + .paint(bounds, &mut frame_state, cx); + Some(frame_state) + } else { + let element_id = self + .element + .as_ref() + .unwrap() + .element_id() + .expect("if we don't have frame state, we should have element state"); + cx.with_element_state(element_id, |element_state, cx| { let mut element_state = element_state.unwrap(); self.element - .paint(bounds, view_state, &mut element_state, cx); + .take() + .unwrap() + .paint(bounds, &mut element_state, cx); ((), element_state) }); - } else { - self.element - .paint(bounds, view_state, frame_state.as_mut().unwrap(), cx); + None } - ElementRenderPhase::Painted { frame_state } } _ => panic!("must call layout before paint"), - }; + } } fn measure( &mut self, available_space: Size, - view_state: &mut V, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Size { - if matches!(&self.phase, ElementRenderPhase::Start) { - self.layout(view_state, cx); + if matches!(&self.phase, ElementDrawPhase::Start) { + self.layout(cx); } let layout_id = match &mut self.phase { - ElementRenderPhase::LayoutRequested { + ElementDrawPhase::LayoutRequested { layout_id, frame_state, } => { cx.compute_layout(*layout_id, available_space); let layout_id = *layout_id; - self.phase = ElementRenderPhase::LayoutComputed { + self.phase = ElementDrawPhase::LayoutComputed { layout_id, available_space, frame_state: frame_state.take(), }; layout_id } - ElementRenderPhase::LayoutComputed { + ElementDrawPhase::LayoutComputed { layout_id, available_space: prev_available_space, .. @@ -243,150 +343,203 @@ where } fn draw( - &mut self, + mut self, origin: Point, available_space: Size, - view_state: &mut V, - cx: &mut ViewContext, - ) { - self.measure(available_space, view_state, cx); - cx.with_absolute_element_offset(origin, |cx| self.paint(view_state, cx)) + cx: &mut WindowContext, + ) -> Option { + self.measure(available_space, cx); + cx.with_absolute_element_offset(origin, |cx| self.paint(cx)) } } -pub struct AnyElement(Box>); +// impl Element for DrawableElement { +// type State = ::State; -impl AnyElement { - pub fn new(element: E) -> Self - where - V: 'static, - E: 'static + Element, - E::ElementState: Any, - { - AnyElement(Box::new(RenderedElement::new(element))) +// fn layout( +// &mut self, +// element_state: Option, +// cx: &mut WindowContext, +// ) -> (LayoutId, Self::State) { + +// } + +// fn paint( +// self, +// bounds: Bounds, +// element_state: &mut Self::State, +// cx: &mut WindowContext, +// ) { +// todo!() +// } +// } + +// impl RenderOnce for DrawableElement { +// type Element = Self; + +// fn element_id(&self) -> Option { +// self.element.as_ref()?.element_id() +// } + +// fn render_once(self) -> Self::Element { +// self +// } +// } + +impl ElementObject for Option> +where + E: Element, + E::State: 'static, +{ + fn element_id(&self) -> Option { + self.as_ref().unwrap().element_id() } - pub fn layout(&mut self, view_state: &mut V, cx: &mut ViewContext) -> LayoutId { - self.0.layout(view_state, cx) + fn layout(&mut self, cx: &mut WindowContext) -> LayoutId { + DrawableElement::layout(self.as_mut().unwrap(), cx) } - pub fn paint(&mut self, view_state: &mut V, cx: &mut ViewContext) { - self.0.paint(view_state, cx) + fn paint(&mut self, cx: &mut WindowContext) { + DrawableElement::paint(self.take().unwrap(), cx); } - /// Initializes this element and performs layout within the given available space to determine its size. - pub fn measure( + fn measure( &mut self, available_space: Size, - view_state: &mut V, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Size { - self.0.measure(available_space, view_state, cx) + DrawableElement::measure(self.as_mut().unwrap(), available_space, cx) } - /// Initializes this element and performs layout in the available space, then paints it at the given origin. - pub fn draw( + fn draw( &mut self, origin: Point, available_space: Size, - view_state: &mut V, - cx: &mut ViewContext, + cx: &mut WindowContext, ) { - self.0.draw(origin, available_space, view_state, cx) + DrawableElement::draw(self.take().unwrap(), origin, available_space, cx); } } -pub trait Component { - fn render(self) -> AnyElement; +pub struct AnyElement(Box); - fn map(self, f: impl FnOnce(Self) -> U) -> U +impl AnyElement { + pub fn new(element: E) -> Self where - Self: Sized, - U: Component, + E: 'static + Element, + E::State: Any, { - f(self) + AnyElement(Box::new(Some(DrawableElement::new(element))) as Box) } - fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self - where - Self: Sized, - { - self.map(|this| if condition { then(this) } else { this }) + pub fn element_id(&self) -> Option { + self.0.element_id() } - fn when_some(self, option: Option, then: impl FnOnce(Self, T) -> Self) -> Self - where - Self: Sized, - { - self.map(|this| { - if let Some(value) = option { - then(this, value) - } else { - this - } - }) + pub fn layout(&mut self, cx: &mut WindowContext) -> LayoutId { + self.0.layout(cx) } -} -impl Component for AnyElement { - fn render(self) -> AnyElement { - self + pub fn paint(mut self, cx: &mut WindowContext) { + self.0.paint(cx) } -} -impl Element for Option -where - V: 'static, - E: 'static + Component, - F: FnOnce(&mut V, &mut ViewContext<'_, V>) -> E + 'static, -{ - type ElementState = AnyElement; + /// Initializes this element and performs layout within the given available space to determine its size. + pub fn measure( + &mut self, + available_space: Size, + cx: &mut WindowContext, + ) -> Size { + self.0.measure(available_space, cx) + } - fn element_id(&self) -> Option { - None + /// Initializes this element and performs layout in the available space, then paints it at the given origin. + pub fn draw( + mut self, + origin: Point, + available_space: Size, + cx: &mut WindowContext, + ) { + self.0.draw(origin, available_space, cx) } + /// Converts this `AnyElement` into a trait object that can be stored and manipulated. + pub fn into_any(self) -> AnyElement { + AnyElement::new(self) + } +} + +impl Element for AnyElement { + type State = (); + fn layout( &mut self, - view_state: &mut V, - _: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { - let render = self.take().unwrap(); - let mut rendered_element = (render)(view_state, cx).render(); - let layout_id = rendered_element.layout(view_state, cx); - (layout_id, rendered_element) + _: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + let layout_id = self.layout(cx); + (layout_id, ()) } - fn paint( - &mut self, - _bounds: Bounds, - view_state: &mut V, - rendered_element: &mut Self::ElementState, - cx: &mut ViewContext, - ) { - rendered_element.paint(view_state, cx) + fn paint(self, _: Bounds, _: &mut Self::State, cx: &mut WindowContext) { + self.paint(cx); } } -impl Component for Option -where - V: 'static, - E: 'static + Component, - F: FnOnce(&mut V, &mut ViewContext<'_, V>) -> E + 'static, -{ - fn render(self) -> AnyElement { - AnyElement::new(self) +impl RenderOnce for AnyElement { + type Element = Self; + + fn element_id(&self) -> Option { + AnyElement::element_id(self) } -} -impl Component for F -where - V: 'static, - E: 'static + Component, - F: FnOnce(&mut V, &mut ViewContext<'_, V>) -> E + 'static, -{ - fn render(self) -> AnyElement { - AnyElement::new(Some(self)) + fn render_once(self) -> Self::Element { + self } } + +// impl Element for Option +// where +// V: 'static, +// E: Element, +// F: FnOnce(&mut V, &mut WindowContext<'_, V>) -> E + 'static, +// { +// type State = Option; + +// fn element_id(&self) -> Option { +// None +// } + +// fn layout( +// &mut self, +// _: Option, +// cx: &mut WindowContext, +// ) -> (LayoutId, Self::State) { +// let render = self.take().unwrap(); +// let mut element = (render)(view_state, cx).into_any(); +// let layout_id = element.layout(view_state, cx); +// (layout_id, Some(element)) +// } + +// fn paint( +// self, +// _bounds: Bounds, +// rendered_element: &mut Self::State, +// cx: &mut WindowContext, +// ) { +// rendered_element.take().unwrap().paint(view_state, cx); +// } +// } + +// impl RenderOnce for Option +// where +// V: 'static, +// E: Element, +// F: FnOnce(&mut V, &mut WindowContext) -> E + 'static, +// { +// type Element = Self; + +// fn render(self) -> Self::Element { +// self +// } +// } diff --git a/crates/gpui2/src/elements/div.rs b/crates/gpui2/src/elements/div.rs index f9560f2c53188a17b6c336e9bb284ee3ec58b07f..630b368b9518aa8c8b46708f4f65e4c45bf01928 100644 --- a/crates/gpui2/src/elements/div.rs +++ b/crates/gpui2/src/elements/div.rs @@ -1,9 +1,9 @@ use crate::{ point, px, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, AppContext, BorrowAppContext, - BorrowWindow, Bounds, ClickEvent, Component, DispatchPhase, Element, ElementId, FocusEvent, - FocusHandle, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, ParentComponent, Pixels, Point, Render, ScrollWheelEvent, - SharedString, Size, Style, StyleRefinement, Styled, Task, View, ViewContext, Visibility, + BorrowWindow, Bounds, ClickEvent, DispatchPhase, Element, ElementId, FocusEvent, FocusHandle, + KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, ParentElement, Pixels, Point, Render, RenderOnce, ScrollWheelEvent, SharedString, + Size, Style, StyleRefinement, Styled, Task, View, Visibility, WindowContext, }; use collections::HashMap; use refineable::Refineable; @@ -12,7 +12,6 @@ use std::{ any::{Any, TypeId}, cell::RefCell, fmt::Debug, - marker::PhantomData, mem, rc::Rc, time::Duration, @@ -28,30 +27,24 @@ pub struct GroupStyle { pub style: StyleRefinement, } -pub trait InteractiveComponent: Sized + Element { - fn interactivity(&mut self) -> &mut Interactivity; +pub trait InteractiveElement: Sized + Element { + fn interactivity(&mut self) -> &mut Interactivity; fn group(mut self, group: impl Into) -> Self { self.interactivity().group = Some(group.into()); self } - fn id(mut self, id: impl Into) -> Stateful { + fn id(mut self, id: impl Into) -> Stateful { self.interactivity().element_id = Some(id.into()); - Stateful { - element: self, - view_type: PhantomData, - } + Stateful { element: self } } - fn track_focus(mut self, focus_handle: &FocusHandle) -> Focusable { + fn track_focus(mut self, focus_handle: &FocusHandle) -> Focusable { self.interactivity().focusable = true; self.interactivity().tracked_focus_handle = Some(focus_handle.clone()); - Focusable { - element: self, - view_type: PhantomData, - } + Focusable { element: self } } fn key_context(mut self, key_context: C) -> Self @@ -85,15 +78,15 @@ pub trait InteractiveComponent: Sized + Element { fn on_mouse_down( mut self, button: MouseButton, - handler: impl Fn(&mut V, &MouseDownEvent, &mut ViewContext) + 'static, + listener: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static, ) -> Self { self.interactivity().mouse_down_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + move |event, bounds, phase, cx| { if phase == DispatchPhase::Bubble && event.button == button && bounds.contains_point(&event.position) { - handler(view, event, cx) + (listener)(event, cx) } }, )); @@ -102,12 +95,12 @@ pub trait InteractiveComponent: Sized + Element { fn on_any_mouse_down( mut self, - handler: impl Fn(&mut V, &MouseDownEvent, &mut ViewContext) + 'static, + listener: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static, ) -> Self { self.interactivity().mouse_down_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + move |event, bounds, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { - handler(view, event, cx) + (listener)(event, cx) } }, )); @@ -117,43 +110,43 @@ pub trait InteractiveComponent: Sized + Element { fn on_mouse_up( mut self, button: MouseButton, - handler: impl Fn(&mut V, &MouseUpEvent, &mut ViewContext) + 'static, + listener: impl Fn(&MouseUpEvent, &mut WindowContext) + 'static, ) -> Self { - self.interactivity().mouse_up_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + self.interactivity() + .mouse_up_listeners + .push(Box::new(move |event, bounds, phase, cx| { if phase == DispatchPhase::Bubble && event.button == button && bounds.contains_point(&event.position) { - handler(view, event, cx) + (listener)(event, cx) } - }, - )); + })); self } fn on_any_mouse_up( mut self, - handler: impl Fn(&mut V, &MouseUpEvent, &mut ViewContext) + 'static, + listener: impl Fn(&MouseUpEvent, &mut WindowContext) + 'static, ) -> Self { - self.interactivity().mouse_up_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + self.interactivity() + .mouse_up_listeners + .push(Box::new(move |event, bounds, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { - handler(view, event, cx) + (listener)(event, cx) } - }, - )); + })); self } fn on_mouse_down_out( mut self, - handler: impl Fn(&mut V, &MouseDownEvent, &mut ViewContext) + 'static, + listener: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static, ) -> Self { self.interactivity().mouse_down_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + move |event, bounds, phase, cx| { if phase == DispatchPhase::Capture && !bounds.contains_point(&event.position) { - handler(view, event, cx) + (listener)(event, cx) } }, )); @@ -163,29 +156,29 @@ pub trait InteractiveComponent: Sized + Element { fn on_mouse_up_out( mut self, button: MouseButton, - handler: impl Fn(&mut V, &MouseUpEvent, &mut ViewContext) + 'static, + listener: impl Fn(&MouseUpEvent, &mut WindowContext) + 'static, ) -> Self { - self.interactivity().mouse_up_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + self.interactivity() + .mouse_up_listeners + .push(Box::new(move |event, bounds, phase, cx| { if phase == DispatchPhase::Capture && event.button == button && !bounds.contains_point(&event.position) { - handler(view, event, cx); + (listener)(event, cx); } - }, - )); + })); self } fn on_mouse_move( mut self, - handler: impl Fn(&mut V, &MouseMoveEvent, &mut ViewContext) + 'static, + listener: impl Fn(&MouseMoveEvent, &mut WindowContext) + 'static, ) -> Self { self.interactivity().mouse_move_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + move |event, bounds, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { - handler(view, event, cx); + (listener)(event, cx); } }, )); @@ -194,29 +187,29 @@ pub trait InteractiveComponent: Sized + Element { fn on_scroll_wheel( mut self, - handler: impl Fn(&mut V, &ScrollWheelEvent, &mut ViewContext) + 'static, + listener: impl Fn(&ScrollWheelEvent, &mut WindowContext) + 'static, ) -> Self { self.interactivity().scroll_wheel_listeners.push(Box::new( - move |view, event, bounds, phase, cx| { + move |event, bounds, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { - handler(view, event, cx); + (listener)(event, cx); } }, )); self } - /// Capture the given action, fires during the capture phase + /// Capture the given action, before normal action dispatch can fire fn capture_action( mut self, - listener: impl Fn(&mut V, &A, &mut ViewContext) + 'static, + listener: impl Fn(&A, &mut WindowContext) + 'static, ) -> Self { self.interactivity().action_listeners.push(( TypeId::of::(), - Box::new(move |view, action, phase, cx| { + Box::new(move |action, phase, cx| { let action = action.downcast_ref().unwrap(); if phase == DispatchPhase::Capture { - listener(view, action, cx) + (listener)(action, cx) } }), )); @@ -224,10 +217,7 @@ pub trait InteractiveComponent: Sized + Element { } /// Add a listener for the given action, fires during the bubble event phase - fn on_action( - mut self, - listener: impl Fn(&mut V, &A, &mut ViewContext) + 'static, - ) -> Self { + fn on_action(mut self, listener: impl Fn(&A, &mut WindowContext) + 'static) -> Self { // NOTE: this debug assert has the side-effect of working around // a bug where a crate consisting only of action definitions does // not register the actions in debug builds: @@ -244,10 +234,10 @@ pub trait InteractiveComponent: Sized + Element { // ); self.interactivity().action_listeners.push(( TypeId::of::(), - Box::new(move |view, action, phase, cx| { + Box::new(move |action, phase, cx| { let action = action.downcast_ref().unwrap(); if phase == DispatchPhase::Bubble { - listener(view, action, cx) + (listener)(action, cx) } }), )); @@ -256,24 +246,53 @@ pub trait InteractiveComponent: Sized + Element { fn on_key_down( mut self, - listener: impl Fn(&mut V, &KeyDownEvent, DispatchPhase, &mut ViewContext) + 'static, + listener: impl Fn(&KeyDownEvent, &mut WindowContext) + 'static, ) -> Self { self.interactivity() .key_down_listeners - .push(Box::new(move |view, event, phase, cx| { - listener(view, event, phase, cx) + .push(Box::new(move |event, phase, cx| { + if phase == DispatchPhase::Bubble { + (listener)(event, cx) + } })); self } - fn on_key_up( + fn capture_key_down( mut self, - listener: impl Fn(&mut V, &KeyUpEvent, DispatchPhase, &mut ViewContext) + 'static, + listener: impl Fn(&KeyDownEvent, &mut WindowContext) + 'static, ) -> Self { + self.interactivity() + .key_down_listeners + .push(Box::new(move |event, phase, cx| { + if phase == DispatchPhase::Capture { + listener(event, cx) + } + })); + self + } + + fn on_key_up(mut self, listener: impl Fn(&KeyUpEvent, &mut WindowContext) + 'static) -> Self { self.interactivity() .key_up_listeners - .push(Box::new(move |view, event, phase, cx| { - listener(view, event, phase, cx) + .push(Box::new(move |event, phase, cx| { + if phase == DispatchPhase::Bubble { + listener(event, cx) + } + })); + self + } + + fn capture_key_up( + mut self, + listener: impl Fn(&KeyUpEvent, &mut WindowContext) + 'static, + ) -> Self { + self.interactivity() + .key_up_listeners + .push(Box::new(move |event, phase, cx| { + if phase == DispatchPhase::Capture { + listener(event, cx) + } })); self } @@ -302,25 +321,22 @@ pub trait InteractiveComponent: Sized + Element { fn on_drop( mut self, - listener: impl Fn(&mut V, View, &mut ViewContext) + 'static, + listener: impl Fn(&View, &mut WindowContext) + 'static, ) -> Self { self.interactivity().drop_listeners.push(( TypeId::of::(), - Box::new(move |view, dragged_view, cx| { - listener(view, dragged_view.downcast().unwrap(), cx); + Box::new(move |dragged_view, cx| { + listener(&dragged_view.downcast().unwrap(), cx); }), )); self } } -pub trait StatefulInteractiveComponent>: InteractiveComponent { - fn focusable(mut self) -> Focusable { +pub trait StatefulInteractiveElement: InteractiveElement { + fn focusable(mut self) -> Focusable { self.interactivity().focusable = true; - Focusable { - element: self, - view_type: PhantomData, - } + Focusable { element: self } } fn overflow_scroll(mut self) -> Self { @@ -362,23 +378,17 @@ pub trait StatefulInteractiveComponent>: InteractiveCo self } - fn on_click( - mut self, - listener: impl Fn(&mut V, &ClickEvent, &mut ViewContext) + 'static, - ) -> Self + fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self where Self: Sized, { self.interactivity() .click_listeners - .push(Box::new(move |view, event, cx| listener(view, event, cx))); + .push(Box::new(move |event, cx| listener(event, cx))); self } - fn on_drag( - mut self, - listener: impl Fn(&mut V, &mut ViewContext) -> View + 'static, - ) -> Self + fn on_drag(mut self, listener: impl Fn(&mut WindowContext) -> View + 'static) -> Self where Self: Sized, W: 'static + Render, @@ -387,15 +397,14 @@ pub trait StatefulInteractiveComponent>: InteractiveCo self.interactivity().drag_listener.is_none(), "calling on_drag more than once on the same element is not supported" ); - self.interactivity().drag_listener = - Some(Box::new(move |view_state, cursor_offset, cx| AnyDrag { - view: listener(view_state, cx).into(), - cursor_offset, - })); + self.interactivity().drag_listener = Some(Box::new(move |cursor_offset, cx| AnyDrag { + view: listener(cx).into(), + cursor_offset, + })); self } - fn on_hover(mut self, listener: impl 'static + Fn(&mut V, bool, &mut ViewContext)) -> Self + fn on_hover(mut self, listener: impl Fn(&bool, &mut WindowContext) + 'static) -> Self where Self: Sized, { @@ -407,10 +416,7 @@ pub trait StatefulInteractiveComponent>: InteractiveCo self } - fn tooltip( - mut self, - build_tooltip: impl Fn(&mut V, &mut ViewContext) -> AnyView + 'static, - ) -> Self + fn tooltip(mut self, build_tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self where Self: Sized, { @@ -418,14 +424,13 @@ pub trait StatefulInteractiveComponent>: InteractiveCo self.interactivity().tooltip_builder.is_none(), "calling tooltip more than once on the same element is not supported" ); - self.interactivity().tooltip_builder = - Some(Rc::new(move |view_state, cx| build_tooltip(view_state, cx))); + self.interactivity().tooltip_builder = Some(Rc::new(build_tooltip)); self } } -pub trait FocusableComponent: InteractiveComponent { +pub trait FocusableElement: InteractiveElement { fn focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self where Self: Sized, @@ -442,49 +447,41 @@ pub trait FocusableComponent: InteractiveComponent { self } - fn on_focus( - mut self, - listener: impl Fn(&mut V, &FocusEvent, &mut ViewContext) + 'static, - ) -> Self + fn on_focus(mut self, listener: impl Fn(&FocusEvent, &mut WindowContext) + 'static) -> Self where Self: Sized, { - self.interactivity().focus_listeners.push(Box::new( - move |view, focus_handle, event, cx| { + self.interactivity() + .focus_listeners + .push(Box::new(move |focus_handle, event, cx| { if event.focused.as_ref() == Some(focus_handle) { - listener(view, event, cx) + listener(event, cx) } - }, - )); + })); self } - fn on_blur( - mut self, - listener: impl Fn(&mut V, &FocusEvent, &mut ViewContext) + 'static, - ) -> Self + fn on_blur(mut self, listener: impl Fn(&FocusEvent, &mut WindowContext) + 'static) -> Self where Self: Sized, { - self.interactivity().focus_listeners.push(Box::new( - move |view, focus_handle, event, cx| { + self.interactivity() + .focus_listeners + .push(Box::new(move |focus_handle, event, cx| { if event.blurred.as_ref() == Some(focus_handle) { - listener(view, event, cx) + listener(event, cx) } - }, - )); + })); self } - fn on_focus_in( - mut self, - listener: impl Fn(&mut V, &FocusEvent, &mut ViewContext) + 'static, - ) -> Self + fn on_focus_in(mut self, listener: impl Fn(&FocusEvent, &mut WindowContext) + 'static) -> Self where Self: Sized, { - self.interactivity().focus_listeners.push(Box::new( - move |view, focus_handle, event, cx| { + self.interactivity() + .focus_listeners + .push(Box::new(move |focus_handle, event, cx| { let descendant_blurred = event .blurred .as_ref() @@ -495,22 +492,19 @@ pub trait FocusableComponent: InteractiveComponent { .map_or(false, |focused| focus_handle.contains(focused, cx)); if !descendant_blurred && descendant_focused { - listener(view, event, cx) + listener(event, cx) } - }, - )); + })); self } - fn on_focus_out( - mut self, - listener: impl Fn(&mut V, &FocusEvent, &mut ViewContext) + 'static, - ) -> Self + fn on_focus_out(mut self, listener: impl Fn(&FocusEvent, &mut WindowContext) + 'static) -> Self where Self: Sized, { - self.interactivity().focus_listeners.push(Box::new( - move |view, focus_handle, event, cx| { + self.interactivity() + .focus_listeners + .push(Box::new(move |focus_handle, event, cx| { let descendant_blurred = event .blurred .as_ref() @@ -520,98 +514,80 @@ pub trait FocusableComponent: InteractiveComponent { .as_ref() .map_or(false, |focused| focus_handle.contains(focused, cx)); if descendant_blurred && !descendant_focused { - listener(view, event, cx) + listener(event, cx) } - }, - )); + })); self } } -pub type FocusListeners = SmallVec<[FocusListener; 2]>; - -pub type FocusListener = - Box) + 'static>; +pub type FocusListeners = SmallVec<[FocusListener; 2]>; -pub type MouseDownListener = Box< - dyn Fn(&mut V, &MouseDownEvent, &Bounds, DispatchPhase, &mut ViewContext) + 'static, ->; -pub type MouseUpListener = Box< - dyn Fn(&mut V, &MouseUpEvent, &Bounds, DispatchPhase, &mut ViewContext) + 'static, ->; +pub type FocusListener = Box; -pub type MouseMoveListener = Box< - dyn Fn(&mut V, &MouseMoveEvent, &Bounds, DispatchPhase, &mut ViewContext) + 'static, ->; +pub type MouseDownListener = + Box, DispatchPhase, &mut WindowContext) + 'static>; +pub type MouseUpListener = + Box, DispatchPhase, &mut WindowContext) + 'static>; -pub type ScrollWheelListener = Box< - dyn Fn(&mut V, &ScrollWheelEvent, &Bounds, DispatchPhase, &mut ViewContext) - + 'static, ->; +pub type MouseMoveListener = + Box, DispatchPhase, &mut WindowContext) + 'static>; -pub type ClickListener = Box) + 'static>; +pub type ScrollWheelListener = + Box, DispatchPhase, &mut WindowContext) + 'static>; -pub type DragListener = - Box, &mut ViewContext) -> AnyDrag + 'static>; +pub type ClickListener = Box; -type DropListener = dyn Fn(&mut V, AnyView, &mut ViewContext) + 'static; +pub type DragListener = Box, &mut WindowContext) -> AnyDrag + 'static>; -pub type HoverListener = Box) + 'static>; +type DropListener = dyn Fn(AnyView, &mut WindowContext) + 'static; -pub type TooltipBuilder = Rc) -> AnyView + 'static>; +pub type TooltipBuilder = Rc AnyView + 'static>; -pub type KeyDownListener = - Box) + 'static>; +pub type KeyDownListener = Box; -pub type KeyUpListener = - Box) + 'static>; +pub type KeyUpListener = Box; -pub type ActionListener = - Box) + 'static>; +pub type ActionListener = Box; -pub fn div() -> Div { +pub fn div() -> Div { Div { interactivity: Interactivity::default(), children: SmallVec::default(), } } -pub struct Div { - interactivity: Interactivity, - children: SmallVec<[AnyElement; 2]>, +pub struct Div { + interactivity: Interactivity, + children: SmallVec<[AnyElement; 2]>, } -impl Styled for Div { +impl Styled for Div { fn style(&mut self) -> &mut StyleRefinement { &mut self.interactivity.base_style } } -impl InteractiveComponent for Div { - fn interactivity(&mut self) -> &mut Interactivity { +impl InteractiveElement for Div { + fn interactivity(&mut self) -> &mut Interactivity { &mut self.interactivity } } -impl ParentComponent for Div { - fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { +impl ParentElement for Div { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { &mut self.children } } -impl Element for Div { - type ElementState = DivState; - - fn element_id(&self) -> Option { - self.interactivity.element_id.clone() - } +impl Element for Div { + type State = DivState; fn layout( &mut self, - view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { + element_state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { let mut child_layout_ids = SmallVec::new(); let mut interactivity = mem::take(&mut self.interactivity); let (layout_id, interactive_state) = interactivity.layout( @@ -622,7 +598,7 @@ impl Element for Div { child_layout_ids = self .children .iter_mut() - .map(|child| child.layout(view_state, cx)) + .map(|child| child.layout(cx)) .collect::>(); cx.request_layout(&style, child_layout_ids.iter().copied()) }) @@ -639,11 +615,10 @@ impl Element for Div { } fn paint( - &mut self, + self, bounds: Bounds, - view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, + element_state: &mut Self::State, + cx: &mut WindowContext, ) { let mut child_min = point(Pixels::MAX, Pixels::MAX); let mut child_max = Point::default(); @@ -658,8 +633,7 @@ impl Element for Div { (child_max - child_min).into() }; - let mut interactivity = mem::take(&mut self.interactivity); - interactivity.paint( + self.interactivity.paint( bounds, content_size, &mut element_state.interactive_state, @@ -679,8 +653,8 @@ impl Element for Div { cx.with_text_style(style.text_style().cloned(), |cx| { cx.with_content_mask(style.overflow_mask(bounds), |cx| { cx.with_element_offset(scroll_offset, |cx| { - for child in &mut self.children { - child.paint(view_state, cx); + for child in self.children { + child.paint(cx); } }) }) @@ -689,13 +663,18 @@ impl Element for Div { }) }, ); - self.interactivity = interactivity; } } -impl Component for Div { - fn render(self) -> AnyElement { - AnyElement::new(self) +impl RenderOnce for Div { + type Element = Self; + + fn element_id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn render_once(self) -> Self::Element { + self } } @@ -710,12 +689,12 @@ impl DivState { } } -pub struct Interactivity { +pub struct Interactivity { pub element_id: Option, pub key_context: KeyContext, pub focusable: bool, pub tracked_focus_handle: Option, - pub focus_listeners: FocusListeners, + pub focus_listeners: FocusListeners, pub group: Option, pub base_style: StyleRefinement, pub focus_style: StyleRefinement, @@ -726,29 +705,26 @@ pub struct Interactivity { pub group_active_style: Option, pub drag_over_styles: SmallVec<[(TypeId, StyleRefinement); 2]>, pub group_drag_over_styles: SmallVec<[(TypeId, GroupStyle); 2]>, - pub mouse_down_listeners: SmallVec<[MouseDownListener; 2]>, - pub mouse_up_listeners: SmallVec<[MouseUpListener; 2]>, - pub mouse_move_listeners: SmallVec<[MouseMoveListener; 2]>, - pub scroll_wheel_listeners: SmallVec<[ScrollWheelListener; 2]>, - pub key_down_listeners: SmallVec<[KeyDownListener; 2]>, - pub key_up_listeners: SmallVec<[KeyUpListener; 2]>, - pub action_listeners: SmallVec<[(TypeId, ActionListener); 8]>, - pub drop_listeners: SmallVec<[(TypeId, Box>); 2]>, - pub click_listeners: SmallVec<[ClickListener; 2]>, - pub drag_listener: Option>, - pub hover_listener: Option>, - pub tooltip_builder: Option>, + pub mouse_down_listeners: SmallVec<[MouseDownListener; 2]>, + pub mouse_up_listeners: SmallVec<[MouseUpListener; 2]>, + pub mouse_move_listeners: SmallVec<[MouseMoveListener; 2]>, + pub scroll_wheel_listeners: SmallVec<[ScrollWheelListener; 2]>, + pub key_down_listeners: SmallVec<[KeyDownListener; 2]>, + pub key_up_listeners: SmallVec<[KeyUpListener; 2]>, + pub action_listeners: SmallVec<[(TypeId, ActionListener); 8]>, + pub drop_listeners: SmallVec<[(TypeId, Box); 2]>, + pub click_listeners: SmallVec<[ClickListener; 2]>, + pub drag_listener: Option, + pub hover_listener: Option>, + pub tooltip_builder: Option, } -impl Interactivity -where - V: 'static, -{ +impl Interactivity { pub fn layout( &mut self, element_state: Option, - cx: &mut ViewContext, - f: impl FnOnce(Style, &mut ViewContext) -> LayoutId, + cx: &mut WindowContext, + f: impl FnOnce(Style, &mut WindowContext) -> LayoutId, ) -> (LayoutId, InteractiveElementState) { let mut element_state = element_state.unwrap_or_default(); @@ -770,12 +746,12 @@ where } pub fn paint( - &mut self, + mut self, bounds: Bounds, content_size: Size, element_state: &mut InteractiveElementState, - cx: &mut ViewContext, - f: impl FnOnce(Style, Point, &mut ViewContext), + cx: &mut WindowContext, + f: impl FnOnce(Style, Point, &mut WindowContext), ) { let style = self.compute_style(Some(bounds), element_state, cx); @@ -787,26 +763,26 @@ where } for listener in self.mouse_down_listeners.drain(..) { - cx.on_mouse_event(move |state, event: &MouseDownEvent, phase, cx| { - listener(state, event, &bounds, phase, cx); + cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { + listener(event, &bounds, phase, cx); }) } for listener in self.mouse_up_listeners.drain(..) { - cx.on_mouse_event(move |state, event: &MouseUpEvent, phase, cx| { - listener(state, event, &bounds, phase, cx); + cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| { + listener(event, &bounds, phase, cx); }) } for listener in self.mouse_move_listeners.drain(..) { - cx.on_mouse_event(move |state, event: &MouseMoveEvent, phase, cx| { - listener(state, event, &bounds, phase, cx); + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { + listener(event, &bounds, phase, cx); }) } for listener in self.scroll_wheel_listeners.drain(..) { - cx.on_mouse_event(move |state, event: &ScrollWheelEvent, phase, cx| { - listener(state, event, &bounds, phase, cx); + cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| { + listener(event, &bounds, phase, cx); }) } @@ -817,7 +793,7 @@ where if let Some(group_bounds) = hover_group_bounds { let hovered = group_bounds.contains_point(&cx.mouse_position()); - cx.on_mouse_event(move |_, event: &MouseMoveEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { if phase == DispatchPhase::Capture { if group_bounds.contains_point(&event.position) != hovered { cx.notify(); @@ -830,7 +806,7 @@ where || (cx.active_drag.is_some() && !self.drag_over_styles.is_empty()) { let hovered = bounds.contains_point(&cx.mouse_position()); - cx.on_mouse_event(move |_, event: &MouseMoveEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { if phase == DispatchPhase::Capture { if bounds.contains_point(&event.position) != hovered { cx.notify(); @@ -841,7 +817,7 @@ where if cx.active_drag.is_some() { let drop_listeners = mem::take(&mut self.drop_listeners); - cx.on_mouse_event(move |view, event: &MouseUpEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { if let Some(drag_state_type) = cx.active_drag.as_ref().map(|drag| drag.view.entity_type()) @@ -852,7 +828,7 @@ where .active_drag .take() .expect("checked for type drag state type above"); - listener(view, drag.view.clone(), cx); + listener(drag.view.clone(), cx); cx.notify(); cx.stop_propagation(); } @@ -872,7 +848,7 @@ where if let Some(drag_listener) = drag_listener { let active_state = element_state.clicked_state.clone(); - cx.on_mouse_event(move |view_state, event: &MouseMoveEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { if cx.active_drag.is_some() { if phase == DispatchPhase::Capture { cx.notify(); @@ -883,7 +859,7 @@ where { *active_state.borrow_mut() = ElementClickedState::default(); let cursor_offset = event.position - bounds.origin; - let drag = drag_listener(view_state, cursor_offset, cx); + let drag = drag_listener(cursor_offset, cx); cx.active_drag = Some(drag); cx.notify(); cx.stop_propagation(); @@ -891,21 +867,21 @@ where }); } - cx.on_mouse_event(move |view_state, event: &MouseUpEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseUpEvent, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { let mouse_click = ClickEvent { down: mouse_down.clone(), up: event.clone(), }; for listener in &click_listeners { - listener(view_state, &mouse_click, cx); + listener(&mouse_click, cx); } } *pending_mouse_down.borrow_mut() = None; cx.notify(); }); } else { - cx.on_mouse_event(move |_view_state, event: &MouseDownEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { *pending_mouse_down.borrow_mut() = Some(event.clone()); cx.notify(); @@ -918,7 +894,7 @@ where let was_hovered = element_state.hover_state.clone(); let has_mouse_down = element_state.pending_mouse_down.clone(); - cx.on_mouse_event(move |view_state, event: &MouseMoveEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { if phase != DispatchPhase::Bubble { return; } @@ -930,7 +906,7 @@ where *was_hovered = is_hovered; drop(was_hovered); - hover_listener(view_state, is_hovered, cx); + hover_listener(&is_hovered, cx); } }); } @@ -939,7 +915,7 @@ where let active_tooltip = element_state.active_tooltip.clone(); let pending_mouse_down = element_state.pending_mouse_down.clone(); - cx.on_mouse_event(move |_, event: &MouseMoveEvent, phase, cx| { + cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { if phase != DispatchPhase::Bubble { return; } @@ -956,12 +932,12 @@ where let active_tooltip = active_tooltip.clone(); let tooltip_builder = tooltip_builder.clone(); - move |view, mut cx| async move { + move |mut cx| async move { cx.background_executor().timer(TOOLTIP_DELAY).await; - view.update(&mut cx, move |view_state, cx| { + cx.update(|_, cx| { active_tooltip.borrow_mut().replace(ActiveTooltip { tooltip: Some(AnyTooltip { - view: tooltip_builder(view_state, cx), + view: tooltip_builder(cx), cursor_offset: cx.mouse_position(), }), _task: None, @@ -979,7 +955,7 @@ where }); let active_tooltip = element_state.active_tooltip.clone(); - cx.on_mouse_event(move |_, _: &MouseDownEvent, _, _| { + cx.on_mouse_event(move |_: &MouseDownEvent, _, _| { active_tooltip.borrow_mut().take(); }); @@ -992,7 +968,7 @@ where let active_state = element_state.clicked_state.clone(); if !active_state.borrow().is_clicked() { - cx.on_mouse_event(move |_, _: &MouseUpEvent, phase, cx| { + cx.on_mouse_event(move |_: &MouseUpEvent, phase, cx| { if phase == DispatchPhase::Capture { *active_state.borrow_mut() = ElementClickedState::default(); cx.notify(); @@ -1003,7 +979,7 @@ where .group_active_style .as_ref() .and_then(|group_active| GroupBounds::get(&group_active.group, cx)); - cx.on_mouse_event(move |_view, down: &MouseDownEvent, phase, cx| { + cx.on_mouse_event(move |down: &MouseDownEvent, phase, cx| { if phase == DispatchPhase::Bubble { let group = active_group_bounds .map_or(false, |bounds| bounds.contains_point(&down.position)); @@ -1025,7 +1001,7 @@ where let line_height = cx.line_height(); let scroll_max = (content_size - bounds.size).max(&Size::default()); - cx.on_mouse_event(move |_, event: &ScrollWheelEvent, phase, cx| { + cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| { if phase == DispatchPhase::Bubble && bounds.contains_point(&event.position) { let mut scroll_offset = scroll_offset.borrow_mut(); let old_scroll_offset = *scroll_offset; @@ -1063,27 +1039,25 @@ where element_state.focus_handle.clone(), |_, cx| { for listener in self.key_down_listeners.drain(..) { - cx.on_key_event(move |state, event: &KeyDownEvent, phase, cx| { - listener(state, event, phase, cx); + cx.on_key_event(move |event: &KeyDownEvent, phase, cx| { + listener(event, phase, cx); }) } for listener in self.key_up_listeners.drain(..) { - cx.on_key_event(move |state, event: &KeyUpEvent, phase, cx| { - listener(state, event, phase, cx); + cx.on_key_event(move |event: &KeyUpEvent, phase, cx| { + listener(event, phase, cx); }) } - for (action_type, listener) in self.action_listeners.drain(..) { + for (action_type, listener) in self.action_listeners { cx.on_action(action_type, listener) } if let Some(focus_handle) = element_state.focus_handle.as_ref() { - for listener in self.focus_listeners.drain(..) { + for listener in self.focus_listeners { let focus_handle = focus_handle.clone(); - cx.on_focus_changed(move |view, event, cx| { - listener(view, &focus_handle, event, cx) - }); + cx.on_focus_changed(move |event, cx| listener(&focus_handle, event, cx)); } } @@ -1100,7 +1074,7 @@ where &self, bounds: Option>, element_state: &mut InteractiveElementState, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Style { let mut style = Style::default(); style.refine(&self.base_style); @@ -1124,9 +1098,14 @@ where } } } + // if self.hover_style.is_some() { if bounds.contains_point(&mouse_position) { + // eprintln!("div hovered {bounds:?} {mouse_position:?}"); style.refine(&self.hover_style); + } else { + // eprintln!("div NOT hovered {bounds:?} {mouse_position:?}"); } + // } if let Some(drag) = cx.active_drag.take() { for (state_type, group_drag_style) in &self.group_drag_over_styles { @@ -1166,7 +1145,7 @@ where } } -impl Default for Interactivity { +impl Default for Interactivity { fn default() -> Self { Self { element_id: None, @@ -1254,31 +1233,25 @@ impl GroupBounds { } } -pub struct Focusable { +pub struct Focusable { element: E, - view_type: PhantomData, } -impl> FocusableComponent for Focusable {} +impl FocusableElement for Focusable {} -impl InteractiveComponent for Focusable +impl InteractiveElement for Focusable where - V: 'static, - E: InteractiveComponent, + E: InteractiveElement, { - fn interactivity(&mut self) -> &mut Interactivity { + fn interactivity(&mut self) -> &mut Interactivity { self.element.interactivity() } } -impl> StatefulInteractiveComponent - for Focusable -{ -} +impl StatefulInteractiveElement for Focusable {} -impl Styled for Focusable +impl Styled for Focusable where - V: 'static, E: Styled, { fn style(&mut self) -> &mut StyleRefinement { @@ -1286,65 +1259,55 @@ where } } -impl Element for Focusable +impl Element for Focusable where - V: 'static, - E: Element, + E: Element, { - type ElementState = E::ElementState; - - fn element_id(&self) -> Option { - self.element.element_id() - } + type State = E::State; fn layout( &mut self, - view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { - self.element.layout(view_state, element_state, cx) + state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + self.element.layout(state, cx) } - fn paint( - &mut self, - bounds: Bounds, - view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, - ) { - self.element.paint(bounds, view_state, element_state, cx); + fn paint(self, bounds: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + self.element.paint(bounds, state, cx) } } -impl Component for Focusable +impl RenderOnce for Focusable where - V: 'static, - E: 'static + Element, + E: Element, { - fn render(self) -> AnyElement { - AnyElement::new(self) + type Element = E; + + fn element_id(&self) -> Option { + self.element.element_id() + } + + fn render_once(self) -> Self::Element { + self.element } } -impl ParentComponent for Focusable +impl ParentElement for Focusable where - V: 'static, - E: ParentComponent, + E: ParentElement, { - fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { self.element.children_mut() } } -pub struct Stateful { +pub struct Stateful { element: E, - view_type: PhantomData, } -impl Styled for Stateful +impl Styled for Stateful where - V: 'static, E: Styled, { fn style(&mut self) -> &mut StyleRefinement { @@ -1352,73 +1315,63 @@ where } } -impl StatefulInteractiveComponent for Stateful +impl StatefulInteractiveElement for Stateful where - V: 'static, - E: Element, - Self: InteractiveComponent, + E: Element, + Self: InteractiveElement, { } -impl InteractiveComponent for Stateful +impl InteractiveElement for Stateful where - V: 'static, - E: InteractiveComponent, + E: InteractiveElement, { - fn interactivity(&mut self) -> &mut Interactivity { + fn interactivity(&mut self) -> &mut Interactivity { self.element.interactivity() } } -impl> FocusableComponent for Stateful {} +impl FocusableElement for Stateful {} -impl Element for Stateful +impl Element for Stateful where - V: 'static, - E: Element, + E: Element, { - type ElementState = E::ElementState; - - fn element_id(&self) -> Option { - self.element.element_id() - } + type State = E::State; fn layout( &mut self, - view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { - self.element.layout(view_state, element_state, cx) + state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + self.element.layout(state, cx) } - fn paint( - &mut self, - bounds: Bounds, - view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, - ) { - self.element.paint(bounds, view_state, element_state, cx) + fn paint(self, bounds: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + self.element.paint(bounds, state, cx) } } -impl Component for Stateful +impl RenderOnce for Stateful where - V: 'static, - E: 'static + Element, + E: Element, { - fn render(self) -> AnyElement { - AnyElement::new(self) + type Element = Self; + + fn element_id(&self) -> Option { + self.element.element_id() + } + + fn render_once(self) -> Self::Element { + self } } -impl ParentComponent for Stateful +impl ParentElement for Stateful where - V: 'static, - E: ParentComponent, + E: ParentElement, { - fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { self.element.children_mut() } } diff --git a/crates/gpui2/src/elements/img.rs b/crates/gpui2/src/elements/img.rs index 1080135fe16edcabdf6c2176e264bd551be5011d..3c0f4c00be852ed2df5295ce83d47b583582f747 100644 --- a/crates/gpui2/src/elements/img.rs +++ b/crates/gpui2/src/elements/img.rs @@ -1,18 +1,17 @@ use crate::{ - AnyElement, BorrowWindow, Bounds, Component, Element, InteractiveComponent, - InteractiveElementState, Interactivity, LayoutId, Pixels, SharedString, StyleRefinement, - Styled, ViewContext, + Bounds, Element, InteractiveElement, InteractiveElementState, Interactivity, LayoutId, Pixels, + RenderOnce, SharedString, StyleRefinement, Styled, WindowContext, }; use futures::FutureExt; use util::ResultExt; -pub struct Img { - interactivity: Interactivity, +pub struct Img { + interactivity: Interactivity, uri: Option, grayscale: bool, } -pub fn img() -> Img { +pub fn img() -> Img { Img { interactivity: Interactivity::default(), uri: None, @@ -20,10 +19,7 @@ pub fn img() -> Img { } } -impl Img -where - V: 'static, -{ +impl Img { pub fn uri(mut self, uri: impl Into) -> Self { self.uri = Some(uri.into()); self @@ -35,36 +31,24 @@ where } } -impl Component for Img { - fn render(self) -> AnyElement { - AnyElement::new(self) - } -} - -impl Element for Img { - type ElementState = InteractiveElementState; - - fn element_id(&self) -> Option { - self.interactivity.element_id.clone() - } +impl Element for Img { + type State = InteractiveElementState; fn layout( &mut self, - _view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { + element_state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { self.interactivity.layout(element_state, cx, |style, cx| { cx.request_layout(&style, None) }) } fn paint( - &mut self, + self, bounds: Bounds, - _view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, + element_state: &mut Self::State, + cx: &mut WindowContext, ) { self.interactivity.paint( bounds, @@ -81,7 +65,7 @@ impl Element for Img { if let Some(data) = image_future .clone() .now_or_never() - .and_then(ResultExt::log_err) + .and_then(|result| result.ok()) { let corner_radii = corner_radii.to_pixels(bounds.size, cx.rem_size()); cx.with_z_index(1, |cx| { @@ -89,8 +73,8 @@ impl Element for Img { .log_err() }); } else { - cx.spawn(|_, mut cx| async move { - if image_future.await.log_err().is_some() { + cx.spawn(|mut cx| async move { + if image_future.await.ok().is_some() { cx.on_next_frame(|cx| cx.notify()); } }) @@ -102,14 +86,26 @@ impl Element for Img { } } -impl Styled for Img { +impl RenderOnce for Img { + type Element = Self; + + fn element_id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn render_once(self) -> Self::Element { + self + } +} + +impl Styled for Img { fn style(&mut self) -> &mut StyleRefinement { &mut self.interactivity.base_style } } -impl InteractiveComponent for Img { - fn interactivity(&mut self) -> &mut Interactivity { +impl InteractiveElement for Img { + fn interactivity(&mut self) -> &mut Interactivity { &mut self.interactivity } } diff --git a/crates/gpui2/src/elements/overlay.rs b/crates/gpui2/src/elements/overlay.rs index 14a8048d398176bbd8bec49b37bc96b261450ed9..29ac2f00c46b2807c9417a9cbeccfa6dd866ca07 100644 --- a/crates/gpui2/src/elements/overlay.rs +++ b/crates/gpui2/src/elements/overlay.rs @@ -2,16 +2,16 @@ use smallvec::SmallVec; use taffy::style::{Display, Position}; use crate::{ - point, AnyElement, BorrowWindow, Bounds, Component, Element, LayoutId, ParentComponent, Pixels, - Point, Size, Style, + point, AnyElement, BorrowWindow, Bounds, Element, LayoutId, ParentElement, Pixels, Point, + RenderOnce, Size, Style, WindowContext, }; pub struct OverlayState { child_layout_ids: SmallVec<[LayoutId; 4]>, } -pub struct Overlay { - children: SmallVec<[AnyElement; 2]>, +pub struct Overlay { + children: SmallVec<[AnyElement; 2]>, anchor_corner: AnchorCorner, fit_mode: OverlayFitMode, // todo!(); @@ -21,7 +21,7 @@ pub struct Overlay { /// overlay gives you a floating element that will avoid overflowing the window bounds. /// Its children should have no margin to avoid measurement issues. -pub fn overlay() -> Overlay { +pub fn overlay() -> Overlay { Overlay { children: SmallVec::new(), anchor_corner: AnchorCorner::TopLeft, @@ -30,7 +30,7 @@ pub fn overlay() -> Overlay { } } -impl Overlay { +impl Overlay { /// Sets which corner of the overlay should be anchored to the current position. pub fn anchor(mut self, anchor: AnchorCorner) -> Self { self.anchor_corner = anchor; @@ -51,35 +51,24 @@ impl Overlay { } } -impl ParentComponent for Overlay { - fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { +impl ParentElement for Overlay { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { &mut self.children } } -impl Component for Overlay { - fn render(self) -> AnyElement { - AnyElement::new(self) - } -} - -impl Element for Overlay { - type ElementState = OverlayState; - - fn element_id(&self) -> Option { - None - } +impl Element for Overlay { + type State = OverlayState; fn layout( &mut self, - view_state: &mut V, - _: Option, - cx: &mut crate::ViewContext, - ) -> (crate::LayoutId, Self::ElementState) { + _: Option, + cx: &mut WindowContext, + ) -> (crate::LayoutId, Self::State) { let child_layout_ids = self .children .iter_mut() - .map(|child| child.layout(view_state, cx)) + .map(|child| child.layout(cx)) .collect::>(); let mut overlay_style = Style::default(); @@ -92,11 +81,10 @@ impl Element for Overlay { } fn paint( - &mut self, + self, bounds: crate::Bounds, - view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut crate::ViewContext, + element_state: &mut Self::State, + cx: &mut WindowContext, ) { if element_state.child_layout_ids.is_empty() { return; @@ -156,13 +144,25 @@ impl Element for Overlay { } cx.with_element_offset(desired.origin - bounds.origin, |cx| { - for child in &mut self.children { - child.paint(view_state, cx); + for child in self.children { + child.paint(cx); } }) } } +impl RenderOnce for Overlay { + type Element = Self; + + fn element_id(&self) -> Option { + None + } + + fn render_once(self) -> Self::Element { + self + } +} + enum Axis { Horizontal, Vertical, diff --git a/crates/gpui2/src/elements/svg.rs b/crates/gpui2/src/elements/svg.rs index c1c7691fbf41371cca9cba173485523df2dd7fb3..c24e4d9b8b88be501e8744e86982a0cbbc542f76 100644 --- a/crates/gpui2/src/elements/svg.rs +++ b/crates/gpui2/src/elements/svg.rs @@ -1,60 +1,43 @@ use crate::{ - AnyElement, Bounds, Component, Element, ElementId, InteractiveComponent, - InteractiveElementState, Interactivity, LayoutId, Pixels, SharedString, StyleRefinement, - Styled, ViewContext, + Bounds, Element, ElementId, InteractiveElement, InteractiveElementState, Interactivity, + LayoutId, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, WindowContext, }; use util::ResultExt; -pub struct Svg { - interactivity: Interactivity, +pub struct Svg { + interactivity: Interactivity, path: Option, } -pub fn svg() -> Svg { +pub fn svg() -> Svg { Svg { interactivity: Interactivity::default(), path: None, } } -impl Svg { +impl Svg { pub fn path(mut self, path: impl Into) -> Self { self.path = Some(path.into()); self } } -impl Component for Svg { - fn render(self) -> AnyElement { - AnyElement::new(self) - } -} - -impl Element for Svg { - type ElementState = InteractiveElementState; - - fn element_id(&self) -> Option { - self.interactivity.element_id.clone() - } +impl Element for Svg { + type State = InteractiveElementState; fn layout( &mut self, - _view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { + element_state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { self.interactivity.layout(element_state, cx, |style, cx| { cx.request_layout(&style, None) }) } - fn paint( - &mut self, - bounds: Bounds, - _view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, - ) where + fn paint(self, bounds: Bounds, element_state: &mut Self::State, cx: &mut WindowContext) + where Self: Sized, { self.interactivity @@ -66,14 +49,26 @@ impl Element for Svg { } } -impl Styled for Svg { +impl RenderOnce for Svg { + type Element = Self; + + fn element_id(&self) -> Option { + self.interactivity.element_id.clone() + } + + fn render_once(self) -> Self::Element { + self + } +} + +impl Styled for Svg { fn style(&mut self) -> &mut StyleRefinement { &mut self.interactivity.base_style } } -impl InteractiveComponent for Svg { - fn interactivity(&mut self) -> &mut Interactivity { +impl InteractiveElement for Svg { + fn interactivity(&mut self) -> &mut Interactivity { &mut self.interactivity } } diff --git a/crates/gpui2/src/elements/text.rs b/crates/gpui2/src/elements/text.rs index 6849a8971107f011ee3e5ee06b186ada3da78ed4..05ab85ca63353fa69f76a89b12e421aaf5c31267 100644 --- a/crates/gpui2/src/elements/text.rs +++ b/crates/gpui2/src/elements/text.rs @@ -1,82 +1,191 @@ use crate::{ - AnyElement, BorrowWindow, Bounds, Component, Element, ElementId, LayoutId, Pixels, - SharedString, Size, TextRun, ViewContext, WrappedLine, + Bounds, Element, ElementId, LayoutId, Pixels, RenderOnce, SharedString, Size, TextRun, + WindowContext, WrappedLine, }; +use anyhow::anyhow; use parking_lot::{Mutex, MutexGuard}; use smallvec::SmallVec; use std::{cell::Cell, rc::Rc, sync::Arc}; use util::ResultExt; -pub struct Text { +impl Element for &'static str { + type State = TextState; + + fn layout( + &mut self, + _: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + let mut state = TextState::default(); + let layout_id = state.layout(SharedString::from(*self), None, cx); + (layout_id, state) + } + + fn paint(self, bounds: Bounds, state: &mut TextState, cx: &mut WindowContext) { + state.paint(bounds, self, cx) + } +} + +impl RenderOnce for &'static str { + type Element = Self; + + fn element_id(&self) -> Option { + None + } + + fn render_once(self) -> Self::Element { + self + } +} + +impl Element for SharedString { + type State = TextState; + + fn layout( + &mut self, + _: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + let mut state = TextState::default(); + let layout_id = state.layout(self.clone(), None, cx); + (layout_id, state) + } + + fn paint(self, bounds: Bounds, state: &mut TextState, cx: &mut WindowContext) { + let text_str: &str = self.as_ref(); + state.paint(bounds, text_str, cx) + } +} + +impl RenderOnce for SharedString { + type Element = Self; + + fn element_id(&self) -> Option { + None + } + + fn render_once(self) -> Self::Element { + self + } +} + +pub struct StyledText { text: SharedString, runs: Option>, } -impl Text { +impl StyledText { /// Renders text with runs of different styles. /// /// Callers are responsible for setting the correct style for each run. /// For text with a uniform style, you can usually avoid calling this constructor /// and just pass text directly. - pub fn styled(text: SharedString, runs: Vec) -> Self { - Text { + pub fn new(text: SharedString, runs: Vec) -> Self { + StyledText { text, runs: Some(runs), } } } -impl Component for Text { - fn render(self) -> AnyElement { - AnyElement::new(self) +impl Element for StyledText { + type State = TextState; + + fn layout( + &mut self, + _: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + let mut state = TextState::default(); + let layout_id = state.layout(self.text.clone(), self.runs.take(), cx); + (layout_id, state) + } + + fn paint(self, bounds: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + state.paint(bounds, &self.text, cx) } } -impl Element for Text { - type ElementState = TextState; +impl RenderOnce for StyledText { + type Element = Self; fn element_id(&self) -> Option { None } + fn render_once(self) -> Self::Element { + self + } +} + +#[derive(Default, Clone)] +pub struct TextState(Arc>>); + +struct TextStateInner { + lines: SmallVec<[WrappedLine; 1]>, + line_height: Pixels, + wrap_width: Option, + size: Option>, +} + +impl TextState { + fn lock(&self) -> MutexGuard> { + self.0.lock() + } + fn layout( &mut self, - _view: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { - let element_state = element_state.unwrap_or_default(); + text: SharedString, + runs: Option>, + cx: &mut WindowContext, + ) -> LayoutId { let text_system = cx.text_system().clone(); let text_style = cx.text_style(); let font_size = text_style.font_size.to_pixels(cx.rem_size()); let line_height = text_style .line_height .to_pixels(font_size.into(), cx.rem_size()); - let text = self.text.clone(); + let text = SharedString::from(text); let rem_size = cx.rem_size(); - let runs = if let Some(runs) = self.runs.take() { + let runs = if let Some(runs) = runs { runs } else { vec![text_style.to_run(text.len())] }; let layout_id = cx.request_measured_layout(Default::default(), rem_size, { - let element_state = element_state.clone(); - move |known_dimensions, _| { + let element_state = self.clone(); + + move |known_dimensions, available_space| { + let wrap_width = known_dimensions.width.or(match available_space.width { + crate::AvailableSpace::Definite(x) => Some(x), + _ => None, + }); + + if let Some(text_state) = element_state.0.lock().as_ref() { + if text_state.size.is_some() + && (wrap_width.is_none() || wrap_width == text_state.wrap_width) + { + return text_state.size.unwrap(); + } + } + let Some(lines) = text_system .shape_text( &text, font_size, &runs[..], - known_dimensions.width, // Wrap if we know the width. + wrap_width, // Wrap if we know the width. ) .log_err() else { element_state.lock().replace(TextStateInner { lines: Default::default(), line_height, + wrap_width, + size: Some(Size::default()), }); return Size::default(); }; @@ -88,28 +197,25 @@ impl Element for Text { size.width = size.width.max(line_size.width); } - element_state - .lock() - .replace(TextStateInner { lines, line_height }); + element_state.lock().replace(TextStateInner { + lines, + line_height, + wrap_width, + size: Some(size), + }); size } }); - (layout_id, element_state) + layout_id } - fn paint( - &mut self, - bounds: Bounds, - _: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, - ) { - let element_state = element_state.lock(); + fn paint(&mut self, bounds: Bounds, text: &str, cx: &mut WindowContext) { + let element_state = self.lock(); let element_state = element_state .as_ref() - .ok_or_else(|| anyhow::anyhow!("measurement has not been performed on {}", &self.text)) + .ok_or_else(|| anyhow!("measurement has not been performed on {}", text)) .unwrap(); let line_height = element_state.line_height; @@ -121,23 +227,9 @@ impl Element for Text { } } -#[derive(Default, Clone)] -pub struct TextState(Arc>>); - -impl TextState { - fn lock(&self) -> MutexGuard> { - self.0.lock() - } -} - -struct TextStateInner { - lines: SmallVec<[WrappedLine; 1]>, - line_height: Pixels, -} - struct InteractiveText { - id: ElementId, - text: Text, + element_id: ElementId, + text: StyledText, } struct InteractiveTextState { @@ -145,32 +237,27 @@ struct InteractiveTextState { clicked_range_ixs: Rc>>, } -impl Element for InteractiveText { - type ElementState = InteractiveTextState; - - fn element_id(&self) -> Option { - Some(self.id.clone()) - } +impl Element for InteractiveText { + type State = InteractiveTextState; fn layout( &mut self, - view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { + state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { if let Some(InteractiveTextState { text_state, clicked_range_ixs, - }) = element_state + }) = state { - let (layout_id, text_state) = self.text.layout(view_state, Some(text_state), cx); + let (layout_id, text_state) = self.text.layout(Some(text_state), cx); let element_state = InteractiveTextState { text_state, clicked_range_ixs, }; (layout_id, element_state) } else { - let (layout_id, text_state) = self.text.layout(view_state, None, cx); + let (layout_id, text_state) = self.text.layout(None, cx); let element_state = InteractiveTextState { text_state, clicked_range_ixs: Rc::default(), @@ -179,46 +266,19 @@ impl Element for InteractiveText { } } - fn paint( - &mut self, - bounds: Bounds, - view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, - ) { - self.text - .paint(bounds, view_state, &mut element_state.text_state, cx) + fn paint(self, bounds: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + self.text.paint(bounds, &mut state.text_state, cx) } } -impl Component for SharedString { - fn render(self) -> AnyElement { - Text { - text: self, - runs: None, - } - .render() - } -} +impl RenderOnce for InteractiveText { + type Element = Self; -impl Component for &'static str { - fn render(self) -> AnyElement { - Text { - text: self.into(), - runs: None, - } - .render() + fn element_id(&self) -> Option { + Some(self.element_id.clone()) } -} -// TODO: Figure out how to pass `String` to `child` without this. -// This impl doesn't exist in the `gpui2` crate. -impl Component for String { - fn render(self) -> AnyElement { - Text { - text: self.into(), - runs: None, - } - .render() + fn render_once(self) -> Self::Element { + self } } diff --git a/crates/gpui2/src/elements/uniform_list.rs b/crates/gpui2/src/elements/uniform_list.rs index 773f9ec8aacfb2ce0ac77cc96b4c30311a406ba5..b24b3935fab874ff4b8443262170f1fb30248df5 100644 --- a/crates/gpui2/src/elements/uniform_list.rs +++ b/crates/gpui2/src/elements/uniform_list.rs @@ -1,40 +1,45 @@ use crate::{ - point, px, size, AnyElement, AvailableSpace, BorrowWindow, Bounds, Component, Element, - ElementId, InteractiveComponent, InteractiveElementState, Interactivity, LayoutId, Pixels, - Point, Size, StyleRefinement, Styled, ViewContext, + point, px, size, AnyElement, AvailableSpace, Bounds, Element, ElementId, InteractiveElement, + InteractiveElementState, Interactivity, LayoutId, Pixels, Point, Render, RenderOnce, Size, + StyleRefinement, Styled, View, ViewContext, WindowContext, }; use smallvec::SmallVec; -use std::{cell::RefCell, cmp, mem, ops::Range, rc::Rc}; +use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; use taffy::style::Overflow; /// uniform_list provides lazy rendering for a set of items that are of uniform height. /// When rendered into a container with overflow-y: hidden and a fixed (or max) height, /// uniform_list will only render the visibile subset of items. -pub fn uniform_list( +pub fn uniform_list( + view: View, id: I, item_count: usize, - f: impl 'static + Fn(&mut V, Range, &mut ViewContext) -> Vec, -) -> UniformList + f: impl 'static + Fn(&mut V, Range, &mut ViewContext) -> Vec, +) -> UniformList where I: Into, - V: 'static, - C: Component, + R: RenderOnce, + V: Render, { let id = id.into(); let mut style = StyleRefinement::default(); style.overflow.y = Some(Overflow::Hidden); + let render_range = move |range, cx: &mut WindowContext| { + view.update(cx, |this, cx| { + f(this, range, cx) + .into_iter() + .map(|component| component.render_into_any()) + .collect() + }) + }; + UniformList { id: id.clone(), style, item_count, item_to_measure_index: 0, - render_items: Box::new(move |view, visible_range, cx| { - f(view, visible_range, cx) - .into_iter() - .map(|component| component.render()) - .collect() - }), + render_items: Box::new(render_range), interactivity: Interactivity { element_id: Some(id.into()), ..Default::default() @@ -43,19 +48,14 @@ where } } -pub struct UniformList { +pub struct UniformList { id: ElementId, style: StyleRefinement, item_count: usize, item_to_measure_index: usize, - render_items: Box< - dyn for<'a> Fn( - &'a mut V, - Range, - &'a mut ViewContext, - ) -> SmallVec<[AnyElement; 64]>, - >, - interactivity: Interactivity, + render_items: + Box Fn(Range, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>, + interactivity: Interactivity, scroll_handle: Option, } @@ -89,7 +89,7 @@ impl UniformListScrollHandle { } } -impl Styled for UniformList { +impl Styled for UniformList { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } @@ -101,29 +101,24 @@ pub struct UniformListState { item_size: Size, } -impl Element for UniformList { - type ElementState = UniformListState; - - fn element_id(&self) -> Option { - Some(self.id.clone()) - } +impl Element for UniformList { + type State = UniformListState; fn layout( &mut self, - view_state: &mut V, - element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { + state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { let max_items = self.item_count; let rem_size = cx.rem_size(); - let item_size = element_state + let item_size = state .as_ref() .map(|s| s.item_size) - .unwrap_or_else(|| self.measure_item(view_state, None, cx)); + .unwrap_or_else(|| self.measure_item(None, cx)); let (layout_id, interactive) = self.interactivity - .layout(element_state.map(|s| s.interactive), cx, |style, cx| { + .layout(state.map(|s| s.interactive), cx, |style, cx| { cx.request_measured_layout( style, rem_size, @@ -159,11 +154,10 @@ impl Element for UniformList { } fn paint( - &mut self, + self, bounds: Bounds, - view_state: &mut V, - element_state: &mut Self::ElementState, - cx: &mut ViewContext, + element_state: &mut Self::State, + cx: &mut WindowContext, ) { let style = self.interactivity @@ -183,14 +177,15 @@ impl Element for UniformList { height: item_size.height * self.item_count, }; - let mut interactivity = mem::take(&mut self.interactivity); let shared_scroll_offset = element_state .interactive .scroll_offset .get_or_insert_with(Rc::default) .clone(); - interactivity.paint( + let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height; + + self.interactivity.paint( bounds, content_size, &mut element_state.interactive, @@ -209,9 +204,6 @@ impl Element for UniformList { style.paint(bounds, cx); if self.item_count > 0 { - let item_height = self - .measure_item(view_state, Some(padded_bounds.size.width), cx) - .height; if let Some(scroll_handle) = self.scroll_handle.clone() { scroll_handle.0.borrow_mut().replace(ScrollHandleState { item_height, @@ -233,44 +225,50 @@ impl Element for UniformList { self.item_count, ); - let mut items = (self.render_items)(view_state, visible_range.clone(), cx); + let items = (self.render_items)(visible_range.clone(), cx); cx.with_z_index(1, |cx| { - for (item, ix) in items.iter_mut().zip(visible_range) { + for (item, ix) in items.into_iter().zip(visible_range) { let item_origin = padded_bounds.origin + point(px(0.), item_height * ix + scroll_offset.y); let available_space = size( AvailableSpace::Definite(padded_bounds.size.width), AvailableSpace::Definite(item_height), ); - item.draw(item_origin, available_space, view_state, cx); + item.draw(item_origin, available_space, cx); } }); } }) }, ); - self.interactivity = interactivity; } } -impl UniformList { +impl RenderOnce for UniformList { + type Element = Self; + + fn element_id(&self) -> Option { + Some(self.id.clone()) + } + + fn render_once(self) -> Self::Element { + self + } +} + +impl UniformList { pub fn with_width_from_item(mut self, item_index: Option) -> Self { self.item_to_measure_index = item_index.unwrap_or(0); self } - fn measure_item( - &self, - view_state: &mut V, - list_width: Option, - cx: &mut ViewContext, - ) -> Size { + fn measure_item(&self, list_width: Option, cx: &mut WindowContext) -> Size { if self.item_count == 0 { return Size::default(); } let item_ix = cmp::min(self.item_to_measure_index, self.item_count - 1); - let mut items = (self.render_items)(view_state, item_ix..item_ix + 1, cx); + let mut items = (self.render_items)(item_ix..item_ix + 1, cx); let mut item_to_measure = items.pop().unwrap(); let available_space = size( list_width.map_or(AvailableSpace::MinContent, |width| { @@ -278,7 +276,7 @@ impl UniformList { }), AvailableSpace::MinContent, ); - item_to_measure.measure(available_space, view_state, cx) + item_to_measure.measure(available_space, cx) } pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self { @@ -287,14 +285,8 @@ impl UniformList { } } -impl InteractiveComponent for UniformList { - fn interactivity(&mut self) -> &mut crate::Interactivity { +impl InteractiveElement for UniformList { + fn interactivity(&mut self) -> &mut crate::Interactivity { &mut self.interactivity } } - -impl Component for UniformList { - fn render(self) -> AnyElement { - AnyElement::new(self) - } -} diff --git a/crates/gpui2/src/gpui2.rs b/crates/gpui2/src/gpui2.rs index 88ecd52c037415696cefc6e663758ed7dcfcab8f..984859f1b005f8fa2edd3256de75c1aa3010ce2b 100644 --- a/crates/gpui2/src/gpui2.rs +++ b/crates/gpui2/src/gpui2.rs @@ -78,8 +78,6 @@ use std::{ }; use taffy::TaffyLayoutEngine; -type AnyBox = Box; - pub trait Context { type Result; @@ -136,11 +134,15 @@ pub trait VisualContext: Context { build_view: impl FnOnce(&mut ViewContext<'_, V>) -> V, ) -> Self::Result> where - V: Render; + V: 'static + Render; fn focus_view(&mut self, view: &View) -> Self::Result<()> where V: FocusableView; + + fn dismiss_view(&mut self, view: &View) -> Self::Result<()> + where + V: ManagedView; } pub trait Entity: Sealed { diff --git a/crates/gpui2/src/image_cache.rs b/crates/gpui2/src/image_cache.rs index 6417f7d5e133991c41593c81bc7088fb46a04129..f80b0f0c2f71a60fa91dbf87a13ffa3b86f43abf 100644 --- a/crates/gpui2/src/image_cache.rs +++ b/crates/gpui2/src/image_cache.rs @@ -2,7 +2,7 @@ use crate::{ImageData, ImageId, SharedString}; use collections::HashMap; use futures::{ future::{BoxFuture, Shared}, - AsyncReadExt, FutureExt, + AsyncReadExt, FutureExt, TryFutureExt, }; use image::ImageError; use parking_lot::Mutex; @@ -88,6 +88,14 @@ impl ImageCache { Ok(Arc::new(ImageData::new(image))) } } + .map_err({ + let uri = uri.clone(); + + move |error| { + log::log!(log::Level::Error, "{:?} {:?}", &uri, &error); + error + } + }) .boxed() .shared(); diff --git a/crates/gpui2/src/input.rs b/crates/gpui2/src/input.rs index 140f72441794d3b66b562ba4bbc22b74e4131d4d..8592eeffeb3573e4d77e55e252a814630d3c959d 100644 --- a/crates/gpui2/src/input.rs +++ b/crates/gpui2/src/input.rs @@ -1,4 +1,6 @@ -use crate::{AsyncWindowContext, Bounds, Pixels, PlatformInputHandler, View, ViewContext}; +use crate::{ + AsyncWindowContext, Bounds, Pixels, PlatformInputHandler, View, ViewContext, WindowContext, +}; use std::ops::Range; /// Implement this trait to allow views to handle textual input when implementing an editor, field, etc. @@ -43,9 +45,9 @@ pub struct ElementInputHandler { impl ElementInputHandler { /// Used in [Element::paint] with the element's bounds and a view context for its /// containing view. - pub fn new(element_bounds: Bounds, cx: &mut ViewContext) -> Self { + pub fn new(element_bounds: Bounds, view: View, cx: &mut WindowContext) -> Self { ElementInputHandler { - view: cx.view().clone(), + view, element_bounds, cx: cx.to_async(), } diff --git a/crates/gpui2/src/interactive.rs b/crates/gpui2/src/interactive.rs index 80a89ef6257497459c848df60e68187a01e7142d..3dfe0c3b436dda921b8fe10727fdcd548a114e3e 100644 --- a/crates/gpui2/src/interactive.rs +++ b/crates/gpui2/src/interactive.rs @@ -1,5 +1,5 @@ use crate::{ - div, point, Component, Div, FocusHandle, Keystroke, Modifiers, Pixels, Point, Render, + div, point, Div, Element, FocusHandle, Keystroke, Modifiers, Pixels, Point, Render, RenderOnce, ViewContext, }; use smallvec::SmallVec; @@ -64,24 +64,24 @@ pub struct Drag where R: Fn(&mut V, &mut ViewContext) -> E, V: 'static, - E: Component<()>, + E: RenderOnce, { pub state: S, pub render_drag_handle: R, - view_type: PhantomData, + view_element_types: PhantomData<(V, E)>, } impl Drag where R: Fn(&mut V, &mut ViewContext) -> E, V: 'static, - E: Component<()>, + E: Element, { pub fn new(state: S, render_drag_handle: R) -> Self { Drag { state, render_drag_handle, - view_type: PhantomData, + view_element_types: Default::default(), } } } @@ -194,7 +194,7 @@ impl Deref for MouseExitEvent { pub struct ExternalPaths(pub(crate) SmallVec<[PathBuf; 2]>); impl Render for ExternalPaths { - type Element = Div; + type Element = Div; fn render(&mut self, _: &mut ViewContext) -> Self::Element { div() // Intentionally left empty because the platform will render icons for the dragged files @@ -286,8 +286,8 @@ pub struct FocusEvent { #[cfg(test)] mod test { use crate::{ - self as gpui, div, Component, Div, FocusHandle, InteractiveComponent, KeyBinding, - Keystroke, ParentComponent, Render, Stateful, TestAppContext, ViewContext, VisualContext, + self as gpui, div, Div, FocusHandle, InteractiveElement, KeyBinding, Keystroke, + ParentElement, Render, RenderOnce, Stateful, TestAppContext, VisualContext, }; struct TestView { @@ -299,20 +299,24 @@ mod test { actions!(TestAction); impl Render for TestView { - type Element = Stateful>; + type Element = Stateful
; - fn render(&mut self, _: &mut gpui::ViewContext) -> Self::Element { + fn render(&mut self, cx: &mut gpui::ViewContext) -> Self::Element { div().id("testview").child( div() .key_context("parent") - .on_key_down(|this: &mut TestView, _, _, _| this.saw_key_down = true) - .on_action(|this: &mut TestView, _: &TestAction, _| this.saw_action = true) - .child(|this: &mut Self, _cx: &mut ViewContext| { + .on_key_down(cx.listener(|this, _, _| this.saw_key_down = true)) + .on_action( + cx.listener(|this: &mut TestView, _: &TestAction, _| { + this.saw_action = true + }), + ) + .child( div() .key_context("nested") - .track_focus(&this.focus_handle) - .render() - }), + .track_focus(&self.focus_handle) + .render_once(), + ), ) } } diff --git a/crates/gpui2/src/platform/mac/window.rs b/crates/gpui2/src/platform/mac/window.rs index 03782d13a84a0cb36e681a6a06470054c61e28e5..bb3a659a62bb998d191d28e31e796e31ca1eb3fe 100644 --- a/crates/gpui2/src/platform/mac/window.rs +++ b/crates/gpui2/src/platform/mac/window.rs @@ -1205,10 +1205,7 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { InputEvent::MouseMove(_) if !(is_active || lock.kind == WindowKind::PopUp) => return, - InputEvent::MouseUp(MouseUpEvent { - button: MouseButton::Left, - .. - }) => { + InputEvent::MouseUp(MouseUpEvent { .. }) => { lock.synthetic_drag_counter += 1; } diff --git a/crates/gpui2/src/prelude.rs b/crates/gpui2/src/prelude.rs index 7c2ad3f07ff8877d83342f1ac60087b9859eacbc..50f48596bcecab152bc5cb933d07bfc49b256602 100644 --- a/crates/gpui2/src/prelude.rs +++ b/crates/gpui2/src/prelude.rs @@ -1,4 +1,5 @@ pub use crate::{ - BorrowAppContext, BorrowWindow, Component, Context, FocusableComponent, InteractiveComponent, - ParentComponent, Refineable, Render, StatefulInteractiveComponent, Styled, VisualContext, + BorrowAppContext, BorrowWindow, Component, Context, Element, FocusableElement, + InteractiveElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, + Styled, VisualContext, }; diff --git a/crates/gpui2/src/style.rs b/crates/gpui2/src/style.rs index 1b0cabb40154575f60ead9d8404bfa7c0fb5ee34..f958b8b44cc3fbfbdcae462905c936d234643581 100644 --- a/crates/gpui2/src/style.rs +++ b/crates/gpui2/src/style.rs @@ -2,7 +2,7 @@ use crate::{ black, phi, point, rems, AbsoluteLength, BorrowAppContext, BorrowWindow, Bounds, ContentMask, Corners, CornersRefinement, CursorStyle, DefiniteLength, Edges, EdgesRefinement, Font, FontFeatures, FontStyle, FontWeight, Hsla, Length, Pixels, Point, PointRefinement, Rgba, - SharedString, Size, SizeRefinement, Styled, TextRun, ViewContext, + SharedString, Size, SizeRefinement, Styled, TextRun, WindowContext, }; use refineable::{Cascade, Refineable}; use smallvec::SmallVec; @@ -313,7 +313,7 @@ impl Style { } /// Paints the background of an element styled with this style. - pub fn paint(&self, bounds: Bounds, cx: &mut ViewContext) { + pub fn paint(&self, bounds: Bounds, cx: &mut WindowContext) { let rem_size = cx.rem_size(); cx.with_z_index(0, |cx| { diff --git a/crates/gpui2/src/taffy.rs b/crates/gpui2/src/taffy.rs index ea87f73872cd445ee37e530d973d5e0e054a76fd..81a057055a1af794d0d19f0921d7807e09417f70 100644 --- a/crates/gpui2/src/taffy.rs +++ b/crates/gpui2/src/taffy.rs @@ -5,12 +5,14 @@ use std::fmt::Debug; use taffy::{ geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize}, style::AvailableSpace as TaffyAvailableSpace, - tree::{Measurable, MeasureFunc, NodeId}, + tree::NodeId, Taffy, }; +type Measureable = dyn Fn(Size>, Size) -> Size + Send + Sync; + pub struct TaffyLayoutEngine { - taffy: Taffy, + taffy: Taffy>, children_to_parents: HashMap, absolute_layout_bounds: HashMap>, computed_layouts: HashSet, @@ -70,9 +72,9 @@ impl TaffyLayoutEngine { ) -> LayoutId { let style = style.to_taffy(rem_size); - let measurable = Box::new(Measureable(measure)) as Box; + let measurable = Box::new(measure); self.taffy - .new_leaf_with_measure(style, MeasureFunc::Boxed(measurable)) + .new_leaf_with_context(style, measurable) .expect(EXPECT_MESSAGE) .into() } @@ -154,7 +156,22 @@ impl TaffyLayoutEngine { // let started_at = std::time::Instant::now(); self.taffy - .compute_layout(id.into(), available_space.into()) + .compute_layout_with_measure( + id.into(), + available_space.into(), + |known_dimensions, available_space, _node_id, context| { + let Some(measure) = context else { + return taffy::geometry::Size::default(); + }; + + let known_dimensions = Size { + width: known_dimensions.width.map(Pixels), + height: known_dimensions.height.map(Pixels), + }; + + measure(known_dimensions, available_space.into()).into() + }, + ) .expect(EXPECT_MESSAGE); // println!("compute_layout took {:?}", started_at.elapsed()); } @@ -202,25 +219,6 @@ impl From for NodeId { } } -struct Measureable(F); - -impl taffy::tree::Measurable for Measureable -where - F: Fn(Size>, Size) -> Size + Send + Sync, -{ - fn measure( - &self, - known_dimensions: TaffySize>, - available_space: TaffySize, - ) -> TaffySize { - let known_dimensions: Size> = known_dimensions.into(); - let known_dimensions: Size> = known_dimensions.map(|d| d.map(Into::into)); - let available_space = available_space.into(); - let size = (self.0)(known_dimensions, available_space); - size.into() - } -} - trait ToTaffy { fn to_taffy(&self, rem_size: Pixels) -> Output; } diff --git a/crates/gpui2/src/view.rs b/crates/gpui2/src/view.rs index 6b8c8a3eefcfcd79c955ecfbed170212bbe0b12c..efa40627ace6ae151c94ff3cc28c7072ede68450 100644 --- a/crates/gpui2/src/view.rs +++ b/crates/gpui2/src/view.rs @@ -1,23 +1,17 @@ use crate::{ - private::Sealed, AnyBox, AnyElement, AnyModel, AnyWeakModel, AppContext, AvailableSpace, - BorrowWindow, Bounds, Component, Element, ElementId, Entity, EntityId, Flatten, FocusHandle, - FocusableView, LayoutId, Model, Pixels, Point, Size, ViewContext, VisualContext, WeakModel, + private::Sealed, AnyElement, AnyModel, AnyWeakModel, AppContext, AvailableSpace, BorrowWindow, + Bounds, Element, ElementId, Entity, EntityId, Flatten, FocusHandle, FocusableView, LayoutId, + Model, Pixels, Point, Render, RenderOnce, Size, ViewContext, VisualContext, WeakModel, WindowContext, }; use anyhow::{Context, Result}; use std::{ - any::{Any, TypeId}, + any::TypeId, hash::{Hash, Hasher}, }; -pub trait Render: 'static + Sized { - type Element: Element + 'static; - - fn render(&mut self, cx: &mut ViewContext) -> Self::Element; -} - pub struct View { - pub(crate) model: Model, + pub model: Model, } impl Sealed for View {} @@ -65,15 +59,15 @@ impl View { self.model.read(cx) } - pub fn render_with(&self, component: C) -> RenderViewWith - where - C: 'static + Component, - { - RenderViewWith { - view: self.clone(), - component: Some(component), - } - } + // pub fn render_with(&self, component: E) -> RenderViewWith + // where + // E: 'static + Element, + // { + // RenderViewWith { + // view: self.clone(), + // element: Some(component), + // } + // } pub fn focus_handle(&self, cx: &AppContext) -> FocusHandle where @@ -83,6 +77,24 @@ impl View { } } +impl Element for View { + type State = Option; + + fn layout( + &mut self, + _state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + let mut element = self.update(cx, |view, cx| view.render(cx).into_any()); + let layout_id = element.layout(cx); + (layout_id, Some(element)) + } + + fn paint(self, _: Bounds, element: &mut Self::State, cx: &mut WindowContext) { + element.take().unwrap().paint(cx); + } +} + impl Clone for View { fn clone(&self) -> Self { Self { @@ -105,12 +117,6 @@ impl PartialEq for View { impl Eq for View {} -impl Component for View { - fn render(self) -> AnyElement { - AnyElement::new(AnyView::from(self)) - } -} - pub struct WeakView { pub(crate) model: WeakModel, } @@ -163,8 +169,8 @@ impl Eq for WeakView {} #[derive(Clone, Debug)] pub struct AnyView { model: AnyModel, - layout: fn(&AnyView, &mut WindowContext) -> (LayoutId, Box), - paint: fn(&AnyView, &mut AnyBox, &mut WindowContext), + layout: fn(&AnyView, &mut WindowContext) -> (LayoutId, AnyElement), + paint: fn(&AnyView, AnyElement, &mut WindowContext), } impl AnyView { @@ -191,6 +197,10 @@ impl AnyView { self.model.entity_type } + pub fn entity_id(&self) -> EntityId { + self.model.entity_id() + } + pub(crate) fn draw( &self, origin: Point, @@ -198,21 +208,15 @@ impl AnyView { cx: &mut WindowContext, ) { cx.with_absolute_element_offset(origin, |cx| { - let (layout_id, mut rendered_element) = (self.layout)(self, cx); + let (layout_id, rendered_element) = (self.layout)(self, cx); cx.window .layout_engine .compute_layout(layout_id, available_space); - (self.paint)(self, &mut rendered_element, cx); + (self.paint)(self, rendered_element, cx); }) } } -impl Component for AnyView { - fn render(self) -> AnyElement { - AnyElement::new(self) - } -} - impl From> for AnyView { fn from(value: View) -> Self { AnyView { @@ -223,37 +227,51 @@ impl From> for AnyView { } } -impl Element for AnyView { - type ElementState = Box; +impl Element for AnyView { + type State = Option; + + fn layout( + &mut self, + _state: Option, + cx: &mut WindowContext, + ) -> (LayoutId, Self::State) { + let (layout_id, state) = (self.layout)(self, cx); + (layout_id, Some(state)) + } + + fn paint(self, _: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + (self.paint)(&self, state.take().unwrap(), cx) + } +} + +impl RenderOnce for View { + type Element = View; fn element_id(&self) -> Option { Some(self.model.entity_id.into()) } - fn layout( - &mut self, - _view_state: &mut ParentViewState, - _element_state: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { - (self.layout)(self, cx) + fn render_once(self) -> Self::Element { + self } +} - fn paint( - &mut self, - _bounds: Bounds, - _view_state: &mut ParentViewState, - rendered_element: &mut Self::ElementState, - cx: &mut ViewContext, - ) { - (self.paint)(self, rendered_element, cx) +impl RenderOnce for AnyView { + type Element = Self; + + fn element_id(&self) -> Option { + Some(self.model.entity_id.into()) + } + + fn render_once(self) -> Self::Element { + self } } pub struct AnyWeakView { model: AnyWeakModel, - layout: fn(&AnyView, &mut WindowContext) -> (LayoutId, Box), - paint: fn(&AnyView, &mut AnyBox, &mut WindowContext), + layout: fn(&AnyView, &mut WindowContext) -> (LayoutId, AnyElement), + paint: fn(&AnyView, AnyElement, &mut WindowContext), } impl AnyWeakView { @@ -267,7 +285,7 @@ impl AnyWeakView { } } -impl From> for AnyWeakView { +impl From> for AnyWeakView { fn from(view: WeakView) -> Self { Self { model: view.model.into(), @@ -280,7 +298,7 @@ impl From> for AnyWeakView { impl Render for T where T: 'static + FnMut(&mut WindowContext) -> E, - E: 'static + Send + Element, + E: 'static + Send + Element, { type Element = E; @@ -289,85 +307,28 @@ where } } -pub struct RenderViewWith { - view: View, - component: Option, -} - -impl Component for RenderViewWith -where - C: 'static + Component, - ParentViewState: 'static, - ViewState: 'static, -{ - fn render(self) -> AnyElement { - AnyElement::new(self) - } -} - -impl Element for RenderViewWith -where - C: 'static + Component, - ParentViewState: 'static, - ViewState: 'static, -{ - type ElementState = AnyElement; - - fn element_id(&self) -> Option { - Some(self.view.entity_id().into()) - } - - fn layout( - &mut self, - _: &mut ParentViewState, - _: Option, - cx: &mut ViewContext, - ) -> (LayoutId, Self::ElementState) { - self.view.update(cx, |view, cx| { - let mut element = self.component.take().unwrap().render(); - let layout_id = element.layout(view, cx); - (layout_id, element) - }) - } - - fn paint( - &mut self, - _: Bounds, - _: &mut ParentViewState, - element: &mut Self::ElementState, - cx: &mut ViewContext, - ) { - self.view.update(cx, |view, cx| element.paint(view, cx)) - } -} - mod any_view { - use crate::{AnyElement, AnyView, BorrowWindow, LayoutId, Render, WindowContext}; - use std::any::Any; + use crate::{AnyElement, AnyView, BorrowWindow, Element, LayoutId, Render, WindowContext}; - pub(crate) fn layout( + pub(crate) fn layout( view: &AnyView, cx: &mut WindowContext, - ) -> (LayoutId, Box) { + ) -> (LayoutId, AnyElement) { cx.with_element_id(Some(view.model.entity_id), |cx| { let view = view.clone().downcast::().unwrap(); - view.update(cx, |view, cx| { - let mut element = AnyElement::new(view.render(cx)); - let layout_id = element.layout(view, cx); - (layout_id, Box::new(element) as Box) - }) + let mut element = view.update(cx, |view, cx| view.render(cx).into_any()); + let layout_id = element.layout(cx); + (layout_id, element) }) } - pub(crate) fn paint( + pub(crate) fn paint( view: &AnyView, - element: &mut Box, + element: AnyElement, cx: &mut WindowContext, ) { cx.with_element_id(Some(view.model.entity_id), |cx| { - let view = view.clone().downcast::().unwrap(); - let element = element.downcast_mut::>().unwrap(); - view.update(cx, |view, cx| element.paint(view, cx)) + element.paint(cx); }) } } diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index 6d07f06d9441b838828f7cf15ab0c2a6da72ff4e..483a8fdbee41515f635c2bd3a85baaaa26c3afd8 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -1,15 +1,15 @@ use crate::{ - key_dispatch::DispatchActionListener, px, size, Action, AnyBox, AnyDrag, AnyView, AppContext, + key_dispatch::DispatchActionListener, px, size, Action, AnyDrag, AnyView, AppContext, AsyncWindowContext, AvailableSpace, Bounds, BoxShadow, Context, Corners, CursorStyle, DevicePixels, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, - EventEmitter, FileDropEvent, FocusEvent, FontId, GlobalElementId, GlyphId, Hsla, ImageData, - InputEvent, IsZero, KeyBinding, KeyContext, KeyDownEvent, LayoutId, Model, ModelContext, - Modifiers, MonochromeSprite, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Path, - Pixels, PlatformAtlas, PlatformDisplay, PlatformInputHandler, PlatformWindow, Point, - PolychromeSprite, PromptLevel, Quad, Render, RenderGlyphParams, RenderImageParams, - RenderSvgParams, ScaledPixels, SceneBuilder, Shadow, SharedString, Size, Style, SubscriberSet, - Subscription, TaffyLayoutEngine, Task, Underline, UnderlineStyle, View, VisualContext, - WeakView, WindowBounds, WindowOptions, SUBPIXEL_VARIANTS, + EventEmitter, FileDropEvent, Flatten, FocusEvent, FontId, GlobalElementId, GlyphId, Hsla, + ImageData, InputEvent, IsZero, KeyBinding, KeyContext, KeyDownEvent, LayoutId, Model, + ModelContext, Modifiers, MonochromeSprite, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInputHandler, + PlatformWindow, Point, PolychromeSprite, PromptLevel, Quad, Render, RenderGlyphParams, + RenderImageParams, RenderSvgParams, ScaledPixels, SceneBuilder, Shadow, SharedString, Size, + Style, SubscriberSet, Subscription, TaffyLayoutEngine, Task, Underline, UnderlineStyle, View, + VisualContext, WeakView, WindowBounds, WindowOptions, SUBPIXEL_VARIANTS, }; use anyhow::{anyhow, Context as _, Result}; use collections::HashMap; @@ -187,23 +187,18 @@ impl Drop for FocusHandle { /// FocusableView allows users of your view to easily /// focus it (using cx.focus_view(view)) -pub trait FocusableView: Render { +pub trait FocusableView: 'static + Render { fn focus_handle(&self, cx: &AppContext) -> FocusHandle; } /// ManagedView is a view (like a Modal, Popover, Menu, etc.) /// where the lifecycle of the view is handled by another view. -pub trait ManagedView: Render { - fn focus_handle(&self, cx: &AppContext) -> FocusHandle; -} +pub trait ManagedView: FocusableView + EventEmitter {} -pub struct Dismiss; -impl EventEmitter for T {} +impl> ManagedView for M {} -impl FocusableView for T { - fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.focus_handle(cx) - } +pub enum Manager { + Dismiss, } // Holds the state for a specific window. @@ -237,7 +232,7 @@ pub struct Window { // #[derive(Default)] pub(crate) struct Frame { - pub(crate) element_states: HashMap, + pub(crate) element_states: HashMap>, mouse_listeners: HashMap>, pub(crate) dispatch_tree: DispatchTree, pub(crate) focus_listeners: Vec, @@ -1441,6 +1436,82 @@ impl<'a> WindowContext<'a> { .dispatch_tree .bindings_for_action(action) } + + pub fn listener_for( + &self, + view: &View, + f: impl Fn(&mut V, &E, &mut ViewContext) + 'static, + ) -> impl Fn(&E, &mut WindowContext) + 'static { + let view = view.downgrade(); + move |e: &E, cx: &mut WindowContext| { + view.update(cx, |view, cx| f(view, e, cx)).ok(); + } + } + + pub fn constructor_for( + &self, + view: &View, + f: impl Fn(&mut V, &mut ViewContext) -> R + 'static, + ) -> impl Fn(&mut WindowContext) -> R + 'static { + let view = view.clone(); + move |cx: &mut WindowContext| view.update(cx, |view, cx| f(view, cx)) + } + + //========== ELEMENT RELATED FUNCTIONS =========== + pub fn with_key_dispatch( + &mut self, + context: KeyContext, + focus_handle: Option, + f: impl FnOnce(Option, &mut Self) -> R, + ) -> R { + let window = &mut self.window; + window + .current_frame + .dispatch_tree + .push_node(context.clone()); + if let Some(focus_handle) = focus_handle.as_ref() { + window + .current_frame + .dispatch_tree + .make_focusable(focus_handle.id); + } + let result = f(focus_handle, self); + + self.window.current_frame.dispatch_tree.pop_node(); + + result + } + + /// Register a focus listener for the current frame only. It will be cleared + /// on the next frame render. You should use this method only from within elements, + /// and we may want to enforce that better via a different context type. + // todo!() Move this to `FrameContext` to emphasize its individuality? + pub fn on_focus_changed( + &mut self, + listener: impl Fn(&FocusEvent, &mut WindowContext) + 'static, + ) { + self.window + .current_frame + .focus_listeners + .push(Box::new(move |event, cx| { + listener(event, cx); + })); + } + + /// Set an input handler, such as [ElementInputHandler], which interfaces with the + /// platform to receive textual input with proper integration with concerns such + /// as IME interactions. + pub fn handle_input( + &mut self, + focus_handle: &FocusHandle, + input_handler: impl PlatformInputHandler, + ) { + if focus_handle.is_focused(self) { + self.window + .platform_window + .set_input_handler(Box::new(input_handler)); + } + } } impl Context for WindowContext<'_> { @@ -1564,7 +1635,7 @@ impl VisualContext for WindowContext<'_> { build_view: impl FnOnce(&mut ViewContext<'_, V>) -> V, ) -> Self::Result> where - V: Render, + V: 'static + Render, { let slot = self.app.entities.reserve(); let view = View { @@ -1582,6 +1653,13 @@ impl VisualContext for WindowContext<'_> { view.focus_handle(cx).clone().focus(cx); }) } + + fn dismiss_view(&mut self, view: &View) -> Self::Result<()> + where + V: ManagedView, + { + self.update_view(view, |_, cx| cx.emit(Manager::Dismiss)) + } } impl<'a> std::ops::Deref for WindowContext<'a> { @@ -1615,6 +1693,10 @@ pub trait BorrowWindow: BorrowMut + BorrowMut { self.borrow_mut() } + fn app(&self) -> &AppContext { + self.borrow() + } + fn window(&self) -> &Window { self.borrow() } @@ -2122,49 +2204,6 @@ impl<'a, V: 'static> ViewContext<'a, V> { ) } - /// Register a focus listener for the current frame only. It will be cleared - /// on the next frame render. You should use this method only from within elements, - /// and we may want to enforce that better via a different context type. - // todo!() Move this to `FrameContext` to emphasize its individuality? - pub fn on_focus_changed( - &mut self, - listener: impl Fn(&mut V, &FocusEvent, &mut ViewContext) + 'static, - ) { - let handle = self.view().downgrade(); - self.window - .current_frame - .focus_listeners - .push(Box::new(move |event, cx| { - handle - .update(cx, |view, cx| listener(view, event, cx)) - .log_err(); - })); - } - - pub fn with_key_dispatch( - &mut self, - context: KeyContext, - focus_handle: Option, - f: impl FnOnce(Option, &mut Self) -> R, - ) -> R { - let window = &mut self.window; - window - .current_frame - .dispatch_tree - .push_node(context.clone()); - if let Some(focus_handle) = focus_handle.as_ref() { - window - .current_frame - .dispatch_tree - .make_focusable(focus_handle.id); - } - let result = f(focus_handle, self); - - self.window.current_frame.dispatch_tree.pop_node(); - - result - } - pub fn spawn( &mut self, f: impl FnOnce(WeakView, AsyncWindowContext) -> Fut, @@ -2241,21 +2280,6 @@ impl<'a, V: 'static> ViewContext<'a, V> { }); } - /// Set an input handler, such as [ElementInputHandler], which interfaces with the - /// platform to receive textual input with proper integration with concerns such - /// as IME interactions. - pub fn handle_input( - &mut self, - focus_handle: &FocusHandle, - input_handler: impl PlatformInputHandler, - ) { - if focus_handle.is_focused(self) { - self.window - .platform_window - .set_input_handler(Box::new(input_handler)); - } - } - pub fn emit(&mut self, event: Evt) where Evt: 'static, @@ -2275,6 +2299,23 @@ impl<'a, V: 'static> ViewContext<'a, V> { { self.defer(|view, cx| view.focus_handle(cx).focus(cx)) } + + pub fn dismiss_self(&mut self) + where + V: ManagedView, + { + self.defer(|_, cx| cx.emit(Manager::Dismiss)) + } + + pub fn listener( + &self, + f: impl Fn(&mut V, &E, &mut ViewContext) + 'static, + ) -> impl Fn(&E, &mut WindowContext) + 'static { + let view = self.view().downgrade(); + move |e: &E, cx: &mut WindowContext| { + view.update(cx, |view, cx| f(view, e, cx)).ok(); + } + } } impl Context for ViewContext<'_, V> { @@ -2346,7 +2387,7 @@ impl VisualContext for ViewContext<'_, V> { build_view: impl FnOnce(&mut ViewContext<'_, W>) -> W, ) -> Self::Result> where - W: Render, + W: 'static + Render, { self.window_cx.replace_root_view(build_view) } @@ -2354,6 +2395,10 @@ impl VisualContext for ViewContext<'_, V> { fn focus_view(&mut self, view: &View) -> Self::Result<()> { self.window_cx.focus_view(view) } + + fn dismiss_view(&mut self, view: &View) -> Self::Result<()> { + self.window_cx.dismiss_view(view) + } } impl<'a, V> std::ops::Deref for ViewContext<'a, V> { @@ -2398,6 +2443,17 @@ impl WindowHandle { } } + pub fn root(&self, cx: &mut C) -> Result> + where + C: Context, + { + Flatten::flatten(cx.update_window(self.any_handle, |root_view, _| { + root_view + .downcast::() + .map_err(|_| anyhow!("the type of the window's root view has changed")) + })) + } + pub fn update( &self, cx: &mut C, @@ -2543,6 +2599,18 @@ pub enum ElementId { FocusHandle(FocusId), } +impl TryInto for ElementId { + type Error = anyhow::Error; + + fn try_into(self) -> anyhow::Result { + if let ElementId::Name(name) = self { + Ok(name) + } else { + Err(anyhow!("element id is not string")) + } + } +} + impl From for ElementId { fn from(id: EntityId) -> Self { ElementId::View(id) diff --git a/crates/gpui2_macros/src/derive_render_once.rs b/crates/gpui2_macros/src/derive_render_once.rs new file mode 100644 index 0000000000000000000000000000000000000000..efe6aab0bb9c43776a4f9da26d084c2577ca367f --- /dev/null +++ b/crates/gpui2_macros/src/derive_render_once.rs @@ -0,0 +1,27 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, DeriveInput}; + +pub fn derive_render_once(input: TokenStream) -> TokenStream { + let ast = parse_macro_input!(input as DeriveInput); + let type_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl(); + + let gen = quote! { + impl #impl_generics gpui::RenderOnce for #type_name #type_generics + #where_clause + { + type Element = gpui::CompositeElement; + + fn element_id(&self) -> Option { + None + } + + fn render_once(self) -> Self::Element { + gpui::CompositeElement::new(self) + } + } + }; + + gen.into() +} diff --git a/crates/gpui2_macros/src/gpui2_macros.rs b/crates/gpui2_macros/src/gpui2_macros.rs index 3ce8373689d077f64702584d91394f2285e909f5..6dd817e28033fe1239c4525e69f2f5deeead9042 100644 --- a/crates/gpui2_macros/src/gpui2_macros.rs +++ b/crates/gpui2_macros/src/gpui2_macros.rs @@ -1,16 +1,12 @@ mod action; mod derive_component; +mod derive_render_once; mod register_action; mod style_helpers; mod test; use proc_macro::TokenStream; -#[proc_macro] -pub fn style_helpers(args: TokenStream) -> TokenStream { - style_helpers::style_helpers(args) -} - #[proc_macro_derive(Action)] pub fn action(input: TokenStream) -> TokenStream { action::action(input) @@ -26,6 +22,16 @@ pub fn derive_component(input: TokenStream) -> TokenStream { derive_component::derive_component(input) } +#[proc_macro_derive(RenderOnce, attributes(view))] +pub fn derive_render_once(input: TokenStream) -> TokenStream { + derive_render_once::derive_render_once(input) +} + +#[proc_macro] +pub fn style_helpers(input: TokenStream) -> TokenStream { + style_helpers::style_helpers(input) +} + #[proc_macro_attribute] pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { test::test(args, function) diff --git a/crates/journal2/Cargo.toml b/crates/journal2/Cargo.toml index 72da3deb69fbc2e2899671852cfcca9c27761af4..ce307948ad2e7d42a745cb4e349a3f8c0972c6c1 100644 --- a/crates/journal2/Cargo.toml +++ b/crates/journal2/Cargo.toml @@ -9,7 +9,7 @@ path = "src/journal2.rs" doctest = false [dependencies] -editor = { path = "../editor" } +editor = { package = "editor2", path = "../editor2" } gpui = { package = "gpui2", path = "../gpui2" } util = { path = "../util" } workspace2 = { path = "../workspace2" } @@ -24,4 +24,4 @@ log.workspace = true shellexpand = "2.1.0" [dev-dependencies] -editor = { path = "../editor", features = ["test-support"] } +editor = { package="editor2", path = "../editor2", features = ["test-support"] } diff --git a/crates/live_kit_client2/build.rs b/crates/live_kit_client2/build.rs index b346b3168bc608006b6c32e4cfbd2c54ff0f9f1c..a2b7ef866d998c05352f656def209c82a5aac0af 100644 --- a/crates/live_kit_client2/build.rs +++ b/crates/live_kit_client2/build.rs @@ -61,12 +61,14 @@ fn build_bridge(swift_target: &SwiftTarget) { let swift_package_root = swift_package_root(); let swift_target_folder = swift_target_folder(); + let swift_cache_folder = swift_cache_folder(); if !Command::new("swift") .arg("build") .arg("--disable-automatic-resolution") .args(["--configuration", &env::var("PROFILE").unwrap()]) .args(["--triple", &swift_target.target.triple]) .args(["--build-path".into(), swift_target_folder]) + .args(["--cache-path".into(), swift_cache_folder]) .current_dir(&swift_package_root) .status() .unwrap() @@ -133,9 +135,17 @@ fn swift_package_root() -> PathBuf { } fn swift_target_folder() -> PathBuf { + let target = env::var("TARGET").unwrap(); env::current_dir() .unwrap() - .join(format!("../../target/{SWIFT_PACKAGE_NAME}")) + .join(format!("../../target/{target}/{SWIFT_PACKAGE_NAME}_target")) +} + +fn swift_cache_folder() -> PathBuf { + let target = env::var("TARGET").unwrap(); + env::current_dir() + .unwrap() + .join(format!("../../target/{target}/{SWIFT_PACKAGE_NAME}_cache")) } fn copy_dir(source: &Path, destination: &Path) { diff --git a/crates/picker2/src/picker2.rs b/crates/picker2/src/picker2.rs index 3491fc3d4a1c7bbbbe1d7e1f0c2cc2304e852bca..e58b7782efe49dda76717b7570f0b8daae3f020d 100644 --- a/crates/picker2/src/picker2.rs +++ b/crates/picker2/src/picker2.rs @@ -1,10 +1,10 @@ use editor::Editor; use gpui::{ - div, prelude::*, uniform_list, AppContext, Component, Div, FocusHandle, FocusableView, - MouseButton, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext, + div, prelude::*, uniform_list, AppContext, Div, FocusHandle, FocusableView, MouseButton, + MouseDownEvent, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext, }; use std::{cmp, sync::Arc}; -use ui::{prelude::*, v_stack, Divider, Label, TextColor}; +use ui::{prelude::*, v_stack, Color, Divider, Label}; pub struct Picker { pub delegate: D, @@ -15,7 +15,7 @@ pub struct Picker { } pub trait PickerDelegate: Sized + 'static { - type ListItem: Component>; + type ListItem: RenderOnce; fn match_count(&self) -> usize; fn selected_index(&self) -> usize; @@ -143,10 +143,10 @@ impl Picker { fn on_input_editor_event( &mut self, _: View, - event: &editor::Event, + event: &editor::EditorEvent, cx: &mut ViewContext, ) { - if let editor::Event::BufferEdited = event { + if let editor::EditorEvent::BufferEdited = event { let query = self.editor.read(cx).text(cx); self.update_matches(query, cx); } @@ -181,20 +181,20 @@ impl Picker { } impl Render for Picker { - type Element = Div; + type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { div() .key_context("picker") .size_full() .elevation_2(cx) - .on_action(Self::select_next) - .on_action(Self::select_prev) - .on_action(Self::select_first) - .on_action(Self::select_last) - .on_action(Self::cancel) - .on_action(Self::confirm) - .on_action(Self::secondary_confirm) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::secondary_confirm)) .child( v_stack() .py_0p5() @@ -208,31 +208,37 @@ impl Render for Picker { .p_1() .grow() .child( - uniform_list("candidates", self.delegate.match_count(), { - move |this: &mut Self, visible_range, cx| { - let selected_ix = this.delegate.selected_index(); - visible_range - .map(|ix| { - div() - .on_mouse_down( - MouseButton::Left, - move |this: &mut Self, event, cx| { - this.handle_click( - ix, - event.modifiers.command, - cx, - ) - }, - ) - .child(this.delegate.render_match( - ix, - ix == selected_ix, - cx, - )) - }) - .collect() - } - }) + uniform_list( + cx.view().clone(), + "candidates", + self.delegate.match_count(), + { + let selected_index = self.delegate.selected_index(); + + move |picker, visible_range, cx| { + visible_range + .map(|ix| { + div() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, cx| { + this.handle_click( + ix, + event.modifiers.command, + cx, + ) + }), + ) + .child(picker.delegate.render_match( + ix, + ix == selected_index, + cx, + )) + }) + .collect() + } + }, + ) .track_scroll(self.scroll_handle.clone()), ) .max_h_72() @@ -244,7 +250,7 @@ impl Render for Picker { v_stack().p_1().grow().child( div() .px_1() - .child(Label::new("No matches").color(TextColor::Muted)), + .child(Label::new("No matches").color(Color::Muted)), ), ) }) diff --git a/crates/project/src/ignore.rs b/crates/project/src/ignore.rs index 8bac08b96c3a7b920328d946723ae423404b529e..41e5746f13c3b372a186813d5863683acf4b9996 100644 --- a/crates/project/src/ignore.rs +++ b/crates/project/src/ignore.rs @@ -20,10 +20,6 @@ impl IgnoreStack { Arc::new(Self::All) } - pub fn is_all(&self) -> bool { - matches!(self, IgnoreStack::All) - } - pub fn append(self: Arc, abs_base_path: Arc, ignore: Arc) -> Arc { match self.as_ref() { IgnoreStack::All => self, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ab6cbd88c07ec8721d3adc2431964f5c69668d99..c24fb5eea1f620b43920d249e6d67b308549ae41 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -5548,7 +5548,16 @@ impl Project { .collect::>(); let background = cx.background().clone(); - let path_count: usize = snapshots.iter().map(|s| s.visible_file_count()).sum(); + let path_count: usize = snapshots + .iter() + .map(|s| { + if query.include_ignored() { + s.file_count() + } else { + s.visible_file_count() + } + }) + .sum(); if path_count == 0 { let (_, rx) = smol::channel::bounded(1024); return rx; @@ -5561,8 +5570,16 @@ impl Project { .iter() .filter_map(|(_, b)| { let buffer = b.upgrade(cx)?; - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - if let Some(path) = snapshot.file().map(|file| file.path()) { + let (is_ignored, snapshot) = buffer.update(cx, |buffer, cx| { + let is_ignored = buffer + .project_path(cx) + .and_then(|path| self.entry_for_path(&path, cx)) + .map_or(false, |entry| entry.is_ignored); + (is_ignored, buffer.snapshot()) + }); + if is_ignored && !query.include_ignored() { + return None; + } else if let Some(path) = snapshot.file().map(|file| file.path()) { Some((path.clone(), (buffer, snapshot))) } else { unnamed_files.push(buffer); @@ -5735,7 +5752,12 @@ impl Project { let mut snapshot_start_ix = 0; let mut abs_path = PathBuf::new(); for snapshot in snapshots { - let snapshot_end_ix = snapshot_start_ix + snapshot.visible_file_count(); + let snapshot_end_ix = snapshot_start_ix + + if query.include_ignored() { + snapshot.file_count() + } else { + snapshot.visible_file_count() + }; if worker_end_ix <= snapshot_start_ix { break; } else if worker_start_ix > snapshot_end_ix { @@ -5748,7 +5770,7 @@ impl Project { cmp::min(worker_end_ix, snapshot_end_ix) - snapshot_start_ix; for entry in snapshot - .files(false, start_in_snapshot) + .files(query.include_ignored(), start_in_snapshot) .take(end_in_snapshot - start_in_snapshot) { if matching_paths_tx.is_closed() { diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index 607b2848139aa88b1d36821030507de8c85ed72a..a7acc7bba8d8e98cbecc832d042e545133dc5058 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -10,6 +10,8 @@ pub struct ProjectSettings { pub lsp: HashMap, LspSettings>, #[serde(default)] pub git: GitSettings, + #[serde(default)] + pub file_scan_exclusions: Option>, } #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 90d32643d56097b170e89e197a5c5297a0eaf821..264c1ff7b54fa52dbffd87545603736704a5f932 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -3598,7 +3598,7 @@ async fn test_search(cx: &mut gpui::TestAppContext) { assert_eq!( search( &project, - SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text("TWO", false, true, false, Vec::new(), Vec::new()).unwrap(), cx ) .await @@ -3623,7 +3623,7 @@ async fn test_search(cx: &mut gpui::TestAppContext) { assert_eq!( search( &project, - SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text("TWO", false, true, false, Vec::new(), Vec::new()).unwrap(), cx ) .await @@ -3662,6 +3662,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![PathMatcher::new("*.odd").unwrap()], Vec::new() ) @@ -3681,6 +3682,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![PathMatcher::new("*.rs").unwrap()], Vec::new() ) @@ -3703,6 +3705,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![ PathMatcher::new("*.ts").unwrap(), PathMatcher::new("*.odd").unwrap(), @@ -3727,6 +3730,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![ PathMatcher::new("*.rs").unwrap(), PathMatcher::new("*.ts").unwrap(), @@ -3774,6 +3778,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![PathMatcher::new("*.odd").unwrap()], ) @@ -3798,6 +3803,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![PathMatcher::new("*.rs").unwrap()], ) @@ -3820,6 +3826,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![ PathMatcher::new("*.ts").unwrap(), @@ -3844,6 +3851,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![ PathMatcher::new("*.rs").unwrap(), @@ -3885,6 +3893,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![PathMatcher::new("*.odd").unwrap()], vec![PathMatcher::new("*.odd").unwrap()], ) @@ -3904,6 +3913,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![PathMatcher::new("*.ts").unwrap()], vec![PathMatcher::new("*.ts").unwrap()], ).unwrap(), @@ -3922,6 +3932,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![ PathMatcher::new("*.ts").unwrap(), PathMatcher::new("*.odd").unwrap() @@ -3947,6 +3958,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![ PathMatcher::new("*.ts").unwrap(), PathMatcher::new("*.odd").unwrap() diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 7e360e22ee213b1f9a2e438dbd337bfedaf15255..c673440326e82630bd34c8117665b3f3cc092b69 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -39,6 +39,7 @@ pub enum SearchQuery { replacement: Option, whole_word: bool, case_sensitive: bool, + include_ignored: bool, inner: SearchInputs, }, @@ -48,6 +49,7 @@ pub enum SearchQuery { multiline: bool, whole_word: bool, case_sensitive: bool, + include_ignored: bool, inner: SearchInputs, }, } @@ -57,6 +59,7 @@ impl SearchQuery { query: impl ToString, whole_word: bool, case_sensitive: bool, + include_ignored: bool, files_to_include: Vec, files_to_exclude: Vec, ) -> Result { @@ -74,6 +77,7 @@ impl SearchQuery { replacement: None, whole_word, case_sensitive, + include_ignored, inner, }) } @@ -82,6 +86,7 @@ impl SearchQuery { query: impl ToString, whole_word: bool, case_sensitive: bool, + include_ignored: bool, files_to_include: Vec, files_to_exclude: Vec, ) -> Result { @@ -111,6 +116,7 @@ impl SearchQuery { multiline, whole_word, case_sensitive, + include_ignored, inner, }) } @@ -121,6 +127,7 @@ impl SearchQuery { message.query, message.whole_word, message.case_sensitive, + message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, ) @@ -129,6 +136,7 @@ impl SearchQuery { message.query, message.whole_word, message.case_sensitive, + message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, ) @@ -156,6 +164,7 @@ impl SearchQuery { regex: self.is_regex(), whole_word: self.whole_word(), case_sensitive: self.case_sensitive(), + include_ignored: self.include_ignored(), files_to_include: self .files_to_include() .iter() @@ -336,6 +345,17 @@ impl SearchQuery { } } + pub fn include_ignored(&self) -> bool { + match self { + Self::Text { + include_ignored, .. + } => *include_ignored, + Self::Regex { + include_ignored, .. + } => *include_ignored, + } + } + pub fn is_regex(&self) -> bool { matches!(self, Self::Regex { .. }) } diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index d59885225acbff208153370e7ed3ec14050661ef..82fa5d60207b6ba7a5e3ed9ac552f39a3ec3ac65 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -1,5 +1,6 @@ use crate::{ - copy_recursive, ignore::IgnoreStack, DiagnosticSummary, ProjectEntryId, RemoveOptions, + copy_recursive, ignore::IgnoreStack, project_settings::ProjectSettings, DiagnosticSummary, + ProjectEntryId, RemoveOptions, }; use ::ignore::gitignore::{Gitignore, GitignoreBuilder}; use anyhow::{anyhow, Context, Result}; @@ -21,7 +22,10 @@ use futures::{ }; use fuzzy::CharBag; use git::{DOT_GIT, GITIGNORE}; -use gpui::{executor, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; +use gpui::{ + executor, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Subscription, Task, +}; +use itertools::Itertools; use language::{ proto::{ deserialize_fingerprint, deserialize_version, serialize_fingerprint, serialize_line_ending, @@ -36,6 +40,7 @@ use postage::{ prelude::{Sink as _, Stream as _}, watch, }; +use settings::SettingsStore; use smol::channel::{self, Sender}; use std::{ any::Any, @@ -55,7 +60,10 @@ use std::{ time::{Duration, SystemTime}, }; use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet}; -use util::{paths::HOME, ResultExt}; +use util::{ + paths::{PathMatcher, HOME}, + ResultExt, +}; #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] pub struct WorktreeId(usize); @@ -70,7 +78,8 @@ pub struct LocalWorktree { scan_requests_tx: channel::Sender, path_prefixes_to_scan_tx: channel::Sender>, is_scanning: (watch::Sender, watch::Receiver), - _background_scanner_task: Task<()>, + _settings_subscription: Subscription, + _background_scanner_tasks: Vec>, share: Option, diagnostics: HashMap< Arc, @@ -216,6 +225,7 @@ pub struct LocalSnapshot { /// All of the git repositories in the worktree, indexed by the project entry /// id of their parent directory. git_repositories: TreeMap, + file_scan_exclusions: Vec, } struct BackgroundScannerState { @@ -299,17 +309,54 @@ impl Worktree { .await .context("failed to stat worktree path")?; + let closure_fs = Arc::clone(&fs); + let closure_next_entry_id = Arc::clone(&next_entry_id); + let closure_abs_path = abs_path.to_path_buf(); Ok(cx.add_model(move |cx: &mut ModelContext| { + let settings_subscription = cx.observe_global::(move |this, cx| { + if let Self::Local(this) = this { + let new_file_scan_exclusions = + file_scan_exclusions(settings::get::(cx)); + if new_file_scan_exclusions != this.snapshot.file_scan_exclusions { + this.snapshot.file_scan_exclusions = new_file_scan_exclusions; + log::info!( + "Re-scanning directories, new scan exclude files: {:?}", + this.snapshot + .file_scan_exclusions + .iter() + .map(ToString::to_string) + .collect::>() + ); + + let (scan_requests_tx, scan_requests_rx) = channel::unbounded(); + let (path_prefixes_to_scan_tx, path_prefixes_to_scan_rx) = + channel::unbounded(); + this.scan_requests_tx = scan_requests_tx; + this.path_prefixes_to_scan_tx = path_prefixes_to_scan_tx; + this._background_scanner_tasks = start_background_scan_tasks( + &closure_abs_path, + this.snapshot(), + scan_requests_rx, + path_prefixes_to_scan_rx, + Arc::clone(&closure_next_entry_id), + Arc::clone(&closure_fs), + cx, + ); + this.is_scanning = watch::channel_with(true); + } + } + }); + let root_name = abs_path .file_name() .map_or(String::new(), |f| f.to_string_lossy().to_string()); - let mut snapshot = LocalSnapshot { + file_scan_exclusions: file_scan_exclusions(settings::get::(cx)), ignores_by_parent_abs_path: Default::default(), git_repositories: Default::default(), snapshot: Snapshot { id: WorktreeId::from_usize(cx.model_id()), - abs_path: abs_path.clone(), + abs_path: abs_path.to_path_buf().into(), root_name: root_name.clone(), root_char_bag: root_name.chars().map(|c| c.to_ascii_lowercase()).collect(), entries_by_path: Default::default(), @@ -334,60 +381,23 @@ impl Worktree { let (scan_requests_tx, scan_requests_rx) = channel::unbounded(); let (path_prefixes_to_scan_tx, path_prefixes_to_scan_rx) = channel::unbounded(); - let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); - - cx.spawn_weak(|this, mut cx| async move { - while let Some((state, this)) = scan_states_rx.next().await.zip(this.upgrade(&cx)) { - this.update(&mut cx, |this, cx| { - let this = this.as_local_mut().unwrap(); - match state { - ScanState::Started => { - *this.is_scanning.0.borrow_mut() = true; - } - ScanState::Updated { - snapshot, - changes, - barrier, - scanning, - } => { - *this.is_scanning.0.borrow_mut() = scanning; - this.set_snapshot(snapshot, changes, cx); - drop(barrier); - } - } - cx.notify(); - }); - } - }) - .detach(); - - let background_scanner_task = cx.background().spawn({ - let fs = fs.clone(); - let snapshot = snapshot.clone(); - let background = cx.background().clone(); - async move { - let events = fs.watch(&abs_path, Duration::from_millis(100)).await; - BackgroundScanner::new( - snapshot, - next_entry_id, - fs, - scan_states_tx, - background, - scan_requests_rx, - path_prefixes_to_scan_rx, - ) - .run(events) - .await; - } - }); - + let task_snapshot = snapshot.clone(); Worktree::Local(LocalWorktree { snapshot, is_scanning: watch::channel_with(true), share: None, scan_requests_tx, path_prefixes_to_scan_tx, - _background_scanner_task: background_scanner_task, + _settings_subscription: settings_subscription, + _background_scanner_tasks: start_background_scan_tasks( + &abs_path, + task_snapshot, + scan_requests_rx, + path_prefixes_to_scan_rx, + Arc::clone(&next_entry_id), + Arc::clone(&fs), + cx, + ), diagnostics: Default::default(), diagnostic_summaries: Default::default(), client, @@ -584,6 +594,76 @@ impl Worktree { } } +fn start_background_scan_tasks( + abs_path: &Path, + snapshot: LocalSnapshot, + scan_requests_rx: channel::Receiver, + path_prefixes_to_scan_rx: channel::Receiver>, + next_entry_id: Arc, + fs: Arc, + cx: &mut ModelContext<'_, Worktree>, +) -> Vec> { + let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); + let background_scanner = cx.background().spawn({ + let abs_path = abs_path.to_path_buf(); + let background = cx.background().clone(); + async move { + let events = fs.watch(&abs_path, Duration::from_millis(100)).await; + BackgroundScanner::new( + snapshot, + next_entry_id, + fs, + scan_states_tx, + background, + scan_requests_rx, + path_prefixes_to_scan_rx, + ) + .run(events) + .await; + } + }); + let scan_state_updater = cx.spawn_weak(|this, mut cx| async move { + while let Some((state, this)) = scan_states_rx.next().await.zip(this.upgrade(&cx)) { + this.update(&mut cx, |this, cx| { + let this = this.as_local_mut().unwrap(); + match state { + ScanState::Started => { + *this.is_scanning.0.borrow_mut() = true; + } + ScanState::Updated { + snapshot, + changes, + barrier, + scanning, + } => { + *this.is_scanning.0.borrow_mut() = scanning; + this.set_snapshot(snapshot, changes, cx); + drop(barrier); + } + } + cx.notify(); + }); + } + }); + vec![background_scanner, scan_state_updater] +} + +fn file_scan_exclusions(project_settings: &ProjectSettings) -> Vec { + project_settings.file_scan_exclusions.as_deref().unwrap_or(&[]).iter() + .sorted() + .filter_map(|pattern| { + PathMatcher::new(pattern) + .map(Some) + .unwrap_or_else(|e| { + log::error!( + "Skipping pattern {pattern} in `file_scan_exclusions` project settings due to parsing error: {e:#}" + ); + None + }) + }) + .collect() +} + impl LocalWorktree { pub fn contains_abs_path(&self, path: &Path) -> bool { path.starts_with(&self.abs_path) @@ -1481,7 +1561,7 @@ impl Snapshot { self.entries_by_id.get(&entry_id, &()).is_some() } - pub(crate) fn insert_entry(&mut self, entry: proto::Entry) -> Result { + fn insert_entry(&mut self, entry: proto::Entry) -> Result { let entry = Entry::try_from((&self.root_char_bag, entry))?; let old_entry = self.entries_by_id.insert_or_replace( PathEntry { @@ -2145,6 +2225,12 @@ impl LocalSnapshot { paths.sort_by(|a, b| a.0.cmp(b.0)); paths } + + fn is_abs_path_excluded(&self, abs_path: &Path) -> bool { + self.file_scan_exclusions + .iter() + .any(|exclude_matcher| exclude_matcher.is_match(abs_path)) + } } impl BackgroundScannerState { @@ -2167,7 +2253,7 @@ impl BackgroundScannerState { let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true); let mut ancestor_inodes = self.snapshot.ancestor_inodes_for_path(&path); let mut containing_repository = None; - if !ignore_stack.is_all() { + if !ignore_stack.is_abs_path_ignored(&abs_path, true) { if let Some((workdir_path, repo)) = self.snapshot.local_repo_for_path(&path) { if let Ok(repo_path) = path.strip_prefix(&workdir_path.0) { containing_repository = Some(( @@ -2378,18 +2464,30 @@ impl BackgroundScannerState { // Remove any git repositories whose .git entry no longer exists. let snapshot = &mut self.snapshot; - let mut repositories = mem::take(&mut snapshot.git_repositories); - let mut repository_entries = mem::take(&mut snapshot.repository_entries); - repositories.retain(|work_directory_id, _| { - snapshot - .entry_for_id(*work_directory_id) + let mut ids_to_preserve = HashSet::default(); + for (&work_directory_id, entry) in snapshot.git_repositories.iter() { + let exists_in_snapshot = snapshot + .entry_for_id(work_directory_id) .map_or(false, |entry| { snapshot.entry_for_path(entry.path.join(*DOT_GIT)).is_some() - }) - }); - repository_entries.retain(|_, entry| repositories.get(&entry.work_directory.0).is_some()); - snapshot.git_repositories = repositories; - snapshot.repository_entries = repository_entries; + }); + if exists_in_snapshot { + ids_to_preserve.insert(work_directory_id); + } else { + let git_dir_abs_path = snapshot.abs_path().join(&entry.git_dir_path); + if snapshot.is_abs_path_excluded(&git_dir_abs_path) + && !matches!(smol::block_on(fs.metadata(&git_dir_abs_path)), Ok(None)) + { + ids_to_preserve.insert(work_directory_id); + } + } + } + snapshot + .git_repositories + .retain(|work_directory_id, _| ids_to_preserve.contains(work_directory_id)); + snapshot + .repository_entries + .retain(|_, entry| ids_to_preserve.contains(&entry.work_directory.0)); } fn build_git_repository( @@ -3094,7 +3192,7 @@ impl BackgroundScanner { let ignore_stack = state .snapshot .ignore_stack_for_abs_path(&root_abs_path, true); - if ignore_stack.is_all() { + if ignore_stack.is_abs_path_ignored(&root_abs_path, true) { root_entry.is_ignored = true; state.insert_entry(root_entry.clone(), self.fs.as_ref()); } @@ -3231,14 +3329,22 @@ impl BackgroundScanner { return false; }; - let parent_dir_is_loaded = relative_path.parent().map_or(true, |parent| { - snapshot - .entry_for_path(parent) - .map_or(false, |entry| entry.kind == EntryKind::Dir) - }); - if !parent_dir_is_loaded { - log::debug!("ignoring event {relative_path:?} within unloaded directory"); - return false; + if !is_git_related(&abs_path) { + let parent_dir_is_loaded = relative_path.parent().map_or(true, |parent| { + snapshot + .entry_for_path(parent) + .map_or(false, |entry| entry.kind == EntryKind::Dir) + }); + if !parent_dir_is_loaded { + log::debug!("ignoring event {relative_path:?} within unloaded directory"); + return false; + } + if snapshot.is_abs_path_excluded(abs_path) { + log::debug!( + "ignoring FS event for path {relative_path:?} within excluded directory" + ); + return false; + } } relative_paths.push(relative_path); @@ -3401,18 +3507,26 @@ impl BackgroundScanner { } async fn scan_dir(&self, job: &ScanJob) -> Result<()> { - log::debug!("scan directory {:?}", job.path); - - let mut ignore_stack = job.ignore_stack.clone(); - let mut new_ignore = None; - let (root_abs_path, root_char_bag, next_entry_id) = { - let snapshot = &self.state.lock().snapshot; - ( - snapshot.abs_path().clone(), - snapshot.root_char_bag, - self.next_entry_id.clone(), - ) - }; + let root_abs_path; + let mut ignore_stack; + let mut new_ignore; + let root_char_bag; + let next_entry_id; + { + let state = self.state.lock(); + let snapshot = &state.snapshot; + root_abs_path = snapshot.abs_path().clone(); + if snapshot.is_abs_path_excluded(&job.abs_path) { + log::error!("skipping excluded directory {:?}", job.path); + return Ok(()); + } + log::debug!("scanning directory {:?}", job.path); + ignore_stack = job.ignore_stack.clone(); + new_ignore = None; + root_char_bag = snapshot.root_char_bag; + next_entry_id = self.next_entry_id.clone(); + drop(state); + } let mut dotgit_path = None; let mut root_canonical_path = None; @@ -3427,18 +3541,8 @@ impl BackgroundScanner { continue; } }; - let child_name = child_abs_path.file_name().unwrap(); let child_path: Arc = job.path.join(child_name).into(); - let child_metadata = match self.fs.metadata(&child_abs_path).await { - Ok(Some(metadata)) => metadata, - Ok(None) => continue, - Err(err) => { - log::error!("error processing {:?}: {:?}", child_abs_path, err); - continue; - } - }; - // If we find a .gitignore, add it to the stack of ignores used to determine which paths are ignored if child_name == *GITIGNORE { match build_gitignore(&child_abs_path, self.fs.as_ref()).await { @@ -3482,6 +3586,26 @@ impl BackgroundScanner { dotgit_path = Some(child_path.clone()); } + { + let mut state = self.state.lock(); + if state.snapshot.is_abs_path_excluded(&child_abs_path) { + let relative_path = job.path.join(child_name); + log::debug!("skipping excluded child entry {relative_path:?}"); + state.remove_path(&relative_path); + continue; + } + drop(state); + } + + let child_metadata = match self.fs.metadata(&child_abs_path).await { + Ok(Some(metadata)) => metadata, + Ok(None) => continue, + Err(err) => { + log::error!("error processing {child_abs_path:?}: {err:?}"); + continue; + } + }; + let mut child_entry = Entry::new( child_path.clone(), &child_metadata, @@ -3662,19 +3786,16 @@ impl BackgroundScanner { self.next_entry_id.as_ref(), state.snapshot.root_char_bag, ); - fs_entry.is_ignored = ignore_stack.is_all(); + let is_dir = fs_entry.is_dir(); + fs_entry.is_ignored = ignore_stack.is_abs_path_ignored(&abs_path, is_dir); fs_entry.is_external = !canonical_path.starts_with(&root_canonical_path); - if !fs_entry.is_ignored { - if !fs_entry.is_dir() { - if let Some((work_dir, repo)) = - state.snapshot.local_repo_for_path(&path) - { - if let Ok(repo_path) = path.strip_prefix(work_dir.0) { - let repo_path = RepoPath(repo_path.into()); - let repo = repo.repo_ptr.lock(); - fs_entry.git_status = repo.status(&repo_path, fs_entry.mtime); - } + if !is_dir && !fs_entry.is_ignored { + if let Some((work_dir, repo)) = state.snapshot.local_repo_for_path(&path) { + if let Ok(repo_path) = path.strip_prefix(work_dir.0) { + let repo_path = RepoPath(repo_path.into()); + let repo = repo.repo_ptr.lock(); + fs_entry.git_status = repo.status(&repo_path, fs_entry.mtime); } } } @@ -3833,8 +3954,7 @@ impl BackgroundScanner { ignore_stack.clone() }; - // Scan any directories that were previously ignored and weren't - // previously scanned. + // Scan any directories that were previously ignored and weren't previously scanned. if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() { let state = self.state.lock(); if state.should_scan_directory(&entry) { @@ -4010,6 +4130,12 @@ impl BackgroundScanner { } } +fn is_git_related(abs_path: &Path) -> bool { + abs_path + .components() + .any(|c| c.as_os_str() == *DOT_GIT || c.as_os_str() == *GITIGNORE) +} + fn char_bag_for_path(root_char_bag: CharBag, path: &Path) -> CharBag { let mut result = root_char_bag; result.extend( diff --git a/crates/project/src/worktree_tests.rs b/crates/project/src/worktree_tests.rs index 4253f45b0ce912412b0f9716474f92d0f875f026..22a5cc1e016e80227898f6404646f54a5fb14ee4 100644 --- a/crates/project/src/worktree_tests.rs +++ b/crates/project/src/worktree_tests.rs @@ -1,6 +1,7 @@ use crate::{ + project_settings::ProjectSettings, worktree::{Event, Snapshot, WorktreeModelHandle}, - Entry, EntryKind, PathChange, Worktree, + Entry, EntryKind, PathChange, Project, Worktree, }; use anyhow::Result; use client::Client; @@ -12,6 +13,7 @@ use postage::stream::Stream; use pretty_assertions::assert_eq; use rand::prelude::*; use serde_json::json; +use settings::SettingsStore; use std::{ env, fmt::Write, @@ -23,6 +25,7 @@ use util::{http::FakeHttpClient, test::temp_tree, ResultExt}; #[gpui::test] async fn test_traversal(cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -78,6 +81,7 @@ async fn test_traversal(cx: &mut TestAppContext) { #[gpui::test] async fn test_descendent_entries(cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -185,6 +189,7 @@ async fn test_descendent_entries(cx: &mut TestAppContext) { #[gpui::test(iterations = 10)] async fn test_circular_symlinks(executor: Arc, cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -264,6 +269,7 @@ async fn test_circular_symlinks(executor: Arc, cx: &mut TestAppCo #[gpui::test] async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -439,6 +445,7 @@ async fn test_symlinks_pointing_outside(cx: &mut TestAppContext) { #[gpui::test] async fn test_open_gitignored_files(cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -599,6 +606,7 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { #[gpui::test] async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -722,6 +730,14 @@ async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { #[gpui::test(iterations = 10)] async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + }); + }); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -827,6 +843,7 @@ async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { #[gpui::test] async fn test_write_file(cx: &mut TestAppContext) { + init_test(cx); let dir = temp_tree(json!({ ".git": {}, ".gitignore": "ignored-dir\n", @@ -877,8 +894,105 @@ async fn test_write_file(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_file_scan_exclusions(cx: &mut TestAppContext) { + init_test(cx); + let dir = temp_tree(json!({ + ".gitignore": "**/target\n/node_modules\n", + "target": { + "index": "blah2" + }, + "node_modules": { + ".DS_Store": "", + "prettier": { + "package.json": "{}", + }, + }, + "src": { + ".DS_Store": "", + "foo": { + "foo.rs": "mod another;\n", + "another.rs": "// another", + }, + "bar": { + "bar.rs": "// bar", + }, + "lib.rs": "mod foo;\nmod bar;\n", + }, + ".DS_Store": "", + })); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["**/foo/**".to_string(), "**/.DS_Store".to_string()]); + }); + }); + }); + + let tree = Worktree::local( + build_client(cx), + 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.read_with(cx, |tree, _| { + check_worktree_entries( + tree, + &[ + "src/foo/foo.rs", + "src/foo/another.rs", + "node_modules/.DS_Store", + "src/.DS_Store", + ".DS_Store", + ], + &["target", "node_modules"], + &["src/lib.rs", "src/bar/bar.rs", ".gitignore"], + ) + }); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["**/node_modules/**".to_string()]); + }); + }); + }); + tree.flush_fs_events(cx).await; + cx.foreground().run_until_parked(); + tree.read_with(cx, |tree, _| { + check_worktree_entries( + tree, + &[ + "node_modules/prettier/package.json", + "node_modules/.DS_Store", + "node_modules", + ], + &["target"], + &[ + ".gitignore", + "src/lib.rs", + "src/bar/bar.rs", + "src/foo/foo.rs", + "src/foo/another.rs", + "src/.DS_Store", + ".DS_Store", + ], + ) + }); +} + #[gpui::test(iterations = 30)] async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -938,6 +1052,7 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { #[gpui::test] async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { + init_test(cx); let client_fake = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); let fs_fake = FakeFs::new(cx.background()); @@ -1054,6 +1169,7 @@ async fn test_random_worktree_operations_during_initial_scan( cx: &mut TestAppContext, mut rng: StdRng, ) { + init_test(cx); let operations = env::var("OPERATIONS") .map(|o| o.parse().unwrap()) .unwrap_or(5); @@ -1143,6 +1259,7 @@ async fn test_random_worktree_operations_during_initial_scan( #[gpui::test(iterations = 100)] async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng) { + init_test(cx); let operations = env::var("OPERATIONS") .map(|o| o.parse().unwrap()) .unwrap_or(40); @@ -1557,6 +1674,7 @@ fn random_filename(rng: &mut impl Rng) -> String { #[gpui::test] async fn test_rename_work_directory(cx: &mut TestAppContext) { + init_test(cx); let root = temp_tree(json!({ "projects": { "project1": { @@ -1627,6 +1745,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { #[gpui::test] async fn test_git_repository_for_path(cx: &mut TestAppContext) { + init_test(cx); let root = temp_tree(json!({ "c.txt": "", "dir1": { @@ -1747,6 +1866,15 @@ async fn test_git_repository_for_path(cx: &mut TestAppContext) { #[gpui::test] async fn test_git_status(deterministic: Arc, cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/.gitignore".to_string()]); + }); + }); + }); const IGNORE_RULE: &'static str = "**/target"; let root = temp_tree(json!({ @@ -1935,6 +2063,7 @@ async fn test_git_status(deterministic: Arc, cx: &mut TestAppCont #[gpui::test] async fn test_propagate_git_statuses(cx: &mut TestAppContext) { + init_test(cx); let fs = FakeFs::new(cx.background()); fs.insert_tree( "/root", @@ -2139,3 +2268,44 @@ fn git_status(repo: &git2::Repository) -> collections::HashMap bool { - matches!(self, IgnoreStack::All) - } - pub fn append(self: Arc, abs_base_path: Arc, ignore: Arc) -> Arc { match self.as_ref() { IgnoreStack::All => self, diff --git a/crates/project2/src/project2.rs b/crates/project2/src/project2.rs index f2e47b71842c0ec3aedd94c20b57bd90123149ca..3f7c9b7188bcbca247839cf70fe9bb2568e4461d 100644 --- a/crates/project2/src/project2.rs +++ b/crates/project2/src/project2.rs @@ -5618,7 +5618,16 @@ impl Project { .collect::>(); let background = cx.background_executor().clone(); - let path_count: usize = snapshots.iter().map(|s| s.visible_file_count()).sum(); + let path_count: usize = snapshots + .iter() + .map(|s| { + if query.include_ignored() { + s.file_count() + } else { + s.visible_file_count() + } + }) + .sum(); if path_count == 0 { let (_, rx) = smol::channel::bounded(1024); return rx; @@ -5631,8 +5640,16 @@ impl Project { .iter() .filter_map(|(_, b)| { let buffer = b.upgrade()?; - let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); - if let Some(path) = snapshot.file().map(|file| file.path()) { + let (is_ignored, snapshot) = buffer.update(cx, |buffer, cx| { + let is_ignored = buffer + .project_path(cx) + .and_then(|path| self.entry_for_path(&path, cx)) + .map_or(false, |entry| entry.is_ignored); + (is_ignored, buffer.snapshot()) + }); + if is_ignored && !query.include_ignored() { + return None; + } else if let Some(path) = snapshot.file().map(|file| file.path()) { Some((path.clone(), (buffer, snapshot))) } else { unnamed_files.push(buffer); @@ -5806,7 +5823,12 @@ impl Project { let mut snapshot_start_ix = 0; let mut abs_path = PathBuf::new(); for snapshot in snapshots { - let snapshot_end_ix = snapshot_start_ix + snapshot.visible_file_count(); + let snapshot_end_ix = snapshot_start_ix + + if query.include_ignored() { + snapshot.file_count() + } else { + snapshot.visible_file_count() + }; if worker_end_ix <= snapshot_start_ix { break; } else if worker_start_ix > snapshot_end_ix { @@ -5819,7 +5841,7 @@ impl Project { cmp::min(worker_end_ix, snapshot_end_ix) - snapshot_start_ix; for entry in snapshot - .files(false, start_in_snapshot) + .files(query.include_ignored(), start_in_snapshot) .take(end_in_snapshot - start_in_snapshot) { if matching_paths_tx.is_closed() { diff --git a/crates/project2/src/project_settings.rs b/crates/project2/src/project_settings.rs index 028a564b9c0b54534572fbccaae44cce0b1a9693..2a8df47e67a5b7ad7e540e581b055da546c3f7cf 100644 --- a/crates/project2/src/project_settings.rs +++ b/crates/project2/src/project_settings.rs @@ -11,6 +11,8 @@ pub struct ProjectSettings { pub lsp: HashMap, LspSettings>, #[serde(default)] pub git: GitSettings, + #[serde(default)] + pub file_scan_exclusions: Option>, } #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] diff --git a/crates/project2/src/project_tests.rs b/crates/project2/src/project_tests.rs index 9eb9a49e49e2930ba1b133b27b8f8fcf799a261e..53b2f6ba1fc7cc04489bce5256a0954b9e2bf7ff 100644 --- a/crates/project2/src/project_tests.rs +++ b/crates/project2/src/project_tests.rs @@ -2633,6 +2633,60 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) .unwrap(); worktree.next_event(cx); + cx.executor().run_until_parked(); + let on_disk_text = fs.load(Path::new("/dir/file1")).await.unwrap(); + buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.text(), on_disk_text); + assert!(!buffer.is_dirty(), "buffer should not be dirty"); + assert!(!buffer.has_conflict(), "buffer should not be dirty"); + }); +} + +#[gpui::test(iterations = 30)] +async fn test_edit_buffer_while_it_reloads(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/dir", + json!({ + "file1": "the original contents", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let worktree = project.read_with(cx, |project, _| project.worktrees().next().unwrap()); + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx)) + .await + .unwrap(); + + // Simulate buffer diffs being slow, so that they don't complete before + // the next file change occurs. + cx.executor().deprioritize(*language::BUFFER_DIFF_TASK); + + // Change the buffer's file on disk, and then wait for the file change + // to be detected by the worktree, so that the buffer starts reloading. + fs.save( + "/dir/file1".as_ref(), + &"the first contents".into(), + Default::default(), + ) + .await + .unwrap(); + worktree.next_event(cx); + + cx.executor() + .spawn(cx.executor().simulate_random_delay()) + .await; + + // Perform a noop edit, causing the buffer's version to increase. + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, " ")], None, cx); + buffer.undo(cx); + }); + cx.executor().run_until_parked(); let on_disk_text = fs.load(Path::new("/dir/file1")).await.unwrap(); buffer.read_with(cx, |buffer, _| { @@ -2646,10 +2700,8 @@ async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) // If the file change occurred while the buffer was processing the first // change, the buffer will be in a conflicting state. else { - assert!( - buffer.is_dirty() && buffer.has_conflict(), - "buffer should report that it has a conflict. text: {buffer_text:?}, disk text: {on_disk_text:?}" - ); + assert!(buffer.is_dirty(), "buffer should report that it is dirty. text: {buffer_text:?}, disk text: {on_disk_text:?}"); + assert!(buffer.has_conflict(), "buffer should report that it is dirty. text: {buffer_text:?}, disk text: {on_disk_text:?}"); } }); } @@ -3678,7 +3730,7 @@ async fn test_search(cx: &mut gpui::TestAppContext) { assert_eq!( search( &project, - SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text("TWO", false, true, false, Vec::new(), Vec::new()).unwrap(), cx ) .await @@ -3703,7 +3755,7 @@ async fn test_search(cx: &mut gpui::TestAppContext) { assert_eq!( search( &project, - SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()).unwrap(), + SearchQuery::text("TWO", false, true, false, Vec::new(), Vec::new()).unwrap(), cx ) .await @@ -3742,6 +3794,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![PathMatcher::new("*.odd").unwrap()], Vec::new() ) @@ -3761,6 +3814,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![PathMatcher::new("*.rs").unwrap()], Vec::new() ) @@ -3783,6 +3837,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![ PathMatcher::new("*.ts").unwrap(), PathMatcher::new("*.odd").unwrap(), @@ -3807,6 +3862,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, vec![ PathMatcher::new("*.rs").unwrap(), PathMatcher::new("*.ts").unwrap(), @@ -3854,6 +3910,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![PathMatcher::new("*.odd").unwrap()], ) @@ -3878,6 +3935,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![PathMatcher::new("*.rs").unwrap()], ) @@ -3900,6 +3958,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![ PathMatcher::new("*.ts").unwrap(), @@ -3924,6 +3983,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { search_query, false, true, + false, Vec::new(), vec![ PathMatcher::new("*.rs").unwrap(), @@ -3965,6 +4025,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![PathMatcher::new("*.odd").unwrap()], vec![PathMatcher::new("*.odd").unwrap()], ) @@ -3984,6 +4045,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![PathMatcher::new("*.ts").unwrap()], vec![PathMatcher::new("*.ts").unwrap()], ).unwrap(), @@ -4002,6 +4064,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![ PathMatcher::new("*.ts").unwrap(), PathMatcher::new("*.odd").unwrap() @@ -4027,6 +4090,7 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex search_query, false, true, + false, vec![ PathMatcher::new("*.ts").unwrap(), PathMatcher::new("*.odd").unwrap() @@ -4084,7 +4148,7 @@ async fn search( fn init_test(cx: &mut gpui::TestAppContext) { if std::env::var("RUST_LOG").is_ok() { - env_logger::init(); + env_logger::try_init().ok(); } cx.update(|cx| { diff --git a/crates/project2/src/search.rs b/crates/project2/src/search.rs index 7e360e22ee213b1f9a2e438dbd337bfedaf15255..c673440326e82630bd34c8117665b3f3cc092b69 100644 --- a/crates/project2/src/search.rs +++ b/crates/project2/src/search.rs @@ -39,6 +39,7 @@ pub enum SearchQuery { replacement: Option, whole_word: bool, case_sensitive: bool, + include_ignored: bool, inner: SearchInputs, }, @@ -48,6 +49,7 @@ pub enum SearchQuery { multiline: bool, whole_word: bool, case_sensitive: bool, + include_ignored: bool, inner: SearchInputs, }, } @@ -57,6 +59,7 @@ impl SearchQuery { query: impl ToString, whole_word: bool, case_sensitive: bool, + include_ignored: bool, files_to_include: Vec, files_to_exclude: Vec, ) -> Result { @@ -74,6 +77,7 @@ impl SearchQuery { replacement: None, whole_word, case_sensitive, + include_ignored, inner, }) } @@ -82,6 +86,7 @@ impl SearchQuery { query: impl ToString, whole_word: bool, case_sensitive: bool, + include_ignored: bool, files_to_include: Vec, files_to_exclude: Vec, ) -> Result { @@ -111,6 +116,7 @@ impl SearchQuery { multiline, whole_word, case_sensitive, + include_ignored, inner, }) } @@ -121,6 +127,7 @@ impl SearchQuery { message.query, message.whole_word, message.case_sensitive, + message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, ) @@ -129,6 +136,7 @@ impl SearchQuery { message.query, message.whole_word, message.case_sensitive, + message.include_ignored, deserialize_path_matches(&message.files_to_include)?, deserialize_path_matches(&message.files_to_exclude)?, ) @@ -156,6 +164,7 @@ impl SearchQuery { regex: self.is_regex(), whole_word: self.whole_word(), case_sensitive: self.case_sensitive(), + include_ignored: self.include_ignored(), files_to_include: self .files_to_include() .iter() @@ -336,6 +345,17 @@ impl SearchQuery { } } + pub fn include_ignored(&self) -> bool { + match self { + Self::Text { + include_ignored, .. + } => *include_ignored, + Self::Regex { + include_ignored, .. + } => *include_ignored, + } + } + pub fn is_regex(&self) -> bool { matches!(self, Self::Regex { .. }) } diff --git a/crates/project2/src/worktree.rs b/crates/project2/src/worktree.rs index a020e8db4c311a78404169b5a31a23db8563b570..fcb64c40b42d09ec30169946a2526d48aa774a0f 100644 --- a/crates/project2/src/worktree.rs +++ b/crates/project2/src/worktree.rs @@ -1,5 +1,6 @@ use crate::{ - copy_recursive, ignore::IgnoreStack, DiagnosticSummary, ProjectEntryId, RemoveOptions, + copy_recursive, ignore::IgnoreStack, project_settings::ProjectSettings, DiagnosticSummary, + ProjectEntryId, RemoveOptions, }; use ::ignore::gitignore::{Gitignore, GitignoreBuilder}; use anyhow::{anyhow, Context as _, Result}; @@ -25,6 +26,7 @@ use gpui::{ AppContext, AsyncAppContext, BackgroundExecutor, Context, EventEmitter, Model, ModelContext, Task, }; +use itertools::Itertools; use language::{ proto::{ deserialize_fingerprint, deserialize_version, serialize_fingerprint, serialize_line_ending, @@ -39,6 +41,7 @@ use postage::{ prelude::{Sink as _, Stream as _}, watch, }; +use settings::{Settings, SettingsStore}; use smol::channel::{self, Sender}; use std::{ any::Any, @@ -58,7 +61,10 @@ use std::{ time::{Duration, SystemTime}, }; use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet}; -use util::{paths::HOME, ResultExt}; +use util::{ + paths::{PathMatcher, HOME}, + ResultExt, +}; #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] pub struct WorktreeId(usize); @@ -73,7 +79,7 @@ pub struct LocalWorktree { scan_requests_tx: channel::Sender, path_prefixes_to_scan_tx: channel::Sender>, is_scanning: (watch::Sender, watch::Receiver), - _background_scanner_task: Task<()>, + _background_scanner_tasks: Vec>, share: Option, diagnostics: HashMap< Arc, @@ -219,6 +225,7 @@ pub struct LocalSnapshot { /// All of the git repositories in the worktree, indexed by the project entry /// id of their parent directory. git_repositories: TreeMap, + file_scan_exclusions: Vec, } struct BackgroundScannerState { @@ -302,17 +309,56 @@ impl Worktree { .await .context("failed to stat worktree path")?; + let closure_fs = Arc::clone(&fs); + let closure_next_entry_id = Arc::clone(&next_entry_id); + let closure_abs_path = abs_path.to_path_buf(); cx.build_model(move |cx: &mut ModelContext| { + cx.observe_global::(move |this, cx| { + if let Self::Local(this) = this { + let new_file_scan_exclusions = + file_scan_exclusions(ProjectSettings::get_global(cx)); + if new_file_scan_exclusions != this.snapshot.file_scan_exclusions { + this.snapshot.file_scan_exclusions = new_file_scan_exclusions; + log::info!( + "Re-scanning directories, new scan exclude files: {:?}", + this.snapshot + .file_scan_exclusions + .iter() + .map(ToString::to_string) + .collect::>() + ); + + let (scan_requests_tx, scan_requests_rx) = channel::unbounded(); + let (path_prefixes_to_scan_tx, path_prefixes_to_scan_rx) = + channel::unbounded(); + this.scan_requests_tx = scan_requests_tx; + this.path_prefixes_to_scan_tx = path_prefixes_to_scan_tx; + this._background_scanner_tasks = start_background_scan_tasks( + &closure_abs_path, + this.snapshot(), + scan_requests_rx, + path_prefixes_to_scan_rx, + Arc::clone(&closure_next_entry_id), + Arc::clone(&closure_fs), + cx, + ); + this.is_scanning = watch::channel_with(true); + } + } + }) + .detach(); + let root_name = abs_path .file_name() .map_or(String::new(), |f| f.to_string_lossy().to_string()); let mut snapshot = LocalSnapshot { + file_scan_exclusions: file_scan_exclusions(ProjectSettings::get_global(cx)), ignores_by_parent_abs_path: Default::default(), git_repositories: Default::default(), snapshot: Snapshot { id: WorktreeId::from_usize(cx.entity_id().as_u64() as usize), - abs_path: abs_path.clone(), + abs_path: abs_path.to_path_buf().into(), root_name: root_name.clone(), root_char_bag: root_name.chars().map(|c| c.to_ascii_lowercase()).collect(), entries_by_path: Default::default(), @@ -337,61 +383,22 @@ impl Worktree { let (scan_requests_tx, scan_requests_rx) = channel::unbounded(); let (path_prefixes_to_scan_tx, path_prefixes_to_scan_rx) = channel::unbounded(); - let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); - - cx.spawn(|this, mut cx| async move { - while let Some((state, this)) = scan_states_rx.next().await.zip(this.upgrade()) { - this.update(&mut cx, |this, cx| { - let this = this.as_local_mut().unwrap(); - match state { - ScanState::Started => { - *this.is_scanning.0.borrow_mut() = true; - } - ScanState::Updated { - snapshot, - changes, - barrier, - scanning, - } => { - *this.is_scanning.0.borrow_mut() = scanning; - this.set_snapshot(snapshot, changes, cx); - drop(barrier); - } - } - cx.notify(); - }) - .ok(); - } - }) - .detach(); - - let background_scanner_task = cx.background_executor().spawn({ - let fs = fs.clone(); - let snapshot = snapshot.clone(); - let background = cx.background_executor().clone(); - async move { - let events = fs.watch(&abs_path, Duration::from_millis(100)).await; - BackgroundScanner::new( - snapshot, - next_entry_id, - fs, - scan_states_tx, - background, - scan_requests_rx, - path_prefixes_to_scan_rx, - ) - .run(events) - .await; - } - }); - + let task_snapshot = snapshot.clone(); Worktree::Local(LocalWorktree { snapshot, is_scanning: watch::channel_with(true), share: None, scan_requests_tx, path_prefixes_to_scan_tx, - _background_scanner_task: background_scanner_task, + _background_scanner_tasks: start_background_scan_tasks( + &abs_path, + task_snapshot, + scan_requests_rx, + path_prefixes_to_scan_rx, + Arc::clone(&next_entry_id), + Arc::clone(&fs), + cx, + ), diagnostics: Default::default(), diagnostic_summaries: Default::default(), client, @@ -584,6 +591,77 @@ impl Worktree { } } +fn start_background_scan_tasks( + abs_path: &Path, + snapshot: LocalSnapshot, + scan_requests_rx: channel::Receiver, + path_prefixes_to_scan_rx: channel::Receiver>, + next_entry_id: Arc, + fs: Arc, + cx: &mut ModelContext<'_, Worktree>, +) -> Vec> { + let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); + let background_scanner = cx.background_executor().spawn({ + let abs_path = abs_path.to_path_buf(); + let background = cx.background_executor().clone(); + async move { + let events = fs.watch(&abs_path, Duration::from_millis(100)).await; + BackgroundScanner::new( + snapshot, + next_entry_id, + fs, + scan_states_tx, + background, + scan_requests_rx, + path_prefixes_to_scan_rx, + ) + .run(events) + .await; + } + }); + let scan_state_updater = cx.spawn(|this, mut cx| async move { + while let Some((state, this)) = scan_states_rx.next().await.zip(this.upgrade()) { + this.update(&mut cx, |this, cx| { + let this = this.as_local_mut().unwrap(); + match state { + ScanState::Started => { + *this.is_scanning.0.borrow_mut() = true; + } + ScanState::Updated { + snapshot, + changes, + barrier, + scanning, + } => { + *this.is_scanning.0.borrow_mut() = scanning; + this.set_snapshot(snapshot, changes, cx); + drop(barrier); + } + } + cx.notify(); + }) + .ok(); + } + }); + vec![background_scanner, scan_state_updater] +} + +fn file_scan_exclusions(project_settings: &ProjectSettings) -> Vec { + project_settings.file_scan_exclusions.as_deref().unwrap_or(&[]).iter() + .sorted() + .filter_map(|pattern| { + PathMatcher::new(pattern) + .map(Some) + .unwrap_or_else(|e| { + log::error!( + "Skipping pattern {pattern} in `file_scan_exclusions` project settings due to parsing error: {e:#}" + ); + None + }) + }) + .collect() +} + impl LocalWorktree { pub fn contains_abs_path(&self, path: &Path) -> bool { path.starts_with(&self.abs_path) @@ -1482,7 +1560,7 @@ impl Snapshot { self.entries_by_id.get(&entry_id, &()).is_some() } - pub(crate) fn insert_entry(&mut self, entry: proto::Entry) -> Result { + fn insert_entry(&mut self, entry: proto::Entry) -> Result { let entry = Entry::try_from((&self.root_char_bag, entry))?; let old_entry = self.entries_by_id.insert_or_replace( PathEntry { @@ -2143,6 +2221,12 @@ impl LocalSnapshot { paths.sort_by(|a, b| a.0.cmp(b.0)); paths } + + fn is_abs_path_excluded(&self, abs_path: &Path) -> bool { + self.file_scan_exclusions + .iter() + .any(|exclude_matcher| exclude_matcher.is_match(abs_path)) + } } impl BackgroundScannerState { @@ -2165,7 +2249,7 @@ impl BackgroundScannerState { let ignore_stack = self.snapshot.ignore_stack_for_abs_path(&abs_path, true); let mut ancestor_inodes = self.snapshot.ancestor_inodes_for_path(&path); let mut containing_repository = None; - if !ignore_stack.is_all() { + if !ignore_stack.is_abs_path_ignored(&abs_path, true) { if let Some((workdir_path, repo)) = self.snapshot.local_repo_for_path(&path) { if let Ok(repo_path) = path.strip_prefix(&workdir_path.0) { containing_repository = Some(( @@ -2376,18 +2460,30 @@ impl BackgroundScannerState { // Remove any git repositories whose .git entry no longer exists. let snapshot = &mut self.snapshot; - let mut repositories = mem::take(&mut snapshot.git_repositories); - let mut repository_entries = mem::take(&mut snapshot.repository_entries); - repositories.retain(|work_directory_id, _| { - snapshot - .entry_for_id(*work_directory_id) + let mut ids_to_preserve = HashSet::default(); + for (&work_directory_id, entry) in snapshot.git_repositories.iter() { + let exists_in_snapshot = snapshot + .entry_for_id(work_directory_id) .map_or(false, |entry| { snapshot.entry_for_path(entry.path.join(*DOT_GIT)).is_some() - }) - }); - repository_entries.retain(|_, entry| repositories.get(&entry.work_directory.0).is_some()); - snapshot.git_repositories = repositories; - snapshot.repository_entries = repository_entries; + }); + if exists_in_snapshot { + ids_to_preserve.insert(work_directory_id); + } else { + let git_dir_abs_path = snapshot.abs_path().join(&entry.git_dir_path); + if snapshot.is_abs_path_excluded(&git_dir_abs_path) + && !matches!(smol::block_on(fs.metadata(&git_dir_abs_path)), Ok(None)) + { + ids_to_preserve.insert(work_directory_id); + } + } + } + snapshot + .git_repositories + .retain(|work_directory_id, _| ids_to_preserve.contains(work_directory_id)); + snapshot + .repository_entries + .retain(|_, entry| ids_to_preserve.contains(&entry.work_directory.0)); } fn build_git_repository( @@ -3085,7 +3181,7 @@ impl BackgroundScanner { let ignore_stack = state .snapshot .ignore_stack_for_abs_path(&root_abs_path, true); - if ignore_stack.is_all() { + if ignore_stack.is_abs_path_ignored(&root_abs_path, true) { root_entry.is_ignored = true; state.insert_entry(root_entry.clone(), self.fs.as_ref()); } @@ -3222,14 +3318,22 @@ impl BackgroundScanner { return false; }; - let parent_dir_is_loaded = relative_path.parent().map_or(true, |parent| { - snapshot - .entry_for_path(parent) - .map_or(false, |entry| entry.kind == EntryKind::Dir) - }); - if !parent_dir_is_loaded { - log::debug!("ignoring event {relative_path:?} within unloaded directory"); - return false; + if !is_git_related(&abs_path) { + let parent_dir_is_loaded = relative_path.parent().map_or(true, |parent| { + snapshot + .entry_for_path(parent) + .map_or(false, |entry| entry.kind == EntryKind::Dir) + }); + if !parent_dir_is_loaded { + log::debug!("ignoring event {relative_path:?} within unloaded directory"); + return false; + } + if snapshot.is_abs_path_excluded(abs_path) { + log::debug!( + "ignoring FS event for path {relative_path:?} within excluded directory" + ); + return false; + } } relative_paths.push(relative_path); @@ -3392,18 +3496,26 @@ impl BackgroundScanner { } async fn scan_dir(&self, job: &ScanJob) -> Result<()> { - log::debug!("scan directory {:?}", job.path); - - let mut ignore_stack = job.ignore_stack.clone(); - let mut new_ignore = None; - let (root_abs_path, root_char_bag, next_entry_id) = { - let snapshot = &self.state.lock().snapshot; - ( - snapshot.abs_path().clone(), - snapshot.root_char_bag, - self.next_entry_id.clone(), - ) - }; + let root_abs_path; + let mut ignore_stack; + let mut new_ignore; + let root_char_bag; + let next_entry_id; + { + let state = self.state.lock(); + let snapshot = &state.snapshot; + root_abs_path = snapshot.abs_path().clone(); + if snapshot.is_abs_path_excluded(&job.abs_path) { + log::error!("skipping excluded directory {:?}", job.path); + return Ok(()); + } + log::debug!("scanning directory {:?}", job.path); + ignore_stack = job.ignore_stack.clone(); + new_ignore = None; + root_char_bag = snapshot.root_char_bag; + next_entry_id = self.next_entry_id.clone(); + drop(state); + } let mut dotgit_path = None; let mut root_canonical_path = None; @@ -3418,18 +3530,8 @@ impl BackgroundScanner { continue; } }; - let child_name = child_abs_path.file_name().unwrap(); let child_path: Arc = job.path.join(child_name).into(); - let child_metadata = match self.fs.metadata(&child_abs_path).await { - Ok(Some(metadata)) => metadata, - Ok(None) => continue, - Err(err) => { - log::error!("error processing {:?}: {:?}", child_abs_path, err); - continue; - } - }; - // If we find a .gitignore, add it to the stack of ignores used to determine which paths are ignored if child_name == *GITIGNORE { match build_gitignore(&child_abs_path, self.fs.as_ref()).await { @@ -3473,6 +3575,26 @@ impl BackgroundScanner { dotgit_path = Some(child_path.clone()); } + { + let mut state = self.state.lock(); + if state.snapshot.is_abs_path_excluded(&child_abs_path) { + let relative_path = job.path.join(child_name); + log::debug!("skipping excluded child entry {relative_path:?}"); + state.remove_path(&relative_path); + continue; + } + drop(state); + } + + let child_metadata = match self.fs.metadata(&child_abs_path).await { + Ok(Some(metadata)) => metadata, + Ok(None) => continue, + Err(err) => { + log::error!("error processing {child_abs_path:?}: {err:?}"); + continue; + } + }; + let mut child_entry = Entry::new( child_path.clone(), &child_metadata, @@ -3653,19 +3775,16 @@ impl BackgroundScanner { self.next_entry_id.as_ref(), state.snapshot.root_char_bag, ); - fs_entry.is_ignored = ignore_stack.is_all(); + let is_dir = fs_entry.is_dir(); + fs_entry.is_ignored = ignore_stack.is_abs_path_ignored(&abs_path, is_dir); fs_entry.is_external = !canonical_path.starts_with(&root_canonical_path); - if !fs_entry.is_ignored { - if !fs_entry.is_dir() { - if let Some((work_dir, repo)) = - state.snapshot.local_repo_for_path(&path) - { - if let Ok(repo_path) = path.strip_prefix(work_dir.0) { - let repo_path = RepoPath(repo_path.into()); - let repo = repo.repo_ptr.lock(); - fs_entry.git_status = repo.status(&repo_path, fs_entry.mtime); - } + if !is_dir && !fs_entry.is_ignored { + if let Some((work_dir, repo)) = state.snapshot.local_repo_for_path(&path) { + if let Ok(repo_path) = path.strip_prefix(work_dir.0) { + let repo_path = RepoPath(repo_path.into()); + let repo = repo.repo_ptr.lock(); + fs_entry.git_status = repo.status(&repo_path, fs_entry.mtime); } } } @@ -3824,8 +3943,7 @@ impl BackgroundScanner { ignore_stack.clone() }; - // Scan any directories that were previously ignored and weren't - // previously scanned. + // Scan any directories that were previously ignored and weren't previously scanned. if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() { let state = self.state.lock(); if state.should_scan_directory(&entry) { @@ -4001,6 +4119,12 @@ impl BackgroundScanner { } } +fn is_git_related(abs_path: &Path) -> bool { + abs_path + .components() + .any(|c| c.as_os_str() == *DOT_GIT || c.as_os_str() == *GITIGNORE) +} + fn char_bag_for_path(root_char_bag: CharBag, path: &Path) -> CharBag { let mut result = root_char_bag; result.extend( diff --git a/crates/project2/src/worktree_tests.rs b/crates/project2/src/worktree_tests.rs index bf195f24c4cae69b3e0cbc62e584757375490adb..df7307f694cbead126690e6fa270023ff4847926 100644 --- a/crates/project2/src/worktree_tests.rs +++ b/crates/project2/src/worktree_tests.rs @@ -1,2141 +1,2310 @@ -// use crate::{ -// worktree::{Event, Snapshot, WorktreeModelHandle}, -// Entry, EntryKind, PathChange, Worktree, -// }; -// use anyhow::Result; -// use client2::Client; -// use fs::{repository::GitFileStatus, FakeFs, Fs, RealFs, RemoveOptions}; -// use git::GITIGNORE; -// use gpui::{executor::Deterministic, ModelContext, Task, TestAppContext}; -// use parking_lot::Mutex; -// use postage::stream::Stream; -// use pretty_assertions::assert_eq; -// use rand::prelude::*; -// use serde_json::json; -// use std::{ -// env, -// fmt::Write, -// mem, -// 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 tree = Worktree::local( -// build_client(cx), -// 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 tree = Worktree::local( -// build_client(cx), -// 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"),] -// ); -// }); - -// // Expand gitignored directory. -// tree.read_with(cx, |tree, _| { -// tree.as_local() -// .unwrap() -// .refresh_entries_for_paths(vec![Path::new("i/j").into()]) -// }) -// .recv() -// .await; - -// tree.read_with(cx, |tree, _| { -// 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 tree = Worktree::local( -// build_client(cx), -// 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_symlinks_pointing_outside(cx: &mut TestAppContext) { -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root", -// json!({ -// "dir1": { -// "deps": { -// // symlinks here -// }, -// "src": { -// "a.rs": "", -// "b.rs": "", -// }, -// }, -// "dir2": { -// "src": { -// "c.rs": "", -// "d.rs": "", -// } -// }, -// "dir3": { -// "deps": {}, -// "src": { -// "e.rs": "", -// "f.rs": "", -// }, -// } -// }), -// ) -// .await; - -// // These symlinks point to directories outside of the worktree's root, dir1. -// fs.insert_symlink("/root/dir1/deps/dep-dir2", "../../dir2".into()) -// .await; -// fs.insert_symlink("/root/dir1/deps/dep-dir3", "../../dir3".into()) -// .await; - -// let tree = Worktree::local( -// build_client(cx), -// Path::new("/root/dir1"), -// true, -// fs.clone(), -// Default::default(), -// &mut cx.to_async(), -// ) -// .await -// .unwrap(); - -// cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) -// .await; - -// let tree_updates = Arc::new(Mutex::new(Vec::new())); -// tree.update(cx, |_, cx| { -// let tree_updates = tree_updates.clone(); -// cx.subscribe(&tree, move |_, _, event, _| { -// if let Event::UpdatedEntries(update) = event { -// tree_updates.lock().extend( -// update -// .iter() -// .map(|(path, _, change)| (path.clone(), *change)), -// ); -// } -// }) -// .detach(); -// }); - -// // The symlinked directories are not scanned by default. -// tree.read_with(cx, |tree, _| { -// assert_eq!( -// tree.entries(true) -// .map(|entry| (entry.path.as_ref(), entry.is_external)) -// .collect::>(), -// vec![ -// (Path::new(""), false), -// (Path::new("deps"), false), -// (Path::new("deps/dep-dir2"), true), -// (Path::new("deps/dep-dir3"), true), -// (Path::new("src"), false), -// (Path::new("src/a.rs"), false), -// (Path::new("src/b.rs"), false), -// ] -// ); - -// assert_eq!( -// tree.entry_for_path("deps/dep-dir2").unwrap().kind, -// EntryKind::UnloadedDir -// ); -// }); - -// // Expand one of the symlinked directories. -// tree.read_with(cx, |tree, _| { -// tree.as_local() -// .unwrap() -// .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3").into()]) -// }) -// .recv() -// .await; - -// // The expanded directory's contents are loaded. Subdirectories are -// // not scanned yet. -// tree.read_with(cx, |tree, _| { -// assert_eq!( -// tree.entries(true) -// .map(|entry| (entry.path.as_ref(), entry.is_external)) -// .collect::>(), -// vec![ -// (Path::new(""), false), -// (Path::new("deps"), false), -// (Path::new("deps/dep-dir2"), true), -// (Path::new("deps/dep-dir3"), true), -// (Path::new("deps/dep-dir3/deps"), true), -// (Path::new("deps/dep-dir3/src"), true), -// (Path::new("src"), false), -// (Path::new("src/a.rs"), false), -// (Path::new("src/b.rs"), false), -// ] -// ); -// }); -// assert_eq!( -// mem::take(&mut *tree_updates.lock()), -// &[ -// (Path::new("deps/dep-dir3").into(), PathChange::Loaded), -// (Path::new("deps/dep-dir3/deps").into(), PathChange::Loaded), -// (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded) -// ] -// ); - -// // Expand a subdirectory of one of the symlinked directories. -// tree.read_with(cx, |tree, _| { -// tree.as_local() -// .unwrap() -// .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3/src").into()]) -// }) -// .recv() -// .await; - -// // The expanded subdirectory's contents are loaded. -// tree.read_with(cx, |tree, _| { -// assert_eq!( -// tree.entries(true) -// .map(|entry| (entry.path.as_ref(), entry.is_external)) -// .collect::>(), -// vec![ -// (Path::new(""), false), -// (Path::new("deps"), false), -// (Path::new("deps/dep-dir2"), true), -// (Path::new("deps/dep-dir3"), true), -// (Path::new("deps/dep-dir3/deps"), true), -// (Path::new("deps/dep-dir3/src"), true), -// (Path::new("deps/dep-dir3/src/e.rs"), true), -// (Path::new("deps/dep-dir3/src/f.rs"), true), -// (Path::new("src"), false), -// (Path::new("src/a.rs"), false), -// (Path::new("src/b.rs"), false), -// ] -// ); -// }); - -// assert_eq!( -// mem::take(&mut *tree_updates.lock()), -// &[ -// (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded), -// ( -// Path::new("deps/dep-dir3/src/e.rs").into(), -// PathChange::Loaded -// ), -// ( -// Path::new("deps/dep-dir3/src/f.rs").into(), -// PathChange::Loaded -// ) -// ] -// ); -// } - -// #[gpui::test] -// async fn test_open_gitignored_files(cx: &mut TestAppContext) { -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root", -// json!({ -// ".gitignore": "node_modules\n", -// "one": { -// "node_modules": { -// "a": { -// "a1.js": "a1", -// "a2.js": "a2", -// }, -// "b": { -// "b1.js": "b1", -// "b2.js": "b2", -// }, -// "c": { -// "c1.js": "c1", -// "c2.js": "c2", -// } -// }, -// }, -// "two": { -// "x.js": "", -// "y.js": "", -// }, -// }), -// ) -// .await; - -// let tree = Worktree::local( -// build_client(cx), -// 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(true) -// .map(|entry| (entry.path.as_ref(), entry.is_ignored)) -// .collect::>(), -// vec![ -// (Path::new(""), false), -// (Path::new(".gitignore"), false), -// (Path::new("one"), false), -// (Path::new("one/node_modules"), true), -// (Path::new("two"), false), -// (Path::new("two/x.js"), false), -// (Path::new("two/y.js"), false), -// ] -// ); -// }); - -// // Open a file that is nested inside of a gitignored directory that -// // has not yet been expanded. -// let prev_read_dir_count = fs.read_dir_call_count(); -// let buffer = tree -// .update(cx, |tree, cx| { -// tree.as_local_mut() -// .unwrap() -// .load_buffer(0, "one/node_modules/b/b1.js".as_ref(), cx) -// }) -// .await -// .unwrap(); - -// tree.read_with(cx, |tree, cx| { -// assert_eq!( -// tree.entries(true) -// .map(|entry| (entry.path.as_ref(), entry.is_ignored)) -// .collect::>(), -// vec![ -// (Path::new(""), false), -// (Path::new(".gitignore"), false), -// (Path::new("one"), false), -// (Path::new("one/node_modules"), true), -// (Path::new("one/node_modules/a"), true), -// (Path::new("one/node_modules/b"), true), -// (Path::new("one/node_modules/b/b1.js"), true), -// (Path::new("one/node_modules/b/b2.js"), true), -// (Path::new("one/node_modules/c"), true), -// (Path::new("two"), false), -// (Path::new("two/x.js"), false), -// (Path::new("two/y.js"), false), -// ] -// ); - -// assert_eq!( -// buffer.read(cx).file().unwrap().path().as_ref(), -// Path::new("one/node_modules/b/b1.js") -// ); - -// // Only the newly-expanded directories are scanned. -// assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 2); -// }); - -// // Open another file in a different subdirectory of the same -// // gitignored directory. -// let prev_read_dir_count = fs.read_dir_call_count(); -// let buffer = tree -// .update(cx, |tree, cx| { -// tree.as_local_mut() -// .unwrap() -// .load_buffer(0, "one/node_modules/a/a2.js".as_ref(), cx) -// }) -// .await -// .unwrap(); - -// tree.read_with(cx, |tree, cx| { -// assert_eq!( -// tree.entries(true) -// .map(|entry| (entry.path.as_ref(), entry.is_ignored)) -// .collect::>(), -// vec![ -// (Path::new(""), false), -// (Path::new(".gitignore"), false), -// (Path::new("one"), false), -// (Path::new("one/node_modules"), true), -// (Path::new("one/node_modules/a"), true), -// (Path::new("one/node_modules/a/a1.js"), true), -// (Path::new("one/node_modules/a/a2.js"), true), -// (Path::new("one/node_modules/b"), true), -// (Path::new("one/node_modules/b/b1.js"), true), -// (Path::new("one/node_modules/b/b2.js"), true), -// (Path::new("one/node_modules/c"), true), -// (Path::new("two"), false), -// (Path::new("two/x.js"), false), -// (Path::new("two/y.js"), false), -// ] -// ); - -// assert_eq!( -// buffer.read(cx).file().unwrap().path().as_ref(), -// Path::new("one/node_modules/a/a2.js") -// ); - -// // Only the newly-expanded directory is scanned. -// assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1); -// }); - -// // No work happens when files and directories change within an unloaded directory. -// let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count(); -// fs.create_dir("/root/one/node_modules/c/lib".as_ref()) -// .await -// .unwrap(); -// cx.foreground().run_until_parked(); -// assert_eq!( -// fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count, -// 0 -// ); -// } - -// #[gpui::test] -// async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root", -// json!({ -// ".gitignore": "node_modules\n", -// "a": { -// "a.js": "", -// }, -// "b": { -// "b.js": "", -// }, -// "node_modules": { -// "c": { -// "c.js": "", -// }, -// "d": { -// "d.js": "", -// "e": { -// "e1.js": "", -// "e2.js": "", -// }, -// "f": { -// "f1.js": "", -// "f2.js": "", -// } -// }, -// }, -// }), -// ) -// .await; - -// let tree = Worktree::local( -// build_client(cx), -// 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; - -// // Open a file within the gitignored directory, forcing some of its -// // subdirectories to be read, but not all. -// let read_dir_count_1 = fs.read_dir_call_count(); -// tree.read_with(cx, |tree, _| { -// tree.as_local() -// .unwrap() -// .refresh_entries_for_paths(vec![Path::new("node_modules/d/d.js").into()]) -// }) -// .recv() -// .await; - -// // Those subdirectories are now loaded. -// tree.read_with(cx, |tree, _| { -// assert_eq!( -// tree.entries(true) -// .map(|e| (e.path.as_ref(), e.is_ignored)) -// .collect::>(), -// &[ -// (Path::new(""), false), -// (Path::new(".gitignore"), false), -// (Path::new("a"), false), -// (Path::new("a/a.js"), false), -// (Path::new("b"), false), -// (Path::new("b/b.js"), false), -// (Path::new("node_modules"), true), -// (Path::new("node_modules/c"), true), -// (Path::new("node_modules/d"), true), -// (Path::new("node_modules/d/d.js"), true), -// (Path::new("node_modules/d/e"), true), -// (Path::new("node_modules/d/f"), true), -// ] -// ); -// }); -// let read_dir_count_2 = fs.read_dir_call_count(); -// assert_eq!(read_dir_count_2 - read_dir_count_1, 2); - -// // Update the gitignore so that node_modules is no longer ignored, -// // but a subdirectory is ignored -// fs.save("/root/.gitignore".as_ref(), &"e".into(), Default::default()) -// .await -// .unwrap(); -// cx.foreground().run_until_parked(); - -// // All of the directories that are no longer ignored are now loaded. -// tree.read_with(cx, |tree, _| { -// assert_eq!( -// tree.entries(true) -// .map(|e| (e.path.as_ref(), e.is_ignored)) -// .collect::>(), -// &[ -// (Path::new(""), false), -// (Path::new(".gitignore"), false), -// (Path::new("a"), false), -// (Path::new("a/a.js"), false), -// (Path::new("b"), false), -// (Path::new("b/b.js"), false), -// // This directory is no longer ignored -// (Path::new("node_modules"), false), -// (Path::new("node_modules/c"), false), -// (Path::new("node_modules/c/c.js"), false), -// (Path::new("node_modules/d"), false), -// (Path::new("node_modules/d/d.js"), false), -// // This subdirectory is now ignored -// (Path::new("node_modules/d/e"), true), -// (Path::new("node_modules/d/f"), false), -// (Path::new("node_modules/d/f/f1.js"), false), -// (Path::new("node_modules/d/f/f2.js"), false), -// ] -// ); -// }); - -// // Each of the newly-loaded directories is scanned only once. -// let read_dir_count_3 = fs.read_dir_call_count(); -// assert_eq!(read_dir_count_3 - read_dir_count_2, 2); -// } - -// #[gpui::test(iterations = 10)] -// async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { -// let fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root", -// 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": "" -// } -// } -// }), -// ) -// .await; - -// let tree = Worktree::local( -// build_client(cx), -// "/root/tree".as_ref(), -// 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, _| { -// tree.as_local() -// .unwrap() -// .refresh_entries_for_paths(vec![Path::new("ignored-dir").into()]) -// }) -// .recv() -// .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 -// ); -// }); - -// fs.create_file( -// "/root/tree/tracked-dir/tracked-file2".as_ref(), -// Default::default(), -// ) -// .await -// .unwrap(); -// fs.create_file( -// "/root/tree/tracked-dir/ancestor-ignored-file2".as_ref(), -// Default::default(), -// ) -// .await -// .unwrap(); -// fs.create_file( -// "/root/tree/ignored-dir/ignored-file2".as_ref(), -// Default::default(), -// ) -// .await -// .unwrap(); - -// cx.foreground().run_until_parked(); -// 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 tree = Worktree::local( -// build_client(cx), -// 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 fs = FakeFs::new(cx.background()); -// fs.insert_tree( -// "/root", -// json!({ -// "b": {}, -// "c": {}, -// "d": {}, -// }), -// ) -// .await; - -// let tree = Worktree::local( -// build_client(cx), -// "/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] -// async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { -// let client_fake = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - -// let fs_fake = FakeFs::new(cx.background()); -// fs_fake -// .insert_tree( -// "/root", -// json!({ -// "a": {}, -// }), -// ) -// .await; - -// let tree_fake = Worktree::local( -// client_fake, -// "/root".as_ref(), -// true, -// fs_fake, -// Default::default(), -// &mut cx.to_async(), -// ) -// .await -// .unwrap(); - -// let entry = tree_fake -// .update(cx, |tree, cx| { -// tree.as_local_mut() -// .unwrap() -// .create_entry("a/b/c/d.txt".as_ref(), false, cx) -// }) -// .await -// .unwrap(); -// assert!(entry.is_file()); - -// cx.foreground().run_until_parked(); -// tree_fake.read_with(cx, |tree, _| { -// assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file()); -// assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir()); -// assert!(tree.entry_for_path("a/b/").unwrap().is_dir()); -// }); - -// let client_real = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); - -// let fs_real = Arc::new(RealFs); -// let temp_root = temp_tree(json!({ -// "a": {} -// })); - -// let tree_real = Worktree::local( -// client_real, -// temp_root.path(), -// true, -// fs_real, -// Default::default(), -// &mut cx.to_async(), -// ) -// .await -// .unwrap(); - -// let entry = tree_real -// .update(cx, |tree, cx| { -// tree.as_local_mut() -// .unwrap() -// .create_entry("a/b/c/d.txt".as_ref(), false, cx) -// }) -// .await -// .unwrap(); -// assert!(entry.is_file()); - -// cx.foreground().run_until_parked(); -// tree_real.read_with(cx, |tree, _| { -// assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file()); -// assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir()); -// assert!(tree.entry_for_path("a/b/").unwrap().is_dir()); -// }); - -// // Test smallest change -// let entry = tree_real -// .update(cx, |tree, cx| { -// tree.as_local_mut() -// .unwrap() -// .create_entry("a/b/c/e.txt".as_ref(), false, cx) -// }) -// .await -// .unwrap(); -// assert!(entry.is_file()); - -// cx.foreground().run_until_parked(); -// tree_real.read_with(cx, |tree, _| { -// assert!(tree.entry_for_path("a/b/c/e.txt").unwrap().is_file()); -// }); - -// // Test largest change -// let entry = tree_real -// .update(cx, |tree, cx| { -// tree.as_local_mut() -// .unwrap() -// .create_entry("d/e/f/g.txt".as_ref(), false, cx) -// }) -// .await -// .unwrap(); -// assert!(entry.is_file()); - -// cx.foreground().run_until_parked(); -// tree_real.read_with(cx, |tree, _| { -// assert!(tree.entry_for_path("d/e/f/g.txt").unwrap().is_file()); -// assert!(tree.entry_for_path("d/e/f").unwrap().is_dir()); -// assert!(tree.entry_for_path("d/e/").unwrap().is_dir()); -// assert!(tree.entry_for_path("d/").unwrap().is_dir()); -// }); -// } - -// #[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 worktree = Worktree::local( -// build_client(cx), -// 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(true) -// }); - -// 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(true); -// 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 worktree = Worktree::local( -// build_client(cx), -// 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(true); -// let expanded_paths = snapshot -// .expanded_entries() -// .map(|e| e.path.clone()) -// .collect::>(); - -// { -// let new_worktree = Worktree::local( -// build_client(cx), -// 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; -// new_worktree -// .update(cx, |tree, _| { -// tree.as_local_mut() -// .unwrap() -// .refresh_entries_for_paths(expanded_paths) -// }) -// .recv() -// .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) -// .map(ignore_pending_dir) -// .collect::>(), -// snapshot -// .entries(true) -// .map(ignore_pending_dir) -// .collect::>(), -// "wrong updates after snapshot {i}: {updates:#?}", -// ); -// } - -// fn ignore_pending_dir(entry: &Entry) -> Entry { -// let mut entry = entry.clone(); -// if entry.kind.is_dir() { -// entry.kind = EntryKind::Dir -// } -// entry -// } -// } - -// // 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::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 | PathChange::Loaded => { -// 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 tree = Worktree::local( -// build_client(cx), -// 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 tree = Worktree::local( -// build_client(cx), -// 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 -// }, - -// })); - -// 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::new("project"); - -// // Set up git repository before creating the worktree. -// 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(A_TXT, &repo); -// git_add(E_TXT, &repo); -// git_add(DOTGITIGNORE, &repo); -// git_commit("Initial commit", &repo); - -// let tree = Worktree::local( -// build_client(cx), -// root.path(), -// true, -// Arc::new(RealFs), -// Default::default(), -// &mut cx.to_async(), -// ) -// .await -// .unwrap(); - -// tree.flush_fs_events(cx).await; -// cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) -// .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) -// ); -// }); - -// // Modify a file in the working copy. -// std::fs::write(work_dir.join(A_TXT), "aa").unwrap(); -// tree.flush_fs_events(cx).await; -// deterministic.run_until_parked(); - -// // The worktree detects that the file's git status has changed. -// tree.read_with(cx, |tree, _cx| { -// let snapshot = tree.snapshot(); -// assert_eq!( -// snapshot.status_for_file(project_path.join(A_TXT)), -// Some(GitFileStatus::Modified) -// ); -// }); - -// // Create a commit in the git repository. -// git_add(A_TXT, &repo); -// git_add(B_TXT, &repo); -// git_commit("Committing modified and added", &repo); -// tree.flush_fs_events(cx).await; -// deterministic.run_until_parked(); - -// // The worktree detects that the files' git status have changed. -// 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); -// }); - -// // Modify files in the working copy and perform git operations on other files. -// 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 tree = Worktree::local( -// build_client(cx), -// 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 -// ); -// } -// } - -// fn build_client(cx: &mut TestAppContext) -> Arc { -// let http_client = FakeHttpClient::with_404_response(); -// cx.read(|cx| Client::new(http_client, cx)) -// } - -// #[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() -// } +use crate::{ + project_settings::ProjectSettings, + worktree::{Event, Snapshot, WorktreeModelHandle}, + Entry, EntryKind, PathChange, Project, Worktree, +}; +use anyhow::Result; +use client::Client; +use fs::{repository::GitFileStatus, FakeFs, Fs, RealFs, RemoveOptions}; +use git::GITIGNORE; +use gpui::{ModelContext, Task, TestAppContext}; +use parking_lot::Mutex; +use postage::stream::Stream; +use pretty_assertions::assert_eq; +use rand::prelude::*; +use serde_json::json; +use settings::SettingsStore; +use std::{ + env, + fmt::Write, + mem, + path::{Path, PathBuf}, + sync::Arc, +}; +use util::{http::FakeHttpClient, test::temp_tree, ResultExt}; + +#[gpui::test] +async fn test_traversal(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "a/b\n", + "a": { + "b": "", + "c": "", + } + }), + ) + .await; + + let tree = Worktree::local( + build_client(cx), + 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) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "a": "", + "b": { + "c": { + "d": "" + }, + "e": {} + }, + "f": "", + "g": { + "h": {} + }, + "i": { + "j": { + "k": "" + }, + "l": { + + } + }, + ".gitignore": "i/j\n", + }), + ) + .await; + + let tree = Worktree::local( + build_client(cx), + 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"),] + ); + }); + + // Expand gitignored directory. + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![Path::new("i/j").into()]) + }) + .recv() + .await; + + tree.read_with(cx, |tree, _| { + 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(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + 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 tree = Worktree::local( + build_client(cx), + 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(); + cx.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_symlinks_pointing_outside(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "deps": { + // symlinks here + }, + "src": { + "a.rs": "", + "b.rs": "", + }, + }, + "dir2": { + "src": { + "c.rs": "", + "d.rs": "", + } + }, + "dir3": { + "deps": {}, + "src": { + "e.rs": "", + "f.rs": "", + }, + } + }), + ) + .await; + + // These symlinks point to directories outside of the worktree's root, dir1. + fs.insert_symlink("/root/dir1/deps/dep-dir2", "../../dir2".into()) + .await; + fs.insert_symlink("/root/dir1/deps/dep-dir3", "../../dir3".into()) + .await; + + let tree = Worktree::local( + build_client(cx), + Path::new("/root/dir1"), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + let tree_updates = Arc::new(Mutex::new(Vec::new())); + tree.update(cx, |_, cx| { + let tree_updates = tree_updates.clone(); + cx.subscribe(&tree, move |_, _, event, _| { + if let Event::UpdatedEntries(update) = event { + tree_updates.lock().extend( + update + .iter() + .map(|(path, _, change)| (path.clone(), *change)), + ); + } + }) + .detach(); + }); + + // The symlinked directories are not scanned by default. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (Path::new(""), false), + (Path::new("deps"), false), + (Path::new("deps/dep-dir2"), true), + (Path::new("deps/dep-dir3"), true), + (Path::new("src"), false), + (Path::new("src/a.rs"), false), + (Path::new("src/b.rs"), false), + ] + ); + + assert_eq!( + tree.entry_for_path("deps/dep-dir2").unwrap().kind, + EntryKind::UnloadedDir + ); + }); + + // Expand one of the symlinked directories. + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3").into()]) + }) + .recv() + .await; + + // The expanded directory's contents are loaded. Subdirectories are + // not scanned yet. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (Path::new(""), false), + (Path::new("deps"), false), + (Path::new("deps/dep-dir2"), true), + (Path::new("deps/dep-dir3"), true), + (Path::new("deps/dep-dir3/deps"), true), + (Path::new("deps/dep-dir3/src"), true), + (Path::new("src"), false), + (Path::new("src/a.rs"), false), + (Path::new("src/b.rs"), false), + ] + ); + }); + assert_eq!( + mem::take(&mut *tree_updates.lock()), + &[ + (Path::new("deps/dep-dir3").into(), PathChange::Loaded), + (Path::new("deps/dep-dir3/deps").into(), PathChange::Loaded), + (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded) + ] + ); + + // Expand a subdirectory of one of the symlinked directories. + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![Path::new("deps/dep-dir3/src").into()]) + }) + .recv() + .await; + + // The expanded subdirectory's contents are loaded. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (Path::new(""), false), + (Path::new("deps"), false), + (Path::new("deps/dep-dir2"), true), + (Path::new("deps/dep-dir3"), true), + (Path::new("deps/dep-dir3/deps"), true), + (Path::new("deps/dep-dir3/src"), true), + (Path::new("deps/dep-dir3/src/e.rs"), true), + (Path::new("deps/dep-dir3/src/f.rs"), true), + (Path::new("src"), false), + (Path::new("src/a.rs"), false), + (Path::new("src/b.rs"), false), + ] + ); + }); + + assert_eq!( + mem::take(&mut *tree_updates.lock()), + &[ + (Path::new("deps/dep-dir3/src").into(), PathChange::Loaded), + ( + Path::new("deps/dep-dir3/src/e.rs").into(), + PathChange::Loaded + ), + ( + Path::new("deps/dep-dir3/src/f.rs").into(), + PathChange::Loaded + ) + ] + ); +} + +#[gpui::test] +async fn test_open_gitignored_files(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "node_modules\n", + "one": { + "node_modules": { + "a": { + "a1.js": "a1", + "a2.js": "a2", + }, + "b": { + "b1.js": "b1", + "b2.js": "b2", + }, + "c": { + "c1.js": "c1", + "c2.js": "c2", + } + }, + }, + "two": { + "x.js": "", + "y.js": "", + }, + }), + ) + .await; + + let tree = Worktree::local( + build_client(cx), + 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(true) + .map(|entry| (entry.path.as_ref(), entry.is_ignored)) + .collect::>(), + vec![ + (Path::new(""), false), + (Path::new(".gitignore"), false), + (Path::new("one"), false), + (Path::new("one/node_modules"), true), + (Path::new("two"), false), + (Path::new("two/x.js"), false), + (Path::new("two/y.js"), false), + ] + ); + }); + + // Open a file that is nested inside of a gitignored directory that + // has not yet been expanded. + let prev_read_dir_count = fs.read_dir_call_count(); + let buffer = tree + .update(cx, |tree, cx| { + tree.as_local_mut() + .unwrap() + .load_buffer(0, "one/node_modules/b/b1.js".as_ref(), cx) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, cx| { + assert_eq!( + tree.entries(true) + .map(|entry| (entry.path.as_ref(), entry.is_ignored)) + .collect::>(), + vec![ + (Path::new(""), false), + (Path::new(".gitignore"), false), + (Path::new("one"), false), + (Path::new("one/node_modules"), true), + (Path::new("one/node_modules/a"), true), + (Path::new("one/node_modules/b"), true), + (Path::new("one/node_modules/b/b1.js"), true), + (Path::new("one/node_modules/b/b2.js"), true), + (Path::new("one/node_modules/c"), true), + (Path::new("two"), false), + (Path::new("two/x.js"), false), + (Path::new("two/y.js"), false), + ] + ); + + assert_eq!( + buffer.read(cx).file().unwrap().path().as_ref(), + Path::new("one/node_modules/b/b1.js") + ); + + // Only the newly-expanded directories are scanned. + assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 2); + }); + + // Open another file in a different subdirectory of the same + // gitignored directory. + let prev_read_dir_count = fs.read_dir_call_count(); + let buffer = tree + .update(cx, |tree, cx| { + tree.as_local_mut() + .unwrap() + .load_buffer(0, "one/node_modules/a/a2.js".as_ref(), cx) + }) + .await + .unwrap(); + + tree.read_with(cx, |tree, cx| { + assert_eq!( + tree.entries(true) + .map(|entry| (entry.path.as_ref(), entry.is_ignored)) + .collect::>(), + vec![ + (Path::new(""), false), + (Path::new(".gitignore"), false), + (Path::new("one"), false), + (Path::new("one/node_modules"), true), + (Path::new("one/node_modules/a"), true), + (Path::new("one/node_modules/a/a1.js"), true), + (Path::new("one/node_modules/a/a2.js"), true), + (Path::new("one/node_modules/b"), true), + (Path::new("one/node_modules/b/b1.js"), true), + (Path::new("one/node_modules/b/b2.js"), true), + (Path::new("one/node_modules/c"), true), + (Path::new("two"), false), + (Path::new("two/x.js"), false), + (Path::new("two/y.js"), false), + ] + ); + + assert_eq!( + buffer.read(cx).file().unwrap().path().as_ref(), + Path::new("one/node_modules/a/a2.js") + ); + + // Only the newly-expanded directory is scanned. + assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1); + }); + + // No work happens when files and directories change within an unloaded directory. + let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count(); + fs.create_dir("/root/one/node_modules/c/lib".as_ref()) + .await + .unwrap(); + cx.executor().run_until_parked(); + assert_eq!( + fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count, + 0 + ); +} + +#[gpui::test] +async fn test_dirs_no_longer_ignored(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "node_modules\n", + "a": { + "a.js": "", + }, + "b": { + "b.js": "", + }, + "node_modules": { + "c": { + "c.js": "", + }, + "d": { + "d.js": "", + "e": { + "e1.js": "", + "e2.js": "", + }, + "f": { + "f1.js": "", + "f2.js": "", + } + }, + }, + }), + ) + .await; + + let tree = Worktree::local( + build_client(cx), + 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; + + // Open a file within the gitignored directory, forcing some of its + // subdirectories to be read, but not all. + let read_dir_count_1 = fs.read_dir_call_count(); + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![Path::new("node_modules/d/d.js").into()]) + }) + .recv() + .await; + + // Those subdirectories are now loaded. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true) + .map(|e| (e.path.as_ref(), e.is_ignored)) + .collect::>(), + &[ + (Path::new(""), false), + (Path::new(".gitignore"), false), + (Path::new("a"), false), + (Path::new("a/a.js"), false), + (Path::new("b"), false), + (Path::new("b/b.js"), false), + (Path::new("node_modules"), true), + (Path::new("node_modules/c"), true), + (Path::new("node_modules/d"), true), + (Path::new("node_modules/d/d.js"), true), + (Path::new("node_modules/d/e"), true), + (Path::new("node_modules/d/f"), true), + ] + ); + }); + let read_dir_count_2 = fs.read_dir_call_count(); + assert_eq!(read_dir_count_2 - read_dir_count_1, 2); + + // Update the gitignore so that node_modules is no longer ignored, + // but a subdirectory is ignored + fs.save("/root/.gitignore".as_ref(), &"e".into(), Default::default()) + .await + .unwrap(); + cx.executor().run_until_parked(); + + // All of the directories that are no longer ignored are now loaded. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true) + .map(|e| (e.path.as_ref(), e.is_ignored)) + .collect::>(), + &[ + (Path::new(""), false), + (Path::new(".gitignore"), false), + (Path::new("a"), false), + (Path::new("a/a.js"), false), + (Path::new("b"), false), + (Path::new("b/b.js"), false), + // This directory is no longer ignored + (Path::new("node_modules"), false), + (Path::new("node_modules/c"), false), + (Path::new("node_modules/c/c.js"), false), + (Path::new("node_modules/d"), false), + (Path::new("node_modules/d/d.js"), false), + // This subdirectory is now ignored + (Path::new("node_modules/d/e"), true), + (Path::new("node_modules/d/f"), false), + (Path::new("node_modules/d/f/f1.js"), false), + (Path::new("node_modules/d/f/f2.js"), false), + ] + ); + }); + + // Each of the newly-loaded directories is scanned only once. + let read_dir_count_3 = fs.read_dir_call_count(); + assert_eq!(read_dir_count_3 - read_dir_count_2, 2); +} + +#[gpui::test(iterations = 10)] +async fn test_rescan_with_gitignore(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + }); + }); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + 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": "" + } + } + }), + ) + .await; + + let tree = Worktree::local( + build_client(cx), + "/root/tree".as_ref(), + 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, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![Path::new("ignored-dir").into()]) + }) + .recv() + .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 + ); + }); + + fs.create_file( + "/root/tree/tracked-dir/tracked-file2".as_ref(), + Default::default(), + ) + .await + .unwrap(); + fs.create_file( + "/root/tree/tracked-dir/ancestor-ignored-file2".as_ref(), + Default::default(), + ) + .await + .unwrap(); + fs.create_file( + "/root/tree/ignored-dir/ignored-file2".as_ref(), + Default::default(), + ) + .await + .unwrap(); + + cx.executor().run_until_parked(); + 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) { + init_test(cx); + cx.executor().allow_parking(); + let dir = temp_tree(json!({ + ".git": {}, + ".gitignore": "ignored-dir\n", + "tracked-dir": {}, + "ignored-dir": {} + })); + + let tree = Worktree::local( + build_client(cx), + 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] +async fn test_file_scan_exclusions(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + let dir = temp_tree(json!({ + ".gitignore": "**/target\n/node_modules\n", + "target": { + "index": "blah2" + }, + "node_modules": { + ".DS_Store": "", + "prettier": { + "package.json": "{}", + }, + }, + "src": { + ".DS_Store": "", + "foo": { + "foo.rs": "mod another;\n", + "another.rs": "// another", + }, + "bar": { + "bar.rs": "// bar", + }, + "lib.rs": "mod foo;\nmod bar;\n", + }, + ".DS_Store": "", + })); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["**/foo/**".to_string(), "**/.DS_Store".to_string()]); + }); + }); + }); + + let tree = Worktree::local( + build_client(cx), + 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.read_with(cx, |tree, _| { + check_worktree_entries( + tree, + &[ + "src/foo/foo.rs", + "src/foo/another.rs", + "node_modules/.DS_Store", + "src/.DS_Store", + ".DS_Store", + ], + &["target", "node_modules"], + &["src/lib.rs", "src/bar/bar.rs", ".gitignore"], + ) + }); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["**/node_modules/**".to_string()]); + }); + }); + }); + tree.flush_fs_events(cx).await; + cx.executor().run_until_parked(); + tree.read_with(cx, |tree, _| { + check_worktree_entries( + tree, + &[ + "node_modules/prettier/package.json", + "node_modules/.DS_Store", + "node_modules", + ], + &["target"], + &[ + ".gitignore", + "src/lib.rs", + "src/bar/bar.rs", + "src/foo/foo.rs", + "src/foo/another.rs", + "src/.DS_Store", + ".DS_Store", + ], + ) + }); +} + +#[gpui::test(iterations = 30)] +async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "b": {}, + "c": {}, + "d": {}, + }), + ) + .await; + + let tree = Worktree::local( + build_client(cx), + "/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.executor().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] +async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + let client_fake = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + + let fs_fake = FakeFs::new(cx.background_executor.clone()); + fs_fake + .insert_tree( + "/root", + json!({ + "a": {}, + }), + ) + .await; + + let tree_fake = Worktree::local( + client_fake, + "/root".as_ref(), + true, + fs_fake, + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let entry = tree_fake + .update(cx, |tree, cx| { + tree.as_local_mut() + .unwrap() + .create_entry("a/b/c/d.txt".as_ref(), false, cx) + }) + .await + .unwrap(); + assert!(entry.is_file()); + + cx.executor().run_until_parked(); + tree_fake.read_with(cx, |tree, _| { + assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file()); + assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir()); + assert!(tree.entry_for_path("a/b/").unwrap().is_dir()); + }); + + let client_real = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx)); + + let fs_real = Arc::new(RealFs); + let temp_root = temp_tree(json!({ + "a": {} + })); + + let tree_real = Worktree::local( + client_real, + temp_root.path(), + true, + fs_real, + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + let entry = tree_real + .update(cx, |tree, cx| { + tree.as_local_mut() + .unwrap() + .create_entry("a/b/c/d.txt".as_ref(), false, cx) + }) + .await + .unwrap(); + assert!(entry.is_file()); + + cx.executor().run_until_parked(); + tree_real.read_with(cx, |tree, _| { + assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file()); + assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir()); + assert!(tree.entry_for_path("a/b/").unwrap().is_dir()); + }); + + // Test smallest change + let entry = tree_real + .update(cx, |tree, cx| { + tree.as_local_mut() + .unwrap() + .create_entry("a/b/c/e.txt".as_ref(), false, cx) + }) + .await + .unwrap(); + assert!(entry.is_file()); + + cx.executor().run_until_parked(); + tree_real.read_with(cx, |tree, _| { + assert!(tree.entry_for_path("a/b/c/e.txt").unwrap().is_file()); + }); + + // Test largest change + let entry = tree_real + .update(cx, |tree, cx| { + tree.as_local_mut() + .unwrap() + .create_entry("d/e/f/g.txt".as_ref(), false, cx) + }) + .await + .unwrap(); + assert!(entry.is_file()); + + cx.executor().run_until_parked(); + tree_real.read_with(cx, |tree, _| { + assert!(tree.entry_for_path("d/e/f/g.txt").unwrap().is_file()); + assert!(tree.entry_for_path("d/e/f").unwrap().is_dir()); + assert!(tree.entry_for_path("d/e/").unwrap().is_dir()); + assert!(tree.entry_for_path("d/").unwrap().is_dir()); + }); +} + +#[gpui::test(iterations = 100)] +async fn test_random_worktree_operations_during_initial_scan( + cx: &mut TestAppContext, + mut rng: StdRng, +) { + init_test(cx); + 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_executor.clone()) 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 worktree = Worktree::local( + build_client(cx), + 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(true) + }); + + 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.executor().run_until_parked(); + + let final_snapshot = worktree.read_with(cx, |tree, _| { + let tree = tree.as_local().unwrap(); + let snapshot = tree.snapshot(); + snapshot.check_invariants(true); + 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) { + init_test(cx); + 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_executor.clone()) 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 worktree = Worktree::local( + build_client(cx), + 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.executor().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.executor().run_until_parked(); + + let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); + snapshot.check_invariants(true); + let expanded_paths = snapshot + .expanded_entries() + .map(|e| e.path.clone()) + .collect::>(); + + { + let new_worktree = Worktree::local( + build_client(cx), + 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; + new_worktree + .update(cx, |tree, _| { + tree.as_local_mut() + .unwrap() + .refresh_entries_for_paths(expanded_paths) + }) + .recv() + .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) + .map(ignore_pending_dir) + .collect::>(), + snapshot + .entries(true) + .map(ignore_pending_dir) + .collect::>(), + "wrong updates after snapshot {i}: {updates:#?}", + ); + } + + fn ignore_pending_dir(entry: &Entry) -> Entry { + let mut entry = entry.clone(); + if entry.kind.is_dir() { + entry.kind = EntryKind::Dir + } + entry + } +} + +// 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::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 | PathChange::Loaded => { + 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.background_executor().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.background_executor().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) { + init_test(cx); + cx.executor().allow_parking(); + let root = temp_tree(json!({ + "projects": { + "project1": { + "a": "", + "b": "", + } + }, + + })); + let root_path = root.path(); + + let tree = Worktree::local( + build_client(cx), + 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) { + init_test(cx); + cx.executor().allow_parking(); + let root = temp_tree(json!({ + "c.txt": "", + "dir1": { + ".git": {}, + "deps": { + "dep1": { + ".git": {}, + "src": { + "a.txt": "" + } + } + }, + "src": { + "b.txt": "" + } + }, + })); + + let tree = Worktree::local( + build_client(cx), + 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(cx: &mut TestAppContext) { + init_test(cx); + cx.executor().allow_parking(); + 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 + }, + + })); + + 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::new("project"); + + // Set up git repository before creating the worktree. + 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(A_TXT, &repo); + git_add(E_TXT, &repo); + git_add(DOTGITIGNORE, &repo); + git_commit("Initial commit", &repo); + + let tree = Worktree::local( + build_client(cx), + root.path(), + true, + Arc::new(RealFs), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + tree.flush_fs_events(cx).await; + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + cx.executor().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) + ); + }); + + // Modify a file in the working copy. + std::fs::write(work_dir.join(A_TXT), "aa").unwrap(); + tree.flush_fs_events(cx).await; + cx.executor().run_until_parked(); + + // The worktree detects that the file's git status has changed. + tree.read_with(cx, |tree, _cx| { + let snapshot = tree.snapshot(); + assert_eq!( + snapshot.status_for_file(project_path.join(A_TXT)), + Some(GitFileStatus::Modified) + ); + }); + + // Create a commit in the git repository. + git_add(A_TXT, &repo); + git_add(B_TXT, &repo); + git_commit("Committing modified and added", &repo); + tree.flush_fs_events(cx).await; + cx.executor().run_until_parked(); + + // The worktree detects that the files' git status have changed. + 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); + }); + + // Modify files in the working copy and perform git operations on other files. + 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; + cx.executor().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; + cx.executor().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; + cx.executor().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; + cx.executor().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) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + 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 tree = Worktree::local( + build_client(cx), + 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.executor().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 + ); + } +} + +fn build_client(cx: &mut TestAppContext) -> Arc { + let http_client = FakeHttpClient::with_404_response(); + cx.read(|cx| Client::new(http_client, cx)) +} + +#[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() +} + +#[track_caller] +fn check_worktree_entries( + tree: &Worktree, + expected_excluded_paths: &[&str], + expected_ignored_paths: &[&str], + expected_tracked_paths: &[&str], +) { + for path in expected_excluded_paths { + let entry = tree.entry_for_path(path); + assert!( + entry.is_none(), + "expected path '{path}' to be excluded, but got entry: {entry:?}", + ); + } + for path in expected_ignored_paths { + let entry = tree + .entry_for_path(path) + .unwrap_or_else(|| panic!("Missing entry for expected ignored path '{path}'")); + assert!( + entry.is_ignored, + "expected path '{path}' to be ignored, but got entry: {entry:?}", + ); + } + for path in expected_tracked_paths { + let entry = tree + .entry_for_path(path) + .unwrap_or_else(|| panic!("Missing entry for expected tracked path '{path}'")); + assert!( + !entry.is_ignored, + "expected path '{path}' to be tracked, but got entry: {entry:?}", + ); + } +} + +fn init_test(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + Project::init_settings(cx); + }); +} diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index d66de1ad2edc365e0d83f05829aba8fbc72a90ac..eb124bfca28840f4b99a3b022abbbee33611fc0e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1732,7 +1732,7 @@ mod tests { use super::*; use gpui::{AnyWindowHandle, TestAppContext, ViewHandle, WindowHandle}; use pretty_assertions::assert_eq; - use project::FakeFs; + use project::{project_settings::ProjectSettings, FakeFs}; use serde_json::json; use settings::SettingsStore; use std::{ @@ -1832,6 +1832,123 @@ mod tests { ); } + #[gpui::test] + async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/4/**".to_string()]); + }); + }); + }); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "4": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx + .add_window(|cx| Workspace::test_new(project.clone(), cx)) + .root(cx); + let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root1/b", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b <== selected", + " > 3", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root2/d", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b", + " > 3", + " > C", + " .dockerignore", + "v root2", + " v d <== selected", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root2/e", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b", + " > 3", + " > C", + " .dockerignore", + "v root2", + " v d", + " v e <== selected", + ] + ); + } + #[gpui::test(iterations = 30)] async fn test_editing_files(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -2929,6 +3046,12 @@ mod tests { workspace::init_settings(cx); client::init_settings(cx); Project::init_settings(cx); + + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + }); }); } diff --git a/crates/project_panel2/Cargo.toml b/crates/project_panel2/Cargo.toml index bd6bc59a652c9945280233bd8588a15ea25834e4..48abfbe1def3493c46e0ddbb21ad077232a41a70 100644 --- a/crates/project_panel2/Cargo.toml +++ b/crates/project_panel2/Cargo.toml @@ -9,7 +9,6 @@ path = "src/project_panel.rs" doctest = false [dependencies] -context_menu = { path = "../context_menu" } collections = { path = "../collections" } db = { path = "../db2", package = "db2" } editor = { path = "../editor2", package = "editor2" } diff --git a/crates/project_panel2/src/project_panel.rs b/crates/project_panel2/src/project_panel.rs index 7a455fe8ce67c174258414db350a81a7a6b9909b..4d1a6ee8f73d5df2f8f00f3ee904dc255feeebfb 100644 --- a/crates/project_panel2/src/project_panel.rs +++ b/crates/project_panel2/src/project_panel.rs @@ -1,6 +1,6 @@ pub mod file_associations; mod project_panel_settings; -use settings::Settings; +use settings::{Settings, SettingsStore}; use db::kvp::KEY_VALUE_STORE; use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor}; @@ -9,9 +9,9 @@ use file_associations::FileAssociations; use anyhow::{anyhow, Result}; use gpui::{ actions, div, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext, - ClipboardItem, Component, Div, EventEmitter, FocusHandle, Focusable, FocusableView, - InteractiveComponent, Model, MouseButton, ParentComponent, Pixels, Point, PromptLevel, Render, - Stateful, StatefulInteractiveComponent, Styled, Task, UniformListScrollHandle, View, + ClipboardItem, Div, EventEmitter, FocusHandle, Focusable, FocusableView, InteractiveElement, + Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, + RenderOnce, Stateful, StatefulInteractiveElement, Styled, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext, }; use menu::{Confirm, SelectNext, SelectPrev}; @@ -34,7 +34,7 @@ use ui::{h_stack, v_stack, IconElement, Label}; use unicase::UniCase; use util::{maybe, ResultExt, TryFutureExt}; use workspace::{ - dock::{DockPosition, PanelEvent}, + dock::{DockPosition, Panel, PanelEvent}, Workspace, }; @@ -148,7 +148,6 @@ pub enum Event { SplitEntry { entry_id: ProjectEntryId, }, - DockPositionChanged, Focus, NewSearchInDirectory { dir_entry: Entry, @@ -200,10 +199,11 @@ impl ProjectPanel { let filename_editor = cx.build_view(|cx| Editor::single_line(cx)); cx.subscribe(&filename_editor, |this, _, event, cx| match event { - editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => { + editor::EditorEvent::BufferEdited + | editor::EditorEvent::SelectionsChanged { .. } => { this.autoscroll(cx); } - editor::Event::Blurred => { + editor::EditorEvent::Blurred => { if this .edit_state .as_ref() @@ -244,16 +244,16 @@ impl ProjectPanel { this.update_visible_entries(None, cx); // Update the dock position when the setting changes. - // todo!() - // let mut old_dock_position = this.position(cx); - // cx.observe_global::(move |this, cx| { - // let new_dock_position = this.position(cx); - // if new_dock_position != old_dock_position { - // old_dock_position = new_dock_position; - // cx.emit(Event::DockPositionChanged); - // } - // }) - // .detach(); + let mut old_dock_position = this.position(cx); + ProjectPanelSettings::register(cx); + cx.observe_global::(move |this, cx| { + let new_dock_position = this.position(cx); + if new_dock_position != old_dock_position { + old_dock_position = new_dock_position; + cx.emit(PanelEvent::ChangePosition); + } + }) + .detach(); this }); @@ -1339,7 +1339,7 @@ impl ProjectPanel { editor: Option<&View>, padding: Pixels, cx: &mut ViewContext, - ) -> Div { + ) -> Div { let show_editor = details.is_editing && !details.is_processing; let theme = cx.theme(); @@ -1378,7 +1378,7 @@ impl ProjectPanel { details: EntryDetails, // dragged_entry_destination: &mut Option>, cx: &mut ViewContext, - ) -> Stateful> { + ) -> Stateful
{ let kind = details.kind; let settings = ProjectPanelSettings::get_global(cx); const INDENT_SIZE: Pixels = px(16.0); @@ -1396,7 +1396,7 @@ impl ProjectPanel { this.bg(cx.theme().colors().element_selected) }) .hover(|style| style.bg(cx.theme().colors().element_hover)) - .on_click(move |this, event, cx| { + .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| { if !show_editor { if kind.is_dir() { this.toggle_expanded(entry_id, cx); @@ -1408,10 +1408,13 @@ impl ProjectPanel { } } } - }) - .on_mouse_down(MouseButton::Right, move |this, event, cx| { - this.deploy_context_menu(event.position, entry_id, cx); - }) + })) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, cx| { + this.deploy_context_menu(event.position, entry_id, cx); + }), + ) // .on_drop::(|this, event, cx| { // this.move_entry( // *dragged_entry, @@ -1424,9 +1427,9 @@ impl ProjectPanel { } impl Render for ProjectPanel { - type Element = Focusable>>; + type Element = Focusable>; - fn render(&mut self, _cx: &mut gpui::ViewContext) -> Self::Element { + fn render(&mut self, cx: &mut gpui::ViewContext) -> Self::Element { let has_worktree = self.visible_entries.len() != 0; if has_worktree { @@ -1434,40 +1437,43 @@ impl Render for ProjectPanel { .id("project-panel") .size_full() .key_context("ProjectPanel") - .on_action(Self::select_next) - .on_action(Self::select_prev) - .on_action(Self::expand_selected_entry) - .on_action(Self::collapse_selected_entry) - .on_action(Self::collapse_all_entries) - .on_action(Self::new_file) - .on_action(Self::new_directory) - .on_action(Self::rename) - .on_action(Self::delete) - .on_action(Self::confirm) - .on_action(Self::open_file) - .on_action(Self::cancel) - .on_action(Self::cut) - .on_action(Self::copy) - .on_action(Self::copy_path) - .on_action(Self::copy_relative_path) - .on_action(Self::paste) - .on_action(Self::reveal_in_finder) - .on_action(Self::open_in_terminal) - .on_action(Self::new_search_in_directory) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::expand_selected_entry)) + .on_action(cx.listener(Self::collapse_selected_entry)) + .on_action(cx.listener(Self::collapse_all_entries)) + .on_action(cx.listener(Self::new_file)) + .on_action(cx.listener(Self::new_directory)) + .on_action(cx.listener(Self::rename)) + .on_action(cx.listener(Self::delete)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::open_file)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::cut)) + .on_action(cx.listener(Self::copy)) + .on_action(cx.listener(Self::copy_path)) + .on_action(cx.listener(Self::copy_relative_path)) + .on_action(cx.listener(Self::paste)) + .on_action(cx.listener(Self::reveal_in_finder)) + .on_action(cx.listener(Self::open_in_terminal)) + .on_action(cx.listener(Self::new_search_in_directory)) .track_focus(&self.focus_handle) .child( uniform_list( + cx.view().clone(), "entries", self.visible_entries .iter() .map(|(_, worktree_entries)| worktree_entries.len()) .sum(), - |this: &mut Self, range, cx| { - let mut items = Vec::new(); - this.for_each_visible_entry(range, cx, |id, details, cx| { - items.push(this.render_entry(id, details, cx)); - }); - items + { + |this, range, cx| { + let mut items = Vec::new(); + this.for_each_visible_entry(range, cx, |id, details, cx| { + items.push(this.render_entry(id, details, cx)); + }); + items + } }, ) .size_full() @@ -1485,7 +1491,7 @@ impl EventEmitter for ProjectPanel {} impl EventEmitter for ProjectPanel {} -impl workspace::dock::Panel for ProjectPanel { +impl Panel for ProjectPanel { fn position(&self, cx: &WindowContext) -> DockPosition { match ProjectPanelSettings::get_global(cx).dock { ProjectPanelDockPosition::Left => DockPosition::Left, @@ -1571,7 +1577,7 @@ mod tests { use super::*; use gpui::{TestAppContext, View, VisualTestContext, WindowHandle}; use pretty_assertions::assert_eq; - use project::FakeFs; + use project::{project_settings::ProjectSettings, FakeFs}; use serde_json::json; use settings::SettingsStore; use std::{ @@ -1672,6 +1678,124 @@ mod tests { ); } + #[gpui::test] + async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/4/**".to_string()]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "4": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root1/b", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b <== selected", + " > 3", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root2/d", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b", + " > 3", + " > C", + " .dockerignore", + "v root2", + " v d <== selected", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root2/e", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b", + " > 3", + " > C", + " .dockerignore", + "v root2", + " v d", + " v e <== selected", + ] + ); + } + #[gpui::test(iterations = 30)] async fn test_editing_files(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -2792,6 +2916,12 @@ mod tests { workspace::init_settings(cx); client::init_settings(cx); Project::init_settings(cx); + + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + }); }); } diff --git a/crates/rich_text2/src/rich_text.rs b/crates/rich_text2/src/rich_text.rs index 48b530b7c578819c424f42f52459fa9498cb1603..4f64654bd3cdb759901afc20d20a805ad21a2d02 100644 --- a/crates/rich_text2/src/rich_text.rs +++ b/crates/rich_text2/src/rich_text.rs @@ -56,12 +56,12 @@ pub struct Mention { } impl RichText { - pub fn element( + pub fn element( &self, // syntax: Arc, // style: RichTextStyle, // cx: &mut ViewContext, - ) -> AnyElement { + ) -> AnyElement { todo!(); // let mut region_id = 0; diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 206777879b6882c64f72fb87f22937003b86835a..a6d27fa57d4a0a9a063f4f0a30b634207ef8ac63 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -884,6 +884,7 @@ message SearchProject { bool case_sensitive = 5; string files_to_include = 6; string files_to_exclude = 7; + bool include_ignored = 8; } message SearchProjectResponse { diff --git a/crates/rpc2/proto/zed.proto b/crates/rpc2/proto/zed.proto index 206777879b6882c64f72fb87f22937003b86835a..a6d27fa57d4a0a9a063f4f0a30b634207ef8ac63 100644 --- a/crates/rpc2/proto/zed.proto +++ b/crates/rpc2/proto/zed.proto @@ -884,6 +884,7 @@ message SearchProject { bool case_sensitive = 5; string files_to_include = 6; string files_to_exclude = 7; + bool include_ignored = 8; } message SearchProjectResponse { diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index ef8c56f2a7b4ed17305ae01a1cd638980079ea0a..29ffe7c021f23b4084d8b10dd9db3c688b8a6b24 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -805,6 +805,7 @@ impl BufferSearchBar { query, self.search_options.contains(SearchOptions::WHOLE_WORD), self.search_options.contains(SearchOptions::CASE_SENSITIVE), + false, Vec::new(), Vec::new(), ) { @@ -820,6 +821,7 @@ impl BufferSearchBar { query, self.search_options.contains(SearchOptions::WHOLE_WORD), self.search_options.contains(SearchOptions::CASE_SENSITIVE), + false, Vec::new(), Vec::new(), ) { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index f6e17bbee5d12685385ca64de790d5f8217bb92d..5f3a6db6d49cd089ef9b3bc1855888b462a674fd 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -4,7 +4,7 @@ use crate::{ search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button}, ActivateRegexMode, ActivateSemanticMode, ActivateTextMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectNextMatch, SelectPrevMatch, - ToggleCaseSensitive, ToggleReplace, ToggleWholeWord, + ToggleCaseSensitive, ToggleIncludeIgnored, ToggleReplace, ToggleWholeWord, }; use anyhow::{Context, Result}; use collections::HashMap; @@ -85,6 +85,7 @@ pub fn init(cx: &mut AppContext) { cx.capture_action(ProjectSearchView::replace_next); add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); + add_toggle_option_action::(SearchOptions::INCLUDE_IGNORED, cx); add_toggle_filters_action::(cx); } @@ -1192,6 +1193,7 @@ impl ProjectSearchView { text, self.search_options.contains(SearchOptions::WHOLE_WORD), self.search_options.contains(SearchOptions::CASE_SENSITIVE), + self.search_options.contains(SearchOptions::INCLUDE_IGNORED), included_files, excluded_files, ) { @@ -1210,6 +1212,7 @@ impl ProjectSearchView { text, self.search_options.contains(SearchOptions::WHOLE_WORD), self.search_options.contains(SearchOptions::CASE_SENSITIVE), + self.search_options.contains(SearchOptions::INCLUDE_IGNORED), included_files, excluded_files, ) { @@ -1764,6 +1767,17 @@ impl View for ProjectSearchBar { render_option_button_icon("icons/word_search.svg", SearchOptions::WHOLE_WORD, cx) }); + let mut include_ignored = is_semantic_disabled.then(|| { + render_option_button_icon( + // TODO proper icon + "icons/case_insensitive.svg", + SearchOptions::INCLUDE_IGNORED, + cx, + ) + }); + // TODO not implemented yet + let _ = include_ignored.take(); + let search_button_for_mode = |mode, side, cx: &mut ViewContext| { let is_active = if let Some(search) = self.active_project_search.as_ref() { let search = search.read(cx); @@ -1879,7 +1893,15 @@ impl View for ProjectSearchBar { .with_children(search.filters_enabled.then(|| { Flex::row() .with_child( - ChildView::new(&search.included_files_editor, cx) + Flex::row() + .with_child( + ChildView::new(&search.included_files_editor, cx) + .contained() + .constrained() + .with_height(theme.search.search_bar_row_height) + .flex(1., true), + ) + .with_children(include_ignored) .contained() .with_style(include_container_style) .constrained() diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index ba06b3f9c772a5cd97483636fefc0d0c2bc7c191..db39455dca3fa5c5b821eb3234595be00fc7ebfa 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -29,6 +29,7 @@ actions!( CycleMode, ToggleWholeWord, ToggleCaseSensitive, + ToggleIncludeIgnored, ToggleReplace, SelectNextMatch, SelectPrevMatch, @@ -49,31 +50,35 @@ bitflags! { const NONE = 0b000; const WHOLE_WORD = 0b001; const CASE_SENSITIVE = 0b010; + const INCLUDE_IGNORED = 0b100; } } impl SearchOptions { pub fn label(&self) -> &'static str { match *self { - SearchOptions::WHOLE_WORD => "Match Whole Word", - SearchOptions::CASE_SENSITIVE => "Match Case", - _ => panic!("{:?} is not a named SearchOption", self), + Self::WHOLE_WORD => "Match Whole Word", + Self::CASE_SENSITIVE => "Match Case", + Self::INCLUDE_IGNORED => "Include Ignored", + _ => panic!("{self:?} is not a named SearchOption"), } } pub fn icon(&self) -> &'static str { match *self { - SearchOptions::WHOLE_WORD => "icons/word_search.svg", - SearchOptions::CASE_SENSITIVE => "icons/case_insensitive.svg", - _ => panic!("{:?} is not a named SearchOption", self), + Self::WHOLE_WORD => "icons/word_search.svg", + Self::CASE_SENSITIVE => "icons/case_insensitive.svg", + Self::INCLUDE_IGNORED => "icons/case_insensitive.svg", + _ => panic!("{self:?} is not a named SearchOption"), } } pub fn to_toggle_action(&self) -> Box { match *self { - SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord), - SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive), - _ => panic!("{:?} is not a named SearchOption", self), + Self::WHOLE_WORD => Box::new(ToggleWholeWord), + Self::CASE_SENSITIVE => Box::new(ToggleCaseSensitive), + Self::INCLUDE_IGNORED => Box::new(ToggleIncludeIgnored), + _ => panic!("{self:?} is not a named SearchOption"), } } @@ -85,6 +90,7 @@ impl SearchOptions { let mut options = SearchOptions::NONE; options.set(SearchOptions::WHOLE_WORD, query.whole_word()); options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive()); + options.set(SearchOptions::INCLUDE_IGNORED, query.include_ignored()); options } diff --git a/crates/search2/Cargo.toml b/crates/search2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..97cfdd6494099eb802a2fd629df1e74edada4232 --- /dev/null +++ b/crates/search2/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "search2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/search.rs" +doctest = false + +[dependencies] +bitflags = "1" +collections = { path = "../collections" } +editor = { package = "editor2", path = "../editor2" } +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +menu = { package = "menu2", path = "../menu2" } +project = { package = "project2", path = "../project2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +util = { path = "../util" } +ui = {package = "ui2", path = "../ui2"} +workspace = { package = "workspace2", path = "../workspace2" } +#semantic_index = { path = "../semantic_index" } +anyhow.workspace = true +futures.workspace = true +log.workspace = true +postage.workspace = true +serde.workspace = true +serde_derive.workspace = true +smallvec.workspace = true +smol.workspace = true +serde_json.workspace = true +[dev-dependencies] +client = { package = "client2", path = "../client2", features = ["test-support"] } +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } + +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } +unindent.workspace = true diff --git a/crates/search2/src/buffer_search.rs b/crates/search2/src/buffer_search.rs new file mode 100644 index 0000000000000000000000000000000000000000..3674baf3569b64542bb3b7bb482a61fcef3f01bb --- /dev/null +++ b/crates/search2/src/buffer_search.rs @@ -0,0 +1,1704 @@ +use crate::{ + history::SearchHistory, + mode::{next_mode, SearchMode}, + search_bar::{render_nav_button, render_search_mode_button}, + ActivateRegexMode, ActivateTextMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, + ReplaceAll, ReplaceNext, SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, + ToggleCaseSensitive, ToggleReplace, ToggleWholeWord, +}; +use collections::HashMap; +use editor::Editor; +use futures::channel::oneshot; +use gpui::{ + actions, div, red, Action, AppContext, Div, EventEmitter, InteractiveElement as _, + ParentElement as _, Render, RenderOnce, Styled, Subscription, Task, View, ViewContext, + VisualContext as _, WindowContext, +}; +use project::search::SearchQuery; +use serde::Deserialize; +use std::{any::Any, sync::Arc}; + +use ui::{h_stack, ButtonGroup, Icon, IconButton, IconElement}; +use util::ResultExt; +use workspace::{ + item::ItemHandle, + searchable::{Direction, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle}, + ToolbarItemLocation, ToolbarItemView, Workspace, +}; + +#[derive(PartialEq, Clone, Deserialize, Default, Action)] +pub struct Deploy { + pub focus: bool, +} + +actions!(Dismiss, FocusEditor); + +pub enum Event { + UpdateLocation, +} + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(|workspace: &mut Workspace, _| BufferSearchBar::register(workspace)) + .detach(); +} + +pub struct BufferSearchBar { + query_editor: View, + replacement_editor: View, + active_searchable_item: Option>, + active_match_index: Option, + active_searchable_item_subscription: Option, + active_search: Option>, + searchable_items_with_matches: + HashMap, Vec>>, + pending_search: Option>, + search_options: SearchOptions, + default_options: SearchOptions, + query_contains_error: bool, + dismissed: bool, + search_history: SearchHistory, + current_mode: SearchMode, + replace_enabled: bool, +} + +impl EventEmitter for BufferSearchBar {} +impl EventEmitter for BufferSearchBar {} +impl Render for BufferSearchBar { + type Element = Div; + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + // let query_container_style = if self.query_contains_error { + // theme.search.invalid_editor + // } else { + // theme.search.editor.input.container + // }; + if self.dismissed { + return div(); + } + let supported_options = self.supported_options(); + + let previous_query_keystrokes = cx + .bindings_for_action(&PreviousHistoryQuery {}) + .into_iter() + .next() + .map(|binding| { + binding + .keystrokes() + .iter() + .map(|k| k.to_string()) + .collect::>() + }); + let next_query_keystrokes = cx + .bindings_for_action(&NextHistoryQuery {}) + .into_iter() + .next() + .map(|binding| { + binding + .keystrokes() + .iter() + .map(|k| k.to_string()) + .collect::>() + }); + let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) { + (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => { + format!( + "Search ({}/{} for previous/next query)", + previous_query_keystrokes.join(" "), + next_query_keystrokes.join(" ") + ) + } + (None, Some(next_query_keystrokes)) => { + format!( + "Search ({} for next query)", + next_query_keystrokes.join(" ") + ) + } + (Some(previous_query_keystrokes), None) => { + format!( + "Search ({} for previous query)", + previous_query_keystrokes.join(" ") + ) + } + (None, None) => String::new(), + }; + let new_placeholder_text = Arc::from(new_placeholder_text); + self.query_editor.update(cx, |editor, cx| { + editor.set_placeholder_text(new_placeholder_text, cx); + }); + self.replacement_editor.update(cx, |editor, cx| { + editor.set_placeholder_text("Replace with...", cx); + }); + + let search_button_for_mode = |mode| { + let is_active = self.current_mode == mode; + + render_search_mode_button( + mode, + is_active, + cx.listener(move |this, _, cx| { + this.activate_search_mode(mode, cx); + }), + ) + }; + let search_option_button = |option| { + let is_active = self.search_options.contains(option); + option.as_button(is_active) + }; + let match_count = self + .active_searchable_item + .as_ref() + .and_then(|searchable_item| { + if self.query(cx).is_empty() { + return None; + } + let matches = self + .searchable_items_with_matches + .get(&searchable_item.downgrade())?; + let message = if let Some(match_ix) = self.active_match_index { + format!("{}/{}", match_ix + 1, matches.len()) + } else { + "No matches".to_string() + }; + + Some(ui::Label::new(message)) + }); + let nav_button_for_direction = |icon, direction| { + render_nav_button( + icon, + self.active_match_index.is_some(), + cx.listener(move |this, _, cx| match direction { + Direction::Prev => this.select_prev_match(&Default::default(), cx), + Direction::Next => this.select_next_match(&Default::default(), cx), + }), + ) + }; + let should_show_replace_input = self.replace_enabled && supported_options.replacement; + let replace_all = should_show_replace_input + .then(|| super::render_replace_button(ReplaceAll, ui::Icon::ReplaceAll)); + let replace_next = should_show_replace_input + .then(|| super::render_replace_button(ReplaceNext, ui::Icon::Replace)); + let in_replace = self.replacement_editor.focus_handle(cx).is_focused(cx); + + h_stack() + .key_context("BufferSearchBar") + .when(in_replace, |this| { + this.key_context("in_replace") + .on_action(cx.listener(Self::replace_next)) + .on_action(cx.listener(Self::replace_all)) + }) + .on_action(cx.listener(Self::previous_history_query)) + .on_action(cx.listener(Self::next_history_query)) + .w_full() + .p_1() + .child( + div() + .flex() + .flex_1() + .border_1() + .border_color(red()) + .rounded_md() + .items_center() + .child(IconElement::new(Icon::MagnifyingGlass)) + .child(self.query_editor.clone()) + .children( + supported_options + .case + .then(|| search_option_button(SearchOptions::CASE_SENSITIVE)), + ) + .children( + supported_options + .word + .then(|| search_option_button(SearchOptions::WHOLE_WORD)), + ), + ) + .child( + h_stack() + .flex_none() + .child(ButtonGroup::new(vec![ + search_button_for_mode(SearchMode::Text), + search_button_for_mode(SearchMode::Regex), + ])) + .when(supported_options.replacement, |this| { + this.child(super::toggle_replace_button(self.replace_enabled)) + }), + ) + .child( + h_stack() + .gap_0p5() + .flex_1() + .when(self.replace_enabled, |this| { + this.child(self.replacement_editor.clone()) + .children(replace_next) + .children(replace_all) + }), + ) + .child( + h_stack() + .gap_0p5() + .flex_none() + .child(self.render_action_button()) + .children(match_count) + .child(nav_button_for_direction( + ui::Icon::ChevronLeft, + Direction::Prev, + )) + .child(nav_button_for_direction( + ui::Icon::ChevronRight, + Direction::Next, + )), + ) + } +} + +impl ToolbarItemView for BufferSearchBar { + fn set_active_pane_item( + &mut self, + item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) -> ToolbarItemLocation { + cx.notify(); + self.active_searchable_item_subscription.take(); + self.active_searchable_item.take(); + + self.pending_search.take(); + + if let Some(searchable_item_handle) = + item.and_then(|item| item.to_searchable_item_handle(cx)) + { + let this = cx.view().downgrade(); + + searchable_item_handle + .subscribe_to_search_events( + cx, + Box::new(move |search_event, cx| { + if let Some(this) = this.upgrade() { + this.update(cx, |this, cx| { + this.on_active_searchable_item_event(search_event, cx) + }); + } + }), + ) + .detach(); + + self.active_searchable_item = Some(searchable_item_handle); + let _ = self.update_matches(cx); + if !self.dismissed { + return ToolbarItemLocation::Secondary; + } + } + ToolbarItemLocation::Hidden + } + + fn row_count(&self, _: &WindowContext<'_>) -> usize { + 1 + } +} + +impl BufferSearchBar { + pub fn register(workspace: &mut Workspace) { + workspace.register_action(|workspace, a: &Deploy, cx| { + workspace.active_pane().update(cx, |this, cx| { + this.toolbar().update(cx, |this, cx| { + if let Some(search_bar) = this.item_of_type::() { + search_bar.update(cx, |this, cx| { + if this.is_dismissed() { + this.show(cx); + } else { + this.dismiss(&Dismiss, cx); + } + }); + return; + } + let view = cx.build_view(|cx| BufferSearchBar::new(cx)); + this.add_item(view.clone(), cx); + view.update(cx, |this, cx| this.deploy(a, cx)); + cx.notify(); + }) + }); + }); + fn register_action( + workspace: &mut Workspace, + update: fn(&mut BufferSearchBar, &A, &mut ViewContext), + ) { + workspace.register_action(move |workspace, action: &A, cx| { + workspace.active_pane().update(cx, move |this, cx| { + this.toolbar().update(cx, move |this, cx| { + if let Some(search_bar) = this.item_of_type::() { + search_bar.update(cx, move |this, cx| update(this, action, cx)); + cx.notify(); + } + }) + }); + }); + } + + register_action(workspace, |this, action: &ToggleCaseSensitive, cx| { + if this.supported_options().case { + this.toggle_case_sensitive(action, cx); + } + }); + register_action(workspace, |this, action: &ToggleWholeWord, cx| { + if this.supported_options().word { + this.toggle_whole_word(action, cx); + } + }); + register_action(workspace, |this, action: &ToggleReplace, cx| { + if this.supported_options().replacement { + this.toggle_replace(action, cx); + } + }); + register_action(workspace, |this, _: &ActivateRegexMode, cx| { + if this.supported_options().regex { + this.activate_search_mode(SearchMode::Regex, cx); + } + }); + register_action(workspace, |this, _: &ActivateTextMode, cx| { + this.activate_search_mode(SearchMode::Text, cx); + }); + register_action(workspace, |this, action: &CycleMode, cx| { + if this.supported_options().regex { + // If regex is not supported then search has just one mode (text) - in that case there's no point in supporting + // cycling. + this.cycle_mode(action, cx) + } + }); + register_action(workspace, |this, action: &SelectNextMatch, cx| { + this.select_next_match(action, cx); + }); + register_action(workspace, |this, action: &SelectPrevMatch, cx| { + this.select_prev_match(action, cx); + }); + register_action(workspace, |this, action: &SelectAllMatches, cx| { + this.select_all_matches(action, cx); + }); + register_action(workspace, |this, _: &editor::Cancel, cx| { + if !this.dismissed { + this.dismiss(&Dismiss, cx); + } + }); + } + pub fn new(cx: &mut ViewContext) -> Self { + let query_editor = cx.build_view(|cx| Editor::single_line(cx)); + cx.subscribe(&query_editor, Self::on_query_editor_event) + .detach(); + let replacement_editor = cx.build_view(|cx| Editor::single_line(cx)); + cx.subscribe(&replacement_editor, Self::on_query_editor_event) + .detach(); + Self { + query_editor, + replacement_editor, + active_searchable_item: None, + active_searchable_item_subscription: None, + active_match_index: None, + searchable_items_with_matches: Default::default(), + default_options: SearchOptions::NONE, + search_options: SearchOptions::NONE, + pending_search: None, + query_contains_error: false, + dismissed: true, + search_history: SearchHistory::default(), + current_mode: SearchMode::default(), + active_search: None, + replace_enabled: false, + } + } + + pub fn is_dismissed(&self) -> bool { + self.dismissed + } + + pub fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext) { + self.dismissed = true; + for searchable_item in self.searchable_items_with_matches.keys() { + if let Some(searchable_item) = + WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx) + { + searchable_item.clear_matches(cx); + } + } + if let Some(active_editor) = self.active_searchable_item.as_ref() { + let handle = active_editor.focus_handle(cx); + cx.focus(&handle); + } + cx.emit(Event::UpdateLocation); + cx.notify(); + } + + pub fn deploy(&mut self, deploy: &Deploy, cx: &mut ViewContext) -> bool { + if self.show(cx) { + self.search_suggested(cx); + if deploy.focus { + self.select_query(cx); + let handle = cx.focus_handle(); + cx.focus(&handle); + } + return true; + } + + false + } + + pub fn show(&mut self, cx: &mut ViewContext) -> bool { + if self.active_searchable_item.is_none() { + return false; + } + self.dismissed = false; + cx.notify(); + cx.emit(Event::UpdateLocation); + true + } + + fn supported_options(&self) -> workspace::searchable::SearchOptions { + self.active_searchable_item + .as_deref() + .map(SearchableItemHandle::supported_options) + .unwrap_or_default() + } + pub fn search_suggested(&mut self, cx: &mut ViewContext) { + let search = self + .query_suggestion(cx) + .map(|suggestion| self.search(&suggestion, Some(self.default_options), cx)); + + if let Some(search) = search { + cx.spawn(|this, mut cx| async move { + search.await?; + this.update(&mut cx, |this, cx| this.activate_current_match(cx)) + }) + .detach_and_log_err(cx); + } + } + + pub fn activate_current_match(&mut self, cx: &mut ViewContext) { + if let Some(match_ix) = self.active_match_index { + if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&active_searchable_item.downgrade()) + { + active_searchable_item.activate_match(match_ix, matches, cx) + } + } + } + } + + pub fn select_query(&mut self, cx: &mut ViewContext) { + self.query_editor.update(cx, |query_editor, cx| { + query_editor.select_all(&Default::default(), cx); + }); + } + + pub fn query(&self, cx: &WindowContext) -> String { + self.query_editor.read(cx).text(cx) + } + pub fn replacement(&self, cx: &WindowContext) -> String { + self.replacement_editor.read(cx).text(cx) + } + pub fn query_suggestion(&mut self, cx: &mut ViewContext) -> Option { + self.active_searchable_item + .as_ref() + .map(|searchable_item| searchable_item.query_suggestion(cx)) + .filter(|suggestion| !suggestion.is_empty()) + } + + pub fn set_replacement(&mut self, replacement: Option<&str>, cx: &mut ViewContext) { + if replacement.is_none() { + self.replace_enabled = false; + return; + } + self.replace_enabled = true; + self.replacement_editor + .update(cx, |replacement_editor, cx| { + replacement_editor + .buffer() + .update(cx, |replacement_buffer, cx| { + let len = replacement_buffer.len(cx); + replacement_buffer.edit([(0..len, replacement.unwrap())], None, cx); + }); + }); + } + + pub fn search( + &mut self, + query: &str, + options: Option, + cx: &mut ViewContext, + ) -> oneshot::Receiver<()> { + let options = options.unwrap_or(self.default_options); + if query != self.query(cx) || self.search_options != options { + self.query_editor.update(cx, |query_editor, cx| { + query_editor.buffer().update(cx, |query_buffer, cx| { + let len = query_buffer.len(cx); + query_buffer.edit([(0..len, query)], None, cx); + }); + }); + self.search_options = options; + self.query_contains_error = false; + self.clear_matches(cx); + cx.notify(); + } + self.update_matches(cx) + } + + fn render_action_button(&self) -> impl RenderOnce { + // let tooltip_style = theme.tooltip.clone(); + + // let style = theme.search.action_button.clone(); + + IconButton::new(0, ui::Icon::SelectAll) + .on_click(|_, cx| cx.dispatch_action(Box::new(SelectAllMatches))) + } + + pub fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext) { + assert_ne!( + mode, + SearchMode::Semantic, + "Semantic search is not supported in buffer search" + ); + if mode == self.current_mode { + return; + } + self.current_mode = mode; + let _ = self.update_matches(cx); + cx.notify(); + } + + pub fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { + if let Some(active_editor) = self.active_searchable_item.as_ref() { + let handle = active_editor.focus_handle(cx); + cx.focus(&handle); + } + } + + fn toggle_search_option(&mut self, search_option: SearchOptions, cx: &mut ViewContext) { + self.search_options.toggle(search_option); + self.default_options = self.search_options; + let _ = self.update_matches(cx); + cx.notify(); + } + + pub fn set_search_options( + &mut self, + search_options: SearchOptions, + cx: &mut ViewContext, + ) { + self.search_options = search_options; + cx.notify(); + } + + fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext) { + self.select_match(Direction::Next, 1, cx); + } + + fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext) { + self.select_match(Direction::Prev, 1, cx); + } + + fn select_all_matches(&mut self, _: &SelectAllMatches, cx: &mut ViewContext) { + if !self.dismissed && self.active_match_index.is_some() { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + searchable_item.select_matches(matches, cx); + self.focus_editor(&FocusEditor, cx); + } + } + } + } + + pub fn select_match(&mut self, direction: Direction, count: usize, cx: &mut ViewContext) { + if let Some(index) = self.active_match_index { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + let new_match_index = searchable_item + .match_index_for_direction(matches, index, direction, count, cx); + + searchable_item.update_matches(matches, cx); + searchable_item.activate_match(new_match_index, matches, cx); + } + } + } + } + + pub fn select_last_match(&mut self, cx: &mut ViewContext) { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if matches.len() == 0 { + return; + } + let new_match_index = matches.len() - 1; + searchable_item.update_matches(matches, cx); + searchable_item.activate_match(new_match_index, matches, cx); + } + } + } + + fn on_query_editor_event( + &mut self, + _: View, + event: &editor::EditorEvent, + cx: &mut ViewContext, + ) { + if let editor::EditorEvent::Edited { .. } = event { + self.query_contains_error = false; + self.clear_matches(cx); + let search = self.update_matches(cx); + cx.spawn(|this, mut cx| async move { + search.await?; + this.update(&mut cx, |this, cx| this.activate_current_match(cx)) + }) + .detach_and_log_err(cx); + } + } + + fn on_active_searchable_item_event(&mut self, event: &SearchEvent, cx: &mut ViewContext) { + match event { + SearchEvent::MatchesInvalidated => { + let _ = self.update_matches(cx); + } + SearchEvent::ActiveMatchChanged => self.update_match_index(cx), + } + } + + fn toggle_case_sensitive(&mut self, _: &ToggleCaseSensitive, cx: &mut ViewContext) { + self.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx) + } + fn toggle_whole_word(&mut self, _: &ToggleWholeWord, cx: &mut ViewContext) { + self.toggle_search_option(SearchOptions::WHOLE_WORD, cx) + } + fn clear_matches(&mut self, cx: &mut ViewContext) { + let mut active_item_matches = None; + for (searchable_item, matches) in self.searchable_items_with_matches.drain() { + if let Some(searchable_item) = + WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx) + { + if Some(&searchable_item) == self.active_searchable_item.as_ref() { + active_item_matches = Some((searchable_item.downgrade(), matches)); + } else { + searchable_item.clear_matches(cx); + } + } + } + + self.searchable_items_with_matches + .extend(active_item_matches); + } + + fn update_matches(&mut self, cx: &mut ViewContext) -> oneshot::Receiver<()> { + let (done_tx, done_rx) = oneshot::channel(); + let query = self.query(cx); + self.pending_search.take(); + + if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { + if query.is_empty() { + self.active_match_index.take(); + active_searchable_item.clear_matches(cx); + let _ = done_tx.send(()); + cx.notify(); + } else { + let query: Arc<_> = if self.current_mode == SearchMode::Regex { + match SearchQuery::regex( + query, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), + false, + Vec::new(), + Vec::new(), + ) { + Ok(query) => query.with_replacement(self.replacement(cx)), + Err(_) => { + self.query_contains_error = true; + cx.notify(); + return done_rx; + } + } + } else { + match SearchQuery::text( + query, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), + false, + Vec::new(), + Vec::new(), + ) { + Ok(query) => query.with_replacement(self.replacement(cx)), + Err(_) => { + self.query_contains_error = true; + cx.notify(); + return done_rx; + } + } + } + .into(); + self.active_search = Some(query.clone()); + let query_text = query.as_str().to_string(); + + let matches = active_searchable_item.find_matches(query, cx); + + let active_searchable_item = active_searchable_item.downgrade(); + self.pending_search = Some(cx.spawn(|this, mut cx| async move { + let matches = matches.await; + + this.update(&mut cx, |this, cx| { + if let Some(active_searchable_item) = + WeakSearchableItemHandle::upgrade(active_searchable_item.as_ref(), cx) + { + this.searchable_items_with_matches + .insert(active_searchable_item.downgrade(), matches); + + this.update_match_index(cx); + this.search_history.add(query_text); + if !this.dismissed { + let matches = this + .searchable_items_with_matches + .get(&active_searchable_item.downgrade()) + .unwrap(); + active_searchable_item.update_matches(matches, cx); + let _ = done_tx.send(()); + } + cx.notify(); + } + }) + .log_err(); + })); + } + } + done_rx + } + + fn update_match_index(&mut self, cx: &mut ViewContext) { + let new_index = self + .active_searchable_item + .as_ref() + .and_then(|searchable_item| { + let matches = self + .searchable_items_with_matches + .get(&searchable_item.downgrade())?; + searchable_item.active_match_index(matches, cx) + }); + if new_index != self.active_match_index { + self.active_match_index = new_index; + cx.notify(); + } + } + + fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext) { + if let Some(new_query) = self.search_history.next().map(str::to_string) { + let _ = self.search(&new_query, Some(self.search_options), cx); + } else { + self.search_history.reset_selection(); + let _ = self.search("", Some(self.search_options), cx); + } + } + + fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext) { + if self.query(cx).is_empty() { + if let Some(new_query) = self.search_history.current().map(str::to_string) { + let _ = self.search(&new_query, Some(self.search_options), cx); + return; + } + } + + if let Some(new_query) = self.search_history.previous().map(str::to_string) { + let _ = self.search(&new_query, Some(self.search_options), cx); + } + } + fn cycle_mode(&mut self, _: &CycleMode, cx: &mut ViewContext) { + self.activate_search_mode(next_mode(&self.current_mode, false), cx); + } + fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext) { + if let Some(_) = &self.active_searchable_item { + self.replace_enabled = !self.replace_enabled; + if !self.replace_enabled { + let handle = self.query_editor.focus_handle(cx); + cx.focus(&handle); + } + cx.notify(); + } + } + fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { + let mut should_propagate = true; + if !self.dismissed && self.active_search.is_some() { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some(query) = self.active_search.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if let Some(active_index) = self.active_match_index { + let query = query + .as_ref() + .clone() + .with_replacement(self.replacement(cx)); + searchable_item.replace(&matches[active_index], &query, cx); + self.select_next_match(&SelectNextMatch, cx); + } + should_propagate = false; + self.focus_editor(&FocusEditor, cx); + } + } + } + } + if !should_propagate { + cx.stop_propagation(); + } + } + pub fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext) { + if !self.dismissed && self.active_search.is_some() { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some(query) = self.active_search.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + let query = query + .as_ref() + .clone() + .with_replacement(self.replacement(cx)); + for m in matches { + searchable_item.replace(m, &query, cx); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::ops::Range; + + use super::*; + use editor::{DisplayPoint, Editor}; + use gpui::{Context, EmptyView, Hsla, TestAppContext, VisualTestContext}; + use language::Buffer; + use smol::stream::StreamExt as _; + use unindent::Unindent as _; + + fn init_globals(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + editor::init(cx); + + language::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + fn init_test( + cx: &mut TestAppContext, + ) -> ( + View, + View, + &mut VisualTestContext<'_>, + ) { + init_globals(cx); + let buffer = cx.build_model(|cx| { + Buffer::new( + 0, + cx.entity_id().as_u64(), + r#" + A regular expression (shortened as regex or regexp;[1] also referred to as + rational expression[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(), + ) + }); + let (_, cx) = cx.add_window_view(|_| EmptyView {}); + let editor = cx.build_view(|cx| Editor::for_buffer(buffer.clone(), None, cx)); + + let search_bar = cx.build_view(|cx| { + let mut search_bar = BufferSearchBar::new(cx); + search_bar.set_active_pane_item(Some(&editor), cx); + search_bar.show(cx); + search_bar + }); + + (editor, search_bar, cx) + } + + #[gpui::test] + async fn test_search_simple(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + // todo! osiewicz: these tests asserted on background color as well, that should be brought back. + let display_points_of = |background_highlights: Vec<(Range, Hsla)>| { + background_highlights + .into_iter() + .map(|(range, _)| range) + .collect::>() + }; + // Search for a string that appears with different casing. + // By default, search is case-insensitive. + search_bar + .update(cx, |search_bar, cx| search_bar.search("us", None, cx)) + .await + .unwrap(); + editor.update(cx, |editor, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(cx)), + &[ + DisplayPoint::new(2, 17)..DisplayPoint::new(2, 19), + DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45), + ] + ); + }); + + // Switch to a case sensitive search. + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx); + }); + let mut editor_notifications = cx.notifications(&editor); + editor_notifications.next().await; + editor.update(cx, |editor, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(cx)), + &[DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),] + ); + }); + + // Search for a string that appears both as a whole word and + // within other words. By default, all results are found. + search_bar + .update(cx, |search_bar, cx| search_bar.search("or", None, cx)) + .await + .unwrap(); + editor.update(cx, |editor, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(cx)), + &[ + DisplayPoint::new(0, 24)..DisplayPoint::new(0, 26), + DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43), + DisplayPoint::new(2, 71)..DisplayPoint::new(2, 73), + DisplayPoint::new(3, 1)..DisplayPoint::new(3, 3), + DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13), + DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58), + DisplayPoint::new(3, 60)..DisplayPoint::new(3, 62), + ] + ); + }); + + // Switch to a whole word search. + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx); + }); + let mut editor_notifications = cx.notifications(&editor); + editor_notifications.next().await; + editor.update(cx, |editor, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(cx)), + &[ + DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43), + DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13), + DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58), + ] + ); + }); + + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]) + }); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(0)); + search_bar.select_next_match(&SelectNextMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_next_match(&SelectNextMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(1)); + }); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_next_match(&SelectNextMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_next_match(&SelectNextMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_prev_match(&SelectPrevMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_prev_match(&SelectPrevMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(1)); + }); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_prev_match(&SelectPrevMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + // Park the cursor in between matches and ensure that going to the previous match selects + // the closest match to the left. + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) + }); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(1)); + search_bar.select_prev_match(&SelectPrevMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + // Park the cursor in between matches and ensure that going to the next match selects the + // closest match to the right. + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) + }); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(1)); + search_bar.select_next_match(&SelectNextMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(3, 11)..DisplayPoint::new(3, 13)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(1)); + }); + + // Park the cursor after the last match and ensure that going to the previous match selects + // the last match. + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(3, 60)..DisplayPoint::new(3, 60)]) + }); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(2)); + search_bar.select_prev_match(&SelectPrevMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + + // Park the cursor after the last match and ensure that going to the next match selects the + // first match. + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(3, 60)..DisplayPoint::new(3, 60)]) + }); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(2)); + search_bar.select_next_match(&SelectNextMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(0, 41)..DisplayPoint::new(0, 43)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(0)); + }); + + // Park the cursor before the first match and ensure that going to the previous match + // selects the last match. + editor.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]) + }); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(0)); + search_bar.select_prev_match(&SelectPrevMatch, cx); + assert_eq!( + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(3, 56)..DisplayPoint::new(3, 58)] + ); + }); + search_bar.update(cx, |search_bar, _| { + assert_eq!(search_bar.active_match_index, Some(2)); + }); + } + + #[gpui::test] + async fn test_search_option_handling(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + // show with options should make current search case sensitive + search_bar + .update(cx, |search_bar, cx| { + search_bar.show(cx); + search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), cx) + }) + .await + .unwrap(); + // todo! osiewicz: these tests previously asserted on background color highlights; that should be introduced back. + let display_points_of = |background_highlights: Vec<(Range, Hsla)>| { + background_highlights + .into_iter() + .map(|(range, _)| range) + .collect::>() + }; + editor.update(cx, |editor, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(cx)), + &[DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),] + ); + }); + + // search_suggested should restore default options + search_bar.update(cx, |search_bar, cx| { + search_bar.search_suggested(cx); + assert_eq!(search_bar.search_options, SearchOptions::NONE) + }); + + // toggling a search option should update the defaults + search_bar + .update(cx, |search_bar, cx| { + search_bar.search("regex", Some(SearchOptions::CASE_SENSITIVE), cx) + }) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx) + }); + let mut editor_notifications = cx.notifications(&editor); + editor_notifications.next().await; + editor.update(cx, |editor, cx| { + assert_eq!( + display_points_of(editor.all_text_background_highlights(cx)), + &[DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),] + ); + }); + + // defaults should still include whole word + search_bar.update(cx, |search_bar, cx| { + search_bar.search_suggested(cx); + assert_eq!( + search_bar.search_options, + SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD + ) + }); + } + + #[gpui::test] + async fn test_search_select_all_matches(cx: &mut TestAppContext) { + init_globals(cx); + let buffer_text = r#" + A regular expression (shortened as regex or regexp;[1] also referred to as + rational expression[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(); + let expected_query_matches_count = buffer_text + .chars() + .filter(|c| c.to_ascii_lowercase() == 'a') + .count(); + assert!( + expected_query_matches_count > 1, + "Should pick a query with multiple results" + ); + let buffer = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), buffer_text)); + let window = cx.add_window(|_| EmptyView {}); + + let editor = window.build_view(cx, |cx| Editor::for_buffer(buffer.clone(), None, cx)); + + let search_bar = window.build_view(cx, |cx| { + let mut search_bar = BufferSearchBar::new(cx); + search_bar.set_active_pane_item(Some(&editor), cx); + search_bar.show(cx); + search_bar + }); + + window + .update(cx, |_, cx| { + search_bar.update(cx, |search_bar, cx| search_bar.search("a", None, cx)) + }) + .unwrap() + .await + .unwrap(); + let initial_selections = window + .update(cx, |_, cx| { + search_bar.update(cx, |search_bar, cx| { + let handle = search_bar.query_editor.focus_handle(cx); + cx.focus(&handle); + search_bar.activate_current_match(cx); + }); + assert!( + !editor.read(cx).is_focused(cx), + "Initially, the editor should not be focused" + ); + let initial_selections = editor.update(cx, |editor, cx| { + let initial_selections = editor.selections.display_ranges(cx); + assert_eq!( + initial_selections.len(), 1, + "Expected to have only one selection before adding carets to all matches, but got: {initial_selections:?}", + ); + initial_selections + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.active_match_index, Some(0)); + let handle = search_bar.query_editor.focus_handle(cx); + cx.focus(&handle); + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + assert!( + editor.read(cx).is_focused(cx), + "Should focus editor after successful SelectAllMatches" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + expected_query_matches_count, + "Should select all `a` characters in the buffer, but got: {all_selections:?}" + ); + assert_eq!( + search_bar.active_match_index, + Some(0), + "Match index should not change after selecting all matches" + ); + }); + + search_bar.update(cx, |this, cx| this.select_next_match(&SelectNextMatch, cx)); + initial_selections + }).unwrap(); + + window + .update(cx, |_, cx| { + assert!( + editor.read(cx).is_focused(cx), + "Should still have editor focused after SelectNextMatch" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + 1, + "On next match, should deselect items and select the next match" + ); + assert_ne!( + all_selections, initial_selections, + "Next match should be different from the first selection" + ); + assert_eq!( + search_bar.active_match_index, + Some(1), + "Match index should be updated to the next one" + ); + let handle = search_bar.query_editor.focus_handle(cx); + cx.focus(&handle); + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + }) + .unwrap(); + window + .update(cx, |_, cx| { + assert!( + editor.read(cx).is_focused(cx), + "Should focus editor after successful SelectAllMatches" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + expected_query_matches_count, + "Should select all `a` characters in the buffer, but got: {all_selections:?}" + ); + assert_eq!( + search_bar.active_match_index, + Some(1), + "Match index should not change after selecting all matches" + ); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.select_prev_match(&SelectPrevMatch, cx); + }); + }) + .unwrap(); + let last_match_selections = window + .update(cx, |_, cx| { + assert!( + editor.read(cx).is_focused(&cx), + "Should still have editor focused after SelectPrevMatch" + ); + + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + 1, + "On previous match, should deselect items and select the previous item" + ); + assert_eq!( + all_selections, initial_selections, + "Previous match should be the same as the first selection" + ); + assert_eq!( + search_bar.active_match_index, + Some(0), + "Match index should be updated to the previous one" + ); + all_selections + }) + }) + .unwrap(); + + window + .update(cx, |_, cx| { + search_bar.update(cx, |search_bar, cx| { + let handle = search_bar.query_editor.focus_handle(cx); + cx.focus(&handle); + search_bar.search("abas_nonexistent_match", None, cx) + }) + }) + .unwrap() + .await + .unwrap(); + window + .update(cx, |_, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + assert!( + editor.update(cx, |this, cx| !this.is_focused(cx.window_context())), + "Should not switch focus to editor if SelectAllMatches does not find any matches" + ); + search_bar.update(cx, |search_bar, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections, last_match_selections, + "Should not select anything new if there are no matches" + ); + assert!( + search_bar.active_match_index.is_none(), + "For no matches, there should be no active match index" + ); + }); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_search_query_history(cx: &mut TestAppContext) { + //crate::project_search::tests::init_test(cx); + init_globals(cx); + let buffer_text = r#" + A regular expression (shortened as regex or regexp;[1] also referred to as + rational expression[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent(); + let buffer = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), buffer_text)); + let (_, cx) = cx.add_window_view(|_| EmptyView {}); + + let editor = cx.build_view(|cx| Editor::for_buffer(buffer.clone(), None, cx)); + + let search_bar = cx.build_view(|cx| { + let mut search_bar = BufferSearchBar::new(cx); + search_bar.set_active_pane_item(Some(&editor), cx); + search_bar.show(cx); + search_bar + }); + + // Add 3 search items into the history. + search_bar + .update(cx, |search_bar, cx| search_bar.search("a", None, cx)) + .await + .unwrap(); + search_bar + .update(cx, |search_bar, cx| search_bar.search("b", None, cx)) + .await + .unwrap(); + search_bar + .update(cx, |search_bar, cx| { + search_bar.search("c", Some(SearchOptions::CASE_SENSITIVE), cx) + }) + .await + .unwrap(); + // Ensure that the latest search is active. + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Next history query after the latest should set the query to the empty string. + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), ""); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), ""); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // First previous query for empty current query should set the query to the latest. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Further previous items should go over the history in reverse order. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "b"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Previous items should never go behind the first history item. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "a"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "a"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Next items should go over the history in the original order. + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "b"); + assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE); + }); + + search_bar + .update(cx, |search_bar, cx| search_bar.search("ba", None, cx)) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "ba"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + + // New search input should add another entry to history and move the selection to the end of the history. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "b"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "c"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), "ba"); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_bar.update(cx, |search_bar, cx| { + assert_eq!(search_bar.query(cx), ""); + assert_eq!(search_bar.search_options, SearchOptions::NONE); + }); + } + #[gpui::test] + async fn test_replace_simple(cx: &mut TestAppContext) { + let (editor, search_bar, cx) = init_test(cx); + + search_bar + .update(cx, |search_bar, cx| { + search_bar.search("expression", None, cx) + }) + .await + .unwrap(); + + search_bar.update(cx, |search_bar, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + // We use $1 here as initially we should be in Text mode, where `$1` should be treated literally. + editor.set_text("expr$1", cx); + }); + search_bar.replace_all(&ReplaceAll, cx) + }); + assert_eq!( + editor.update(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex or regexp;[1] also referred to as + rational expr$1[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + + // Search for word boundaries and replace just a single one. + search_bar + .update(cx, |search_bar, cx| { + search_bar.search("or", Some(SearchOptions::WHOLE_WORD), cx) + }) + .await + .unwrap(); + + search_bar.update(cx, |search_bar, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("banana", cx); + }); + search_bar.replace_next(&ReplaceNext, cx) + }); + // Notice how the first or in the text (shORtened) is not replaced. Neither are the remaining hits of `or` in the text. + assert_eq!( + editor.update(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex banana regexp;[1] also referred to as + rational expr$1[2][3]) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + // Let's turn on regex mode. + search_bar + .update(cx, |search_bar, cx| { + search_bar.activate_search_mode(SearchMode::Regex, cx); + search_bar.search("\\[([^\\]]+)\\]", None, cx) + }) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("${1}number", cx); + }); + search_bar.replace_all(&ReplaceAll, cx) + }); + assert_eq!( + editor.update(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex banana regexp;1number also referred to as + rational expr$12number3number) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching algorithms + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + // Now with a whole-word twist. + search_bar + .update(cx, |search_bar, cx| { + search_bar.activate_search_mode(SearchMode::Regex, cx); + search_bar.search("a\\w+s", Some(SearchOptions::WHOLE_WORD), cx) + }) + .await + .unwrap(); + search_bar.update(cx, |search_bar, cx| { + search_bar.replacement_editor.update(cx, |editor, cx| { + editor.set_text("things", cx); + }); + search_bar.replace_all(&ReplaceAll, cx) + }); + // The only word affected by this edit should be `algorithms`, even though there's a bunch + // of words in this text that would match this regex if not for WHOLE_WORD. + assert_eq!( + editor.update(cx, |this, cx| { this.text(cx) }), + r#" + A regular expr$1 (shortened as regex banana regexp;1number also referred to as + rational expr$12number3number) is a sequence of characters that specifies a search + pattern in text. Usually such patterns are used by string-searching things + for "find" or "find and replace" operations on strings, or for input validation. + "# + .unindent() + ); + } +} diff --git a/crates/search2/src/history.rs b/crates/search2/src/history.rs new file mode 100644 index 0000000000000000000000000000000000000000..6b06c60293d4389693b9d3692a2649856076081f --- /dev/null +++ b/crates/search2/src/history.rs @@ -0,0 +1,184 @@ +use smallvec::SmallVec; +const SEARCH_HISTORY_LIMIT: usize = 20; + +#[derive(Default, Debug, Clone)] +pub struct SearchHistory { + history: SmallVec<[String; SEARCH_HISTORY_LIMIT]>, + selected: Option, +} + +impl SearchHistory { + pub fn add(&mut self, search_string: String) { + if let Some(i) = self.selected { + if search_string == self.history[i] { + return; + } + } + + if let Some(previously_searched) = self.history.last_mut() { + if search_string.find(previously_searched.as_str()).is_some() { + *previously_searched = search_string; + self.selected = Some(self.history.len() - 1); + return; + } + } + + self.history.push(search_string); + if self.history.len() > SEARCH_HISTORY_LIMIT { + self.history.remove(0); + } + self.selected = Some(self.history.len() - 1); + } + + pub fn next(&mut self) -> Option<&str> { + let history_size = self.history.len(); + if history_size == 0 { + return None; + } + + let selected = self.selected?; + if selected == history_size - 1 { + return None; + } + let next_index = selected + 1; + self.selected = Some(next_index); + Some(&self.history[next_index]) + } + + pub fn current(&self) -> Option<&str> { + Some(&self.history[self.selected?]) + } + + pub fn previous(&mut self) -> Option<&str> { + let history_size = self.history.len(); + if history_size == 0 { + return None; + } + + let prev_index = match self.selected { + Some(selected_index) => { + if selected_index == 0 { + return None; + } else { + selected_index - 1 + } + } + None => history_size - 1, + }; + + self.selected = Some(prev_index); + Some(&self.history[prev_index]) + } + + pub fn reset_selection(&mut self) { + self.selected = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add() { + let mut search_history = SearchHistory::default(); + assert_eq!( + search_history.current(), + None, + "No current selection should be set fo the default search history" + ); + + search_history.add("rust".to_string()); + assert_eq!( + search_history.current(), + Some("rust"), + "Newly added item should be selected" + ); + + // check if duplicates are not added + search_history.add("rust".to_string()); + assert_eq!( + search_history.history.len(), + 1, + "Should not add a duplicate" + ); + assert_eq!(search_history.current(), Some("rust")); + + // check if new string containing the previous string replaces it + search_history.add("rustlang".to_string()); + assert_eq!( + search_history.history.len(), + 1, + "Should replace previous item if it's a substring" + ); + assert_eq!(search_history.current(), Some("rustlang")); + + // push enough items to test SEARCH_HISTORY_LIMIT + for i in 0..SEARCH_HISTORY_LIMIT * 2 { + search_history.add(format!("item{i}")); + } + assert!(search_history.history.len() <= SEARCH_HISTORY_LIMIT); + } + + #[test] + fn test_next_and_previous() { + let mut search_history = SearchHistory::default(); + assert_eq!( + search_history.next(), + None, + "Default search history should not have a next item" + ); + + search_history.add("Rust".to_string()); + assert_eq!(search_history.next(), None); + search_history.add("JavaScript".to_string()); + assert_eq!(search_history.next(), None); + search_history.add("TypeScript".to_string()); + assert_eq!(search_history.next(), None); + + assert_eq!(search_history.current(), Some("TypeScript")); + + assert_eq!(search_history.previous(), Some("JavaScript")); + assert_eq!(search_history.current(), Some("JavaScript")); + + assert_eq!(search_history.previous(), Some("Rust")); + assert_eq!(search_history.current(), Some("Rust")); + + assert_eq!(search_history.previous(), None); + assert_eq!(search_history.current(), Some("Rust")); + + assert_eq!(search_history.next(), Some("JavaScript")); + assert_eq!(search_history.current(), Some("JavaScript")); + + assert_eq!(search_history.next(), Some("TypeScript")); + assert_eq!(search_history.current(), Some("TypeScript")); + + assert_eq!(search_history.next(), None); + assert_eq!(search_history.current(), Some("TypeScript")); + } + + #[test] + fn test_reset_selection() { + let mut search_history = SearchHistory::default(); + search_history.add("Rust".to_string()); + search_history.add("JavaScript".to_string()); + search_history.add("TypeScript".to_string()); + + assert_eq!(search_history.current(), Some("TypeScript")); + search_history.reset_selection(); + assert_eq!(search_history.current(), None); + assert_eq!( + search_history.previous(), + Some("TypeScript"), + "Should start from the end after reset on previous item query" + ); + + search_history.previous(); + assert_eq!(search_history.current(), Some("JavaScript")); + search_history.previous(); + assert_eq!(search_history.current(), Some("Rust")); + + search_history.reset_selection(); + assert_eq!(search_history.current(), None); + } +} diff --git a/crates/search2/src/mode.rs b/crates/search2/src/mode.rs new file mode 100644 index 0000000000000000000000000000000000000000..817fb454d2dcb08953d012fbb9814874c786cb78 --- /dev/null +++ b/crates/search2/src/mode.rs @@ -0,0 +1,32 @@ +// TODO: Update the default search mode to get from config +#[derive(Copy, Clone, Debug, Default, PartialEq)] +pub enum SearchMode { + #[default] + Text, + Semantic, + Regex, +} + +impl SearchMode { + pub(crate) fn label(&self) -> &'static str { + match self { + SearchMode::Text => "Text", + SearchMode::Semantic => "Semantic", + SearchMode::Regex => "Regex", + } + } +} + +pub(crate) fn next_mode(mode: &SearchMode, semantic_enabled: bool) -> SearchMode { + match mode { + SearchMode::Text => SearchMode::Regex, + SearchMode::Regex => { + if semantic_enabled { + SearchMode::Semantic + } else { + SearchMode::Text + } + } + SearchMode::Semantic => SearchMode::Text, + } +} diff --git a/crates/search2/src/project_search.rs b/crates/search2/src/project_search.rs new file mode 100644 index 0000000000000000000000000000000000000000..f6e17bbee5d12685385ca64de790d5f8217bb92d --- /dev/null +++ b/crates/search2/src/project_search.rs @@ -0,0 +1,2661 @@ +use crate::{ + history::SearchHistory, + mode::{SearchMode, Side}, + search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button}, + ActivateRegexMode, ActivateSemanticMode, ActivateTextMode, CycleMode, NextHistoryQuery, + PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectNextMatch, SelectPrevMatch, + ToggleCaseSensitive, ToggleReplace, ToggleWholeWord, +}; +use anyhow::{Context, Result}; +use collections::HashMap; +use editor::{ + items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer, + SelectAll, MAX_TAB_TITLE_LEN, +}; +use futures::StreamExt; +use gpui::{ + actions, + elements::*, + platform::{MouseButton, PromptLevel}, + Action, AnyElement, AnyViewHandle, AppContext, Entity, ModelContext, ModelHandle, Subscription, + Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle, +}; +use menu::Confirm; +use project::{ + search::{SearchInputs, SearchQuery}, + Entry, Project, +}; +use semantic_index::{SemanticIndex, SemanticIndexStatus}; +use smallvec::SmallVec; +use std::{ + any::{Any, TypeId}, + borrow::Cow, + collections::HashSet, + mem, + ops::{Not, Range}, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; +use util::{paths::PathMatcher, ResultExt as _}; +use workspace::{ + item::{BreadcrumbText, Item, ItemEvent, ItemHandle}, + searchable::{Direction, SearchableItem, SearchableItemHandle}, + ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, +}; + +actions!( + project_search, + [SearchInNew, ToggleFocus, NextField, ToggleFilters,] +); + +#[derive(Default)] +struct ActiveSearches(HashMap, WeakViewHandle>); + +#[derive(Default)] +struct ActiveSettings(HashMap, ProjectSearchSettings>); + +pub fn init(cx: &mut AppContext) { + cx.set_global(ActiveSearches::default()); + cx.set_global(ActiveSettings::default()); + cx.add_action(ProjectSearchView::deploy); + cx.add_action(ProjectSearchView::move_focus_to_results); + cx.add_action(ProjectSearchBar::confirm); + cx.add_action(ProjectSearchBar::search_in_new); + cx.add_action(ProjectSearchBar::select_next_match); + cx.add_action(ProjectSearchBar::select_prev_match); + cx.add_action(ProjectSearchBar::replace_next); + cx.add_action(ProjectSearchBar::replace_all); + cx.add_action(ProjectSearchBar::cycle_mode); + cx.add_action(ProjectSearchBar::next_history_query); + cx.add_action(ProjectSearchBar::previous_history_query); + cx.add_action(ProjectSearchBar::activate_regex_mode); + cx.add_action(ProjectSearchBar::toggle_replace); + cx.add_action(ProjectSearchBar::toggle_replace_on_a_pane); + cx.add_action(ProjectSearchBar::activate_text_mode); + + // This action should only be registered if the semantic index is enabled + // We are registering it all the time, as I dont want to introduce a dependency + // for Semantic Index Settings globally whenever search is tested. + cx.add_action(ProjectSearchBar::activate_semantic_mode); + + cx.capture_action(ProjectSearchBar::tab); + cx.capture_action(ProjectSearchBar::tab_previous); + cx.capture_action(ProjectSearchView::replace_all); + cx.capture_action(ProjectSearchView::replace_next); + add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); + add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); + add_toggle_filters_action::(cx); +} + +fn add_toggle_filters_action(cx: &mut AppContext) { + cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + if search_bar.update(cx, |search_bar, cx| search_bar.toggle_filters(cx)) { + return; + } + } + cx.propagate_action(); + }); +} + +fn add_toggle_option_action(option: SearchOptions, cx: &mut AppContext) { + cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext| { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + if search_bar.update(cx, |search_bar, cx| { + search_bar.toggle_search_option(option, cx) + }) { + return; + } + } + cx.propagate_action(); + }); +} + +struct ProjectSearch { + project: ModelHandle, + excerpts: ModelHandle, + pending_search: Option>>, + match_ranges: Vec>, + active_query: Option, + search_id: usize, + search_history: SearchHistory, + no_results: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum InputPanel { + Query, + Exclude, + Include, +} + +pub struct ProjectSearchView { + model: ModelHandle, + query_editor: ViewHandle, + replacement_editor: ViewHandle, + results_editor: ViewHandle, + semantic_state: Option, + semantic_permissioned: Option, + search_options: SearchOptions, + panels_with_errors: HashSet, + active_match_index: Option, + search_id: usize, + query_editor_was_focused: bool, + included_files_editor: ViewHandle, + excluded_files_editor: ViewHandle, + filters_enabled: bool, + replace_enabled: bool, + current_mode: SearchMode, +} + +struct SemanticState { + index_status: SemanticIndexStatus, + maintain_rate_limit: Option>, + _subscription: Subscription, +} + +#[derive(Debug, Clone)] +struct ProjectSearchSettings { + search_options: SearchOptions, + filters_enabled: bool, + current_mode: SearchMode, +} + +pub struct ProjectSearchBar { + active_project_search: Option>, + subscription: Option, +} + +impl Entity for ProjectSearch { + type Event = (); +} + +impl ProjectSearch { + fn new(project: ModelHandle, cx: &mut ModelContext) -> Self { + let replica_id = project.read(cx).replica_id(); + Self { + project, + excerpts: cx.add_model(|_| MultiBuffer::new(replica_id)), + pending_search: Default::default(), + match_ranges: Default::default(), + active_query: None, + search_id: 0, + search_history: SearchHistory::default(), + no_results: None, + } + } + + fn clone(&self, cx: &mut ModelContext) -> ModelHandle { + cx.add_model(|cx| Self { + project: self.project.clone(), + excerpts: self + .excerpts + .update(cx, |excerpts, cx| cx.add_model(|cx| excerpts.clone(cx))), + pending_search: Default::default(), + match_ranges: self.match_ranges.clone(), + active_query: self.active_query.clone(), + search_id: self.search_id, + search_history: self.search_history.clone(), + no_results: self.no_results.clone(), + }) + } + + fn search(&mut self, query: SearchQuery, cx: &mut ModelContext) { + let search = self + .project + .update(cx, |project, cx| project.search(query.clone(), cx)); + self.search_id += 1; + self.search_history.add(query.as_str().to_string()); + self.active_query = Some(query); + self.match_ranges.clear(); + self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move { + let mut matches = search; + let this = this.upgrade(&cx)?; + this.update(&mut cx, |this, cx| { + this.match_ranges.clear(); + this.excerpts.update(cx, |this, cx| this.clear(cx)); + this.no_results = Some(true); + }); + + while let Some((buffer, anchors)) = matches.next().await { + let mut ranges = this.update(&mut cx, |this, cx| { + this.no_results = Some(false); + this.excerpts.update(cx, |excerpts, cx| { + excerpts.stream_excerpts_with_context_lines(buffer, anchors, 1, cx) + }) + }); + + while let Some(range) = ranges.next().await { + this.update(&mut cx, |this, _| this.match_ranges.push(range)); + } + this.update(&mut cx, |_, cx| cx.notify()); + } + + this.update(&mut cx, |this, cx| { + this.pending_search.take(); + cx.notify(); + }); + + None + })); + cx.notify(); + } + + fn semantic_search(&mut self, inputs: &SearchInputs, cx: &mut ModelContext) { + let search = SemanticIndex::global(cx).map(|index| { + index.update(cx, |semantic_index, cx| { + semantic_index.search_project( + self.project.clone(), + inputs.as_str().to_owned(), + 10, + inputs.files_to_include().to_vec(), + inputs.files_to_exclude().to_vec(), + cx, + ) + }) + }); + self.search_id += 1; + self.match_ranges.clear(); + self.search_history.add(inputs.as_str().to_string()); + self.no_results = None; + self.pending_search = Some(cx.spawn(|this, mut cx| async move { + let results = search?.await.log_err()?; + let matches = results + .into_iter() + .map(|result| (result.buffer, vec![result.range.start..result.range.start])); + + this.update(&mut cx, |this, cx| { + this.no_results = Some(true); + this.excerpts.update(cx, |excerpts, cx| { + excerpts.clear(cx); + }); + }); + for (buffer, ranges) in matches { + let mut match_ranges = this.update(&mut cx, |this, cx| { + this.no_results = Some(false); + this.excerpts.update(cx, |excerpts, cx| { + excerpts.stream_excerpts_with_context_lines(buffer, ranges, 3, cx) + }) + }); + while let Some(match_range) = match_ranges.next().await { + this.update(&mut cx, |this, cx| { + this.match_ranges.push(match_range); + while let Ok(Some(match_range)) = match_ranges.try_next() { + this.match_ranges.push(match_range); + } + cx.notify(); + }); + } + } + + this.update(&mut cx, |this, cx| { + this.pending_search.take(); + cx.notify(); + }); + + None + })); + cx.notify(); + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ViewEvent { + UpdateTab, + Activate, + EditorEvent(editor::Event), + Dismiss, +} + +impl Entity for ProjectSearchView { + type Event = ViewEvent; +} + +impl View for ProjectSearchView { + fn ui_name() -> &'static str { + "ProjectSearchView" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let model = &self.model.read(cx); + if model.match_ranges.is_empty() { + enum Status {} + + let theme = theme::current(cx).clone(); + + // If Search is Active -> Major: Searching..., Minor: None + // If Semantic -> Major: "Search using Natural Language", Minor: {Status}/n{ex...}/n{ex...} + // If Regex -> Major: "Search using Regex", Minor: {ex...} + // If Text -> Major: "Text search all files and folders", Minor: {...} + + let current_mode = self.current_mode; + let mut major_text = if model.pending_search.is_some() { + Cow::Borrowed("Searching...") + } else if model.no_results.is_some_and(|v| v) { + Cow::Borrowed("No Results") + } else { + match current_mode { + SearchMode::Text => Cow::Borrowed("Text search all files and folders"), + SearchMode::Semantic => { + Cow::Borrowed("Search all code objects using Natural Language") + } + SearchMode::Regex => Cow::Borrowed("Regex search all files and folders"), + } + }; + + let mut show_minor_text = true; + let semantic_status = self.semantic_state.as_ref().and_then(|semantic| { + let status = semantic.index_status; + match status { + SemanticIndexStatus::NotAuthenticated => { + major_text = Cow::Borrowed("Not Authenticated"); + show_minor_text = false; + Some(vec![ + "API Key Missing: Please set 'OPENAI_API_KEY' in Environment Variables." + .to_string(), "If you authenticated using the Assistant Panel, please restart Zed to Authenticate.".to_string()]) + } + SemanticIndexStatus::Indexed => Some(vec!["Indexing complete".to_string()]), + SemanticIndexStatus::Indexing { + remaining_files, + rate_limit_expiry, + } => { + if remaining_files == 0 { + Some(vec![format!("Indexing...")]) + } else { + if let Some(rate_limit_expiry) = rate_limit_expiry { + let remaining_seconds = + rate_limit_expiry.duration_since(Instant::now()); + if remaining_seconds > Duration::from_secs(0) { + Some(vec![format!( + "Remaining files to index (rate limit resets in {}s): {}", + remaining_seconds.as_secs(), + remaining_files + )]) + } else { + Some(vec![format!("Remaining files to index: {}", remaining_files)]) + } + } else { + Some(vec![format!("Remaining files to index: {}", remaining_files)]) + } + } + } + SemanticIndexStatus::NotIndexed => None, + } + }); + + let minor_text = if let Some(no_results) = model.no_results { + if model.pending_search.is_none() && no_results { + vec!["No results found in this project for the provided query".to_owned()] + } else { + vec![] + } + } else { + match current_mode { + SearchMode::Semantic => { + let mut minor_text: Vec = Vec::new(); + minor_text.push("".into()); + if let Some(semantic_status) = semantic_status { + minor_text.extend(semantic_status); + } + if show_minor_text { + minor_text + .push("Simply explain the code you are looking to find.".into()); + minor_text.push( + "ex. 'prompt user for permissions to index their project'".into(), + ); + } + minor_text + } + _ => vec![ + "".to_owned(), + "Include/exclude specific paths with the filter option.".to_owned(), + "Matching exact word and/or casing is available too.".to_owned(), + ], + } + }; + + let previous_query_keystrokes = + cx.binding_for_action(&PreviousHistoryQuery {}) + .map(|binding| { + binding + .keystrokes() + .iter() + .map(|k| k.to_string()) + .collect::>() + }); + let next_query_keystrokes = + cx.binding_for_action(&NextHistoryQuery {}).map(|binding| { + binding + .keystrokes() + .iter() + .map(|k| k.to_string()) + .collect::>() + }); + let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) { + (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => { + format!( + "Search ({}/{} for previous/next query)", + previous_query_keystrokes.join(" "), + next_query_keystrokes.join(" ") + ) + } + (None, Some(next_query_keystrokes)) => { + format!( + "Search ({} for next query)", + next_query_keystrokes.join(" ") + ) + } + (Some(previous_query_keystrokes), None) => { + format!( + "Search ({} for previous query)", + previous_query_keystrokes.join(" ") + ) + } + (None, None) => String::new(), + }; + self.query_editor.update(cx, |editor, cx| { + editor.set_placeholder_text(new_placeholder_text, cx); + }); + + MouseEventHandler::new::(0, cx, |_, _| { + Flex::column() + .with_child(Flex::column().contained().flex(1., true)) + .with_child( + Flex::column() + .align_children_center() + .with_child(Label::new( + major_text, + theme.search.major_results_status.clone(), + )) + .with_children( + minor_text.into_iter().map(|x| { + Label::new(x, theme.search.minor_results_status.clone()) + }), + ) + .aligned() + .top() + .contained() + .flex(7., true), + ) + .contained() + .with_background_color(theme.editor.background) + }) + .on_down(MouseButton::Left, |_, _, cx| { + cx.focus_parent(); + }) + .into_any_named("project search view") + } else { + ChildView::new(&self.results_editor, cx) + .flex(1., true) + .into_any_named("project search view") + } + } + + fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { + let handle = cx.weak_handle(); + cx.update_global(|state: &mut ActiveSearches, cx| { + state + .0 + .insert(self.model.read(cx).project.downgrade(), handle) + }); + + cx.update_global(|state: &mut ActiveSettings, cx| { + state.0.insert( + self.model.read(cx).project.downgrade(), + self.current_settings(), + ); + }); + + if cx.is_self_focused() { + if self.query_editor_was_focused { + cx.focus(&self.query_editor); + } else { + cx.focus(&self.results_editor); + } + } + } +} + +impl Item for ProjectSearchView { + fn tab_tooltip_text(&self, cx: &AppContext) -> Option> { + let query_text = self.query_editor.read(cx).text(cx); + + query_text + .is_empty() + .not() + .then(|| query_text.into()) + .or_else(|| Some("Project Search".into())) + } + fn should_close_item_on_event(event: &Self::Event) -> bool { + event == &Self::Event::Dismiss + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a ViewHandle, + _: &'a AppContext, + ) -> Option<&'a AnyViewHandle> { + if type_id == TypeId::of::() { + Some(self_handle) + } else if type_id == TypeId::of::() { + Some(&self.results_editor) + } else { + None + } + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.results_editor + .update(cx, |editor, cx| editor.deactivated(cx)); + } + + fn tab_content( + &self, + _detail: Option, + tab_theme: &theme::Tab, + cx: &AppContext, + ) -> AnyElement { + Flex::row() + .with_child( + Svg::new("icons/magnifying_glass.svg") + .with_color(tab_theme.label.text.color) + .constrained() + .with_width(tab_theme.type_icon_width) + .aligned() + .contained() + .with_margin_right(tab_theme.spacing), + ) + .with_child({ + let tab_name: Option> = self + .model + .read(cx) + .search_history + .current() + .as_ref() + .map(|query| { + let query_text = util::truncate_and_trailoff(query, MAX_TAB_TITLE_LEN); + query_text.into() + }); + Label::new( + tab_name + .filter(|name| !name.is_empty()) + .unwrap_or("Project search".into()), + tab_theme.label.clone(), + ) + .aligned() + }) + .into_any() + } + + fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) { + self.results_editor.for_each_project_item(cx, f) + } + + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + + fn can_save(&self, _: &AppContext) -> bool { + true + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.results_editor.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.results_editor.read(cx).has_conflict(cx) + } + + fn save( + &mut self, + project: ModelHandle, + cx: &mut ViewContext, + ) -> Task> { + self.results_editor + .update(cx, |editor, cx| editor.save(project, cx)) + } + + fn save_as( + &mut self, + _: ModelHandle, + _: PathBuf, + _: &mut ViewContext, + ) -> Task> { + unreachable!("save_as should not have been called") + } + + fn reload( + &mut self, + project: ModelHandle, + cx: &mut ViewContext, + ) -> Task> { + self.results_editor + .update(cx, |editor, cx| editor.reload(project, cx)) + } + + fn clone_on_split(&self, _workspace_id: WorkspaceId, cx: &mut ViewContext) -> Option + where + Self: Sized, + { + let model = self.model.update(cx, |model, cx| model.clone(cx)); + Some(Self::new(model, cx, None)) + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + self.results_editor + .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); + } + + fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext) { + self.results_editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.results_editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> { + match event { + ViewEvent::UpdateTab => { + smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab] + } + ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event), + ViewEvent::Dismiss => smallvec::smallvec![ItemEvent::CloseItem], + _ => SmallVec::new(), + } + } + + fn breadcrumb_location(&self) -> ToolbarItemLocation { + if self.has_matches() { + ToolbarItemLocation::Secondary + } else { + ToolbarItemLocation::Hidden + } + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { + self.results_editor.breadcrumbs(theme, cx) + } + + fn serialized_item_kind() -> Option<&'static str> { + None + } + + fn deserialize( + _project: ModelHandle, + _workspace: WeakViewHandle, + _workspace_id: workspace::WorkspaceId, + _item_id: workspace::ItemId, + _cx: &mut ViewContext, + ) -> Task>> { + unimplemented!() + } +} + +impl ProjectSearchView { + fn toggle_filters(&mut self, cx: &mut ViewContext) { + self.filters_enabled = !self.filters_enabled; + cx.update_global(|state: &mut ActiveSettings, cx| { + state.0.insert( + self.model.read(cx).project.downgrade(), + self.current_settings(), + ); + }); + } + + fn current_settings(&self) -> ProjectSearchSettings { + ProjectSearchSettings { + search_options: self.search_options, + filters_enabled: self.filters_enabled, + current_mode: self.current_mode, + } + } + fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext) { + self.search_options.toggle(option); + cx.update_global(|state: &mut ActiveSettings, cx| { + state.0.insert( + self.model.read(cx).project.downgrade(), + self.current_settings(), + ); + }); + } + + fn index_project(&mut self, cx: &mut ViewContext) { + if let Some(semantic_index) = SemanticIndex::global(cx) { + // Semantic search uses no options + self.search_options = SearchOptions::none(); + + let project = self.model.read(cx).project.clone(); + + semantic_index.update(cx, |semantic_index, cx| { + semantic_index + .index_project(project.clone(), cx) + .detach_and_log_err(cx); + }); + + self.semantic_state = Some(SemanticState { + index_status: semantic_index.read(cx).status(&project), + maintain_rate_limit: None, + _subscription: cx.observe(&semantic_index, Self::semantic_index_changed), + }); + self.semantic_index_changed(semantic_index, cx); + } + } + + fn semantic_index_changed( + &mut self, + semantic_index: ModelHandle, + cx: &mut ViewContext, + ) { + let project = self.model.read(cx).project.clone(); + if let Some(semantic_state) = self.semantic_state.as_mut() { + cx.notify(); + semantic_state.index_status = semantic_index.read(cx).status(&project); + if let SemanticIndexStatus::Indexing { + rate_limit_expiry: Some(_), + .. + } = &semantic_state.index_status + { + if semantic_state.maintain_rate_limit.is_none() { + semantic_state.maintain_rate_limit = + Some(cx.spawn(|this, mut cx| async move { + loop { + cx.background().timer(Duration::from_secs(1)).await; + this.update(&mut cx, |_, cx| cx.notify()).log_err(); + } + })); + return; + } + } else { + semantic_state.maintain_rate_limit = None; + } + } + } + + fn clear_search(&mut self, cx: &mut ViewContext) { + self.model.update(cx, |model, cx| { + model.pending_search = None; + model.no_results = None; + model.match_ranges.clear(); + + model.excerpts.update(cx, |excerpts, cx| { + excerpts.clear(cx); + }); + }); + } + + fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext) { + let previous_mode = self.current_mode; + if previous_mode == mode { + return; + } + + self.clear_search(cx); + self.current_mode = mode; + self.active_match_index = None; + + match mode { + SearchMode::Semantic => { + let has_permission = self.semantic_permissioned(cx); + self.active_match_index = None; + cx.spawn(|this, mut cx| async move { + let has_permission = has_permission.await?; + + if !has_permission { + let mut answer = this.update(&mut cx, |this, cx| { + let project = this.model.read(cx).project.clone(); + let project_name = project + .read(cx) + .worktree_root_names(cx) + .collect::>() + .join("/"); + let is_plural = + project_name.chars().filter(|letter| *letter == '/').count() > 0; + let prompt_text = format!("Would you like to index the '{}' project{} for semantic search? This requires sending code to the OpenAI API", project_name, + if is_plural { + "s" + } else {""}); + cx.prompt( + PromptLevel::Info, + prompt_text.as_str(), + &["Continue", "Cancel"], + ) + })?; + + if answer.next().await == Some(0) { + this.update(&mut cx, |this, _| { + this.semantic_permissioned = Some(true); + })?; + } else { + this.update(&mut cx, |this, cx| { + this.semantic_permissioned = Some(false); + debug_assert_ne!(previous_mode, SearchMode::Semantic, "Tried to re-enable semantic search mode after user modal was rejected"); + this.activate_search_mode(previous_mode, cx); + })?; + return anyhow::Ok(()); + } + } + + this.update(&mut cx, |this, cx| { + this.index_project(cx); + })?; + + anyhow::Ok(()) + }).detach_and_log_err(cx); + } + SearchMode::Regex | SearchMode::Text => { + self.semantic_state = None; + self.active_match_index = None; + self.search(cx); + } + } + + cx.update_global(|state: &mut ActiveSettings, cx| { + state.0.insert( + self.model.read(cx).project.downgrade(), + self.current_settings(), + ); + }); + + cx.notify(); + } + fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { + let model = self.model.read(cx); + if let Some(query) = model.active_query.as_ref() { + if model.match_ranges.is_empty() { + return; + } + if let Some(active_index) = self.active_match_index { + let query = query.clone().with_replacement(self.replacement(cx)); + self.results_editor.replace( + &(Box::new(model.match_ranges[active_index].clone()) as _), + &query, + cx, + ); + self.select_match(Direction::Next, cx) + } + } + } + pub fn replacement(&self, cx: &AppContext) -> String { + self.replacement_editor.read(cx).text(cx) + } + fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext) { + let model = self.model.read(cx); + if let Some(query) = model.active_query.as_ref() { + if model.match_ranges.is_empty() { + return; + } + if self.active_match_index.is_some() { + let query = query.clone().with_replacement(self.replacement(cx)); + let matches = model + .match_ranges + .iter() + .map(|item| Box::new(item.clone()) as _) + .collect::>(); + for item in matches { + self.results_editor.replace(&item, &query, cx); + } + } + } + } + + fn new( + model: ModelHandle, + cx: &mut ViewContext, + settings: Option, + ) -> Self { + let project; + let excerpts; + let mut replacement_text = None; + let mut query_text = String::new(); + + // Read in settings if available + let (mut options, current_mode, filters_enabled) = if let Some(settings) = settings { + ( + settings.search_options, + settings.current_mode, + settings.filters_enabled, + ) + } else { + (SearchOptions::NONE, Default::default(), false) + }; + + { + let model = model.read(cx); + project = model.project.clone(); + excerpts = model.excerpts.clone(); + if let Some(active_query) = model.active_query.as_ref() { + query_text = active_query.as_str().to_string(); + replacement_text = active_query.replacement().map(ToOwned::to_owned); + options = SearchOptions::from_query(active_query); + } + } + cx.observe(&model, |this, _, cx| this.model_changed(cx)) + .detach(); + + let query_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| theme.search.editor.input.clone())), + cx, + ); + editor.set_placeholder_text("Text search all files", cx); + editor.set_text(query_text, cx); + editor + }); + // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes + cx.subscribe(&query_editor, |_, _, event, cx| { + cx.emit(ViewEvent::EditorEvent(event.clone())) + }) + .detach(); + let replacement_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| theme.search.editor.input.clone())), + cx, + ); + editor.set_placeholder_text("Replace in project..", cx); + if let Some(text) = replacement_text { + editor.set_text(text, cx); + } + editor + }); + let results_editor = cx.add_view(|cx| { + let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), cx); + editor.set_searchable(false); + editor + }); + cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)) + .detach(); + + cx.subscribe(&results_editor, |this, _, event, cx| { + if matches!(event, editor::Event::SelectionsChanged { .. }) { + this.update_match_index(cx); + } + // Reraise editor events for workspace item activation purposes + cx.emit(ViewEvent::EditorEvent(event.clone())); + }) + .detach(); + + let included_files_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| { + theme.search.include_exclude_editor.input.clone() + })), + cx, + ); + editor.set_placeholder_text("Include: crates/**/*.toml", cx); + + editor + }); + // Subscribe to include_files_editor in order to reraise editor events for workspace item activation purposes + cx.subscribe(&included_files_editor, |_, _, event, cx| { + cx.emit(ViewEvent::EditorEvent(event.clone())) + }) + .detach(); + + let excluded_files_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| { + theme.search.include_exclude_editor.input.clone() + })), + cx, + ); + editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx); + + editor + }); + // Subscribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes + cx.subscribe(&excluded_files_editor, |_, _, event, cx| { + cx.emit(ViewEvent::EditorEvent(event.clone())) + }) + .detach(); + + // Check if Worktrees have all been previously indexed + let mut this = ProjectSearchView { + replacement_editor, + search_id: model.read(cx).search_id, + model, + query_editor, + results_editor, + semantic_state: None, + semantic_permissioned: None, + search_options: options, + panels_with_errors: HashSet::new(), + active_match_index: None, + query_editor_was_focused: false, + included_files_editor, + excluded_files_editor, + filters_enabled, + current_mode, + replace_enabled: false, + }; + this.model_changed(cx); + this + } + + fn semantic_permissioned(&mut self, cx: &mut ViewContext) -> Task> { + if let Some(value) = self.semantic_permissioned { + return Task::ready(Ok(value)); + } + + SemanticIndex::global(cx) + .map(|semantic| { + let project = self.model.read(cx).project.clone(); + semantic.update(cx, |this, cx| this.project_previously_indexed(&project, cx)) + }) + .unwrap_or(Task::ready(Ok(false))) + } + pub fn new_search_in_directory( + workspace: &mut Workspace, + dir_entry: &Entry, + cx: &mut ViewContext, + ) { + if !dir_entry.is_dir() { + return; + } + let Some(filter_str) = dir_entry.path.to_str() else { + return; + }; + + let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx)); + let search = cx.add_view(|cx| ProjectSearchView::new(model, cx, None)); + workspace.add_item(Box::new(search.clone()), cx); + search.update(cx, |search, cx| { + search + .included_files_editor + .update(cx, |editor, cx| editor.set_text(filter_str, cx)); + search.filters_enabled = true; + search.focus_query_editor(cx) + }); + } + + // Re-activate the most recently activated search or the most recent if it has been closed. + // If no search exists in the workspace, create a new one. + fn deploy( + workspace: &mut Workspace, + _: &workspace::NewSearch, + cx: &mut ViewContext, + ) { + // Clean up entries for dropped projects + cx.update_global(|state: &mut ActiveSearches, cx| { + state.0.retain(|project, _| project.is_upgradable(cx)) + }); + + let active_search = cx + .global::() + .0 + .get(&workspace.project().downgrade()); + + let existing = active_search + .and_then(|active_search| { + workspace + .items_of_type::(cx) + .find(|search| search == active_search) + }) + .or_else(|| workspace.item_of_type::(cx)); + + let query = workspace.active_item(cx).and_then(|item| { + let editor = item.act_as::(cx)?; + let query = editor.query_suggestion(cx); + if query.is_empty() { + None + } else { + Some(query) + } + }); + + let search = if let Some(existing) = existing { + workspace.activate_item(&existing, cx); + existing + } else { + let settings = cx + .global::() + .0 + .get(&workspace.project().downgrade()); + + let settings = if let Some(settings) = settings { + Some(settings.clone()) + } else { + None + }; + + let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx)); + let view = cx.add_view(|cx| ProjectSearchView::new(model, cx, settings)); + + workspace.add_item(Box::new(view.clone()), cx); + view + }; + + search.update(cx, |search, cx| { + if let Some(query) = query { + search.set_query(&query, cx); + } + search.focus_query_editor(cx) + }); + } + + fn search(&mut self, cx: &mut ViewContext) { + let mode = self.current_mode; + match mode { + SearchMode::Semantic => { + if self.semantic_state.is_some() { + if let Some(query) = self.build_search_query(cx) { + self.model + .update(cx, |model, cx| model.semantic_search(query.as_inner(), cx)); + } + } + } + + _ => { + if let Some(query) = self.build_search_query(cx) { + self.model.update(cx, |model, cx| model.search(query, cx)); + } + } + } + } + + fn build_search_query(&mut self, cx: &mut ViewContext) -> Option { + let text = self.query_editor.read(cx).text(cx); + let included_files = + match Self::parse_path_matches(&self.included_files_editor.read(cx).text(cx)) { + Ok(included_files) => { + self.panels_with_errors.remove(&InputPanel::Include); + included_files + } + Err(_e) => { + self.panels_with_errors.insert(InputPanel::Include); + cx.notify(); + return None; + } + }; + let excluded_files = + match Self::parse_path_matches(&self.excluded_files_editor.read(cx).text(cx)) { + Ok(excluded_files) => { + self.panels_with_errors.remove(&InputPanel::Exclude); + excluded_files + } + Err(_e) => { + self.panels_with_errors.insert(InputPanel::Exclude); + cx.notify(); + return None; + } + }; + let current_mode = self.current_mode; + match current_mode { + SearchMode::Regex => { + match SearchQuery::regex( + text, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), + included_files, + excluded_files, + ) { + Ok(query) => { + self.panels_with_errors.remove(&InputPanel::Query); + Some(query) + } + Err(_e) => { + self.panels_with_errors.insert(InputPanel::Query); + cx.notify(); + None + } + } + } + _ => match SearchQuery::text( + text, + self.search_options.contains(SearchOptions::WHOLE_WORD), + self.search_options.contains(SearchOptions::CASE_SENSITIVE), + included_files, + excluded_files, + ) { + Ok(query) => { + self.panels_with_errors.remove(&InputPanel::Query); + Some(query) + } + Err(_e) => { + self.panels_with_errors.insert(InputPanel::Query); + cx.notify(); + None + } + }, + } + } + + fn parse_path_matches(text: &str) -> anyhow::Result> { + text.split(',') + .map(str::trim) + .filter(|maybe_glob_str| !maybe_glob_str.is_empty()) + .map(|maybe_glob_str| { + PathMatcher::new(maybe_glob_str) + .with_context(|| format!("parsing {maybe_glob_str} as path matcher")) + }) + .collect() + } + + fn select_match(&mut self, direction: Direction, cx: &mut ViewContext) { + if let Some(index) = self.active_match_index { + let match_ranges = self.model.read(cx).match_ranges.clone(); + let new_index = self.results_editor.update(cx, |editor, cx| { + editor.match_index_for_direction(&match_ranges, index, direction, 1, cx) + }); + + let range_to_select = match_ranges[new_index].clone(); + self.results_editor.update(cx, |editor, cx| { + let range_to_select = editor.range_for_match(&range_to_select); + editor.unfold_ranges([range_to_select.clone()], false, true, cx); + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges([range_to_select]) + }); + }); + } + } + + fn focus_query_editor(&mut self, cx: &mut ViewContext) { + self.query_editor.update(cx, |query_editor, cx| { + query_editor.select_all(&SelectAll, cx); + }); + self.query_editor_was_focused = true; + cx.focus(&self.query_editor); + } + + fn set_query(&mut self, query: &str, cx: &mut ViewContext) { + self.query_editor + .update(cx, |query_editor, cx| query_editor.set_text(query, cx)); + } + + fn focus_results_editor(&mut self, cx: &mut ViewContext) { + self.query_editor.update(cx, |query_editor, cx| { + let cursor = query_editor.selections.newest_anchor().head(); + query_editor.change_selections(None, cx, |s| s.select_ranges([cursor.clone()..cursor])); + }); + self.query_editor_was_focused = false; + cx.focus(&self.results_editor); + } + + fn model_changed(&mut self, cx: &mut ViewContext) { + let match_ranges = self.model.read(cx).match_ranges.clone(); + if match_ranges.is_empty() { + self.active_match_index = None; + } else { + self.active_match_index = Some(0); + self.update_match_index(cx); + let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id); + let is_new_search = self.search_id != prev_search_id; + self.results_editor.update(cx, |editor, cx| { + if is_new_search { + let range_to_select = match_ranges + .first() + .clone() + .map(|range| editor.range_for_match(range)); + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges(range_to_select) + }); + } + editor.highlight_background::( + match_ranges, + |theme| theme.search.match_background, + cx, + ); + }); + if is_new_search && self.query_editor.is_focused(cx) { + self.focus_results_editor(cx); + } + } + + cx.emit(ViewEvent::UpdateTab); + cx.notify(); + } + + fn update_match_index(&mut self, cx: &mut ViewContext) { + let results_editor = self.results_editor.read(cx); + let new_index = active_match_index( + &self.model.read(cx).match_ranges, + &results_editor.selections.newest_anchor().head(), + &results_editor.buffer().read(cx).snapshot(cx), + ); + if self.active_match_index != new_index { + self.active_match_index = new_index; + cx.notify(); + } + } + + pub fn has_matches(&self) -> bool { + self.active_match_index.is_some() + } + + fn move_focus_to_results(pane: &mut Pane, _: &ToggleFocus, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |search_view, cx| { + if !search_view.results_editor.is_focused(cx) + && !search_view.model.read(cx).match_ranges.is_empty() + { + return search_view.focus_results_editor(cx); + } + }); + } + + cx.propagate_action(); + } +} + +impl Default for ProjectSearchBar { + fn default() -> Self { + Self::new() + } +} + +impl ProjectSearchBar { + pub fn new() -> Self { + Self { + active_project_search: Default::default(), + subscription: Default::default(), + } + } + fn cycle_mode(workspace: &mut Workspace, _: &CycleMode, cx: &mut ViewContext) { + if let Some(search_view) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |this, cx| { + let new_mode = + crate::mode::next_mode(&this.current_mode, SemanticIndex::enabled(cx)); + this.activate_search_mode(new_mode, cx); + cx.focus(&this.query_editor); + }) + } + } + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + let mut should_propagate = true; + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.update(cx, |search_view, cx| { + if !search_view.replacement_editor.is_focused(cx) { + should_propagate = false; + search_view.search(cx); + } + }); + } + if should_propagate { + cx.propagate_action(); + } + } + + fn search_in_new(workspace: &mut Workspace, _: &SearchInNew, cx: &mut ViewContext) { + if let Some(search_view) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + { + let new_query = search_view.update(cx, |search_view, cx| { + let new_query = search_view.build_search_query(cx); + if new_query.is_some() { + if let Some(old_query) = search_view.model.read(cx).active_query.clone() { + search_view.query_editor.update(cx, |editor, cx| { + editor.set_text(old_query.as_str(), cx); + }); + search_view.search_options = SearchOptions::from_query(&old_query); + } + } + new_query + }); + if let Some(new_query) = new_query { + let model = cx.add_model(|cx| { + let mut model = ProjectSearch::new(workspace.project().clone(), cx); + model.search(new_query, cx); + model + }); + workspace.add_item( + Box::new(cx.add_view(|cx| ProjectSearchView::new(model, cx, None))), + cx, + ); + } + } + } + + fn select_next_match(pane: &mut Pane, _: &SelectNextMatch, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| view.select_match(Direction::Next, cx)); + } else { + cx.propagate_action(); + } + } + + fn replace_next(pane: &mut Pane, _: &ReplaceNext, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| view.replace_next(&ReplaceNext, cx)); + } else { + cx.propagate_action(); + } + } + fn replace_all(pane: &mut Pane, _: &ReplaceAll, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| view.replace_all(&ReplaceAll, cx)); + } else { + cx.propagate_action(); + } + } + fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| view.select_match(Direction::Prev, cx)); + } else { + cx.propagate_action(); + } + } + + fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext) { + self.cycle_field(Direction::Next, cx); + } + + fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext) { + self.cycle_field(Direction::Prev, cx); + } + + fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext) { + let active_project_search = match &self.active_project_search { + Some(active_project_search) => active_project_search, + + None => { + cx.propagate_action(); + return; + } + }; + + active_project_search.update(cx, |project_view, cx| { + let mut views = vec![&project_view.query_editor]; + if project_view.filters_enabled { + views.extend([ + &project_view.included_files_editor, + &project_view.excluded_files_editor, + ]); + } + if project_view.replace_enabled { + views.push(&project_view.replacement_editor); + } + let current_index = match views + .iter() + .enumerate() + .find(|(_, view)| view.is_focused(cx)) + { + Some((index, _)) => index, + + None => { + cx.propagate_action(); + return; + } + }; + + let new_index = match direction { + Direction::Next => (current_index + 1) % views.len(), + Direction::Prev if current_index == 0 => views.len() - 1, + Direction::Prev => (current_index - 1) % views.len(), + }; + cx.focus(views[new_index]); + }); + } + + fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext) -> bool { + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.update(cx, |search_view, cx| { + search_view.toggle_search_option(option, cx); + search_view.search(cx); + }); + + cx.notify(); + true + } else { + false + } + } + fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext) { + if let Some(search) = &self.active_project_search { + search.update(cx, |this, cx| { + this.replace_enabled = !this.replace_enabled; + if !this.replace_enabled { + cx.focus(&this.query_editor); + } + cx.notify(); + }); + } + } + fn toggle_replace_on_a_pane(pane: &mut Pane, _: &ToggleReplace, cx: &mut ViewContext) { + let mut should_propagate = true; + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |this, cx| { + should_propagate = false; + this.replace_enabled = !this.replace_enabled; + if !this.replace_enabled { + cx.focus(&this.query_editor); + } + cx.notify(); + }); + } + if should_propagate { + cx.propagate_action(); + } + } + fn activate_text_mode(pane: &mut Pane, _: &ActivateTextMode, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| { + view.activate_search_mode(SearchMode::Text, cx) + }); + } else { + cx.propagate_action(); + } + } + + fn activate_regex_mode(pane: &mut Pane, _: &ActivateRegexMode, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| { + view.activate_search_mode(SearchMode::Regex, cx) + }); + } else { + cx.propagate_action(); + } + } + + fn activate_semantic_mode( + pane: &mut Pane, + _: &ActivateSemanticMode, + cx: &mut ViewContext, + ) { + if SemanticIndex::enabled(cx) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| { + view.activate_search_mode(SearchMode::Semantic, cx) + }); + } else { + cx.propagate_action(); + } + } + } + + fn toggle_filters(&mut self, cx: &mut ViewContext) -> bool { + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.update(cx, |search_view, cx| { + search_view.toggle_filters(cx); + search_view + .included_files_editor + .update(cx, |_, cx| cx.notify()); + search_view + .excluded_files_editor + .update(cx, |_, cx| cx.notify()); + cx.refresh_windows(); + cx.notify(); + }); + cx.notify(); + true + } else { + false + } + } + + fn activate_search_mode(&self, mode: SearchMode, cx: &mut ViewContext) { + // Update Current Mode + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.update(cx, |search_view, cx| { + search_view.activate_search_mode(mode, cx); + }); + cx.notify(); + } + } + + fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool { + if let Some(search) = self.active_project_search.as_ref() { + search.read(cx).search_options.contains(option) + } else { + false + } + } + + fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext) { + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.update(cx, |search_view, cx| { + let new_query = search_view.model.update(cx, |model, _| { + if let Some(new_query) = model.search_history.next().map(str::to_string) { + new_query + } else { + model.search_history.reset_selection(); + String::new() + } + }); + search_view.set_query(&new_query, cx); + }); + } + } + + fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext) { + if let Some(search_view) = self.active_project_search.as_ref() { + search_view.update(cx, |search_view, cx| { + if search_view.query_editor.read(cx).text(cx).is_empty() { + if let Some(new_query) = search_view + .model + .read(cx) + .search_history + .current() + .map(str::to_string) + { + search_view.set_query(&new_query, cx); + return; + } + } + + if let Some(new_query) = search_view.model.update(cx, |model, _| { + model.search_history.previous().map(str::to_string) + }) { + search_view.set_query(&new_query, cx); + } + }); + } + } +} + +impl Entity for ProjectSearchBar { + type Event = (); +} + +impl View for ProjectSearchBar { + fn ui_name() -> &'static str { + "ProjectSearchBar" + } + + fn update_keymap_context( + &self, + keymap: &mut gpui::keymap_matcher::KeymapContext, + cx: &AppContext, + ) { + Self::reset_to_default_keymap_context(keymap); + let in_replace = self + .active_project_search + .as_ref() + .map(|search| { + search + .read(cx) + .replacement_editor + .read_with(cx, |_, cx| cx.is_self_focused()) + }) + .flatten() + .unwrap_or(false); + if in_replace { + keymap.add_identifier("in_replace"); + } + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + if let Some(_search) = self.active_project_search.as_ref() { + let search = _search.read(cx); + let theme = theme::current(cx).clone(); + let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) { + theme.search.invalid_editor + } else { + theme.search.editor.input.container + }; + + let search = _search.read(cx); + let filter_button = render_option_button_icon( + search.filters_enabled, + "icons/filter.svg", + 0, + "Toggle filters", + Box::new(ToggleFilters), + move |_, this, cx| { + this.toggle_filters(cx); + }, + cx, + ); + + let search = _search.read(cx); + let is_semantic_available = SemanticIndex::enabled(cx); + let is_semantic_disabled = search.semantic_state.is_none(); + let icon_style = theme.search.editor_icon.clone(); + let is_active = search.active_match_index.is_some(); + + let render_option_button_icon = |path, option, cx: &mut ViewContext| { + crate::search_bar::render_option_button_icon( + self.is_option_enabled(option, cx), + path, + option.bits as usize, + format!("Toggle {}", option.label()), + option.to_toggle_action(), + move |_, this, cx| { + this.toggle_search_option(option, cx); + }, + cx, + ) + }; + let case_sensitive = is_semantic_disabled.then(|| { + render_option_button_icon( + "icons/case_insensitive.svg", + SearchOptions::CASE_SENSITIVE, + cx, + ) + }); + + let whole_word = is_semantic_disabled.then(|| { + render_option_button_icon("icons/word_search.svg", SearchOptions::WHOLE_WORD, cx) + }); + + let search_button_for_mode = |mode, side, cx: &mut ViewContext| { + let is_active = if let Some(search) = self.active_project_search.as_ref() { + let search = search.read(cx); + search.current_mode == mode + } else { + false + }; + render_search_mode_button( + mode, + side, + is_active, + move |_, this, cx| { + this.activate_search_mode(mode, cx); + }, + cx, + ) + }; + + let search = _search.read(cx); + + let include_container_style = + if search.panels_with_errors.contains(&InputPanel::Include) { + theme.search.invalid_include_exclude_editor + } else { + theme.search.include_exclude_editor.input.container + }; + + let exclude_container_style = + if search.panels_with_errors.contains(&InputPanel::Exclude) { + theme.search.invalid_include_exclude_editor + } else { + theme.search.include_exclude_editor.input.container + }; + + let matches = search.active_match_index.map(|match_ix| { + Label::new( + format!( + "{}/{}", + match_ix + 1, + search.model.read(cx).match_ranges.len() + ), + theme.search.match_index.text.clone(), + ) + .contained() + .with_style(theme.search.match_index.container) + .aligned() + }); + let should_show_replace_input = search.replace_enabled; + let replacement = should_show_replace_input.then(|| { + Flex::row() + .with_child( + Svg::for_style(theme.search.replace_icon.clone().icon) + .contained() + .with_style(theme.search.replace_icon.clone().container), + ) + .with_child(ChildView::new(&search.replacement_editor, cx).flex(1., true)) + .align_children_center() + .flex(1., true) + .contained() + .with_style(query_container_style) + .constrained() + .with_min_width(theme.search.editor.min_width) + .with_max_width(theme.search.editor.max_width) + .with_height(theme.search.search_bar_row_height) + .flex(1., false) + }); + let replace_all = should_show_replace_input.then(|| { + super::replace_action( + ReplaceAll, + "Replace all", + "icons/replace_all.svg", + theme.tooltip.clone(), + theme.search.action_button.clone(), + ) + }); + let replace_next = should_show_replace_input.then(|| { + super::replace_action( + ReplaceNext, + "Replace next", + "icons/replace_next.svg", + theme.tooltip.clone(), + theme.search.action_button.clone(), + ) + }); + let query_column = Flex::column() + .with_spacing(theme.search.search_row_spacing) + .with_child( + Flex::row() + .with_child( + Svg::for_style(icon_style.icon) + .contained() + .with_style(icon_style.container), + ) + .with_child(ChildView::new(&search.query_editor, cx).flex(1., true)) + .with_child( + Flex::row() + .with_child(filter_button) + .with_children(case_sensitive) + .with_children(whole_word) + .flex(1., false) + .constrained() + .contained(), + ) + .align_children_center() + .contained() + .with_style(query_container_style) + .constrained() + .with_min_width(theme.search.editor.min_width) + .with_max_width(theme.search.editor.max_width) + .with_height(theme.search.search_bar_row_height) + .flex(1., false), + ) + .with_children(search.filters_enabled.then(|| { + Flex::row() + .with_child( + ChildView::new(&search.included_files_editor, cx) + .contained() + .with_style(include_container_style) + .constrained() + .with_height(theme.search.search_bar_row_height) + .flex(1., true), + ) + .with_child( + ChildView::new(&search.excluded_files_editor, cx) + .contained() + .with_style(exclude_container_style) + .constrained() + .with_height(theme.search.search_bar_row_height) + .flex(1., true), + ) + .constrained() + .with_min_width(theme.search.editor.min_width) + .with_max_width(theme.search.editor.max_width) + .flex(1., false) + })) + .flex(1., false); + let switches_column = Flex::row() + .align_children_center() + .with_child(super::toggle_replace_button( + search.replace_enabled, + theme.tooltip.clone(), + theme.search.option_button_component.clone(), + )) + .constrained() + .with_height(theme.search.search_bar_row_height) + .contained() + .with_style(theme.search.option_button_group); + let mode_column = + Flex::row() + .with_child(search_button_for_mode( + SearchMode::Text, + Some(Side::Left), + cx, + )) + .with_child(search_button_for_mode( + SearchMode::Regex, + if is_semantic_available { + None + } else { + Some(Side::Right) + }, + cx, + )) + .with_children(is_semantic_available.then(|| { + search_button_for_mode(SearchMode::Semantic, Some(Side::Right), cx) + })) + .contained() + .with_style(theme.search.modes_container); + + let nav_button_for_direction = |label, direction, cx: &mut ViewContext| { + render_nav_button( + label, + direction, + is_active, + move |_, this, cx| { + if let Some(search) = this.active_project_search.as_ref() { + search.update(cx, |search, cx| search.select_match(direction, cx)); + } + }, + cx, + ) + }; + + let nav_column = Flex::row() + .with_children(replace_next) + .with_children(replace_all) + .with_child(Flex::row().with_children(matches)) + .with_child(nav_button_for_direction("<", Direction::Prev, cx)) + .with_child(nav_button_for_direction(">", Direction::Next, cx)) + .constrained() + .with_height(theme.search.search_bar_row_height) + .flex_float(); + + Flex::row() + .with_child(query_column) + .with_child(mode_column) + .with_child(switches_column) + .with_children(replacement) + .with_child(nav_column) + .contained() + .with_style(theme.search.container) + .into_any_named("project search") + } else { + Empty::new().into_any() + } + } +} + +impl ToolbarItemView for ProjectSearchBar { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) -> ToolbarItemLocation { + cx.notify(); + self.subscription = None; + self.active_project_search = None; + if let Some(search) = active_pane_item.and_then(|i| i.downcast::()) { + search.update(cx, |search, cx| { + if search.current_mode == SearchMode::Semantic { + search.index_project(cx); + } + }); + + self.subscription = Some(cx.observe(&search, |_, _, cx| cx.notify())); + self.active_project_search = Some(search); + ToolbarItemLocation::PrimaryLeft { + flex: Some((1., true)), + } + } else { + ToolbarItemLocation::Hidden + } + } + + fn row_count(&self, cx: &ViewContext) -> usize { + if let Some(search) = self.active_project_search.as_ref() { + if search.read(cx).filters_enabled { + return 2; + } + } + 1 + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use editor::DisplayPoint; + use gpui::{color::Color, executor::Deterministic, TestAppContext}; + use project::FakeFs; + use semantic_index::semantic_index_settings::SemanticIndexSettings; + use serde_json::json; + use settings::SettingsStore; + use std::sync::Arc; + use theme::ThemeSettings; + + #[gpui::test] + async fn test_project_search(deterministic: Arc, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + "three.rs": "const THREE: usize = one::ONE + two::TWO;", + "four.rs": "const FOUR: usize = one::ONE + three::THREE;", + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let search = cx.add_model(|cx| ProjectSearch::new(project, cx)); + let search_view = cx + .add_window(|cx| ProjectSearchView::new(search.clone(), cx, None)) + .root(cx); + + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx)); + search_view.search(cx); + }); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;" + ); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.all_text_background_highlights(cx)), + &[ + ( + DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35), + Color::red() + ), + ( + DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40), + Color::red() + ), + ( + DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9), + Color::red() + ) + ] + ); + assert_eq!(search_view.active_match_index, Some(0)); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)] + ); + + search_view.select_match(Direction::Next, cx); + }); + + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.active_match_index, Some(1)); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)] + ); + search_view.select_match(Direction::Next, cx); + }); + + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.active_match_index, Some(2)); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)] + ); + search_view.select_match(Direction::Next, cx); + }); + + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.active_match_index, Some(0)); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(2, 32)..DisplayPoint::new(2, 35)] + ); + search_view.select_match(Direction::Prev, cx); + }); + + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.active_match_index, Some(2)); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(5, 6)..DisplayPoint::new(5, 9)] + ); + search_view.select_match(Direction::Prev, cx); + }); + + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.active_match_index, Some(1)); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.selections.display_ranges(cx)), + [DisplayPoint::new(2, 37)..DisplayPoint::new(2, 40)] + ); + }); + } + + #[gpui::test] + async fn test_project_search_focus(deterministic: Arc, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + "three.rs": "const THREE: usize = one::ONE + two::TWO;", + "four.rs": "const FOUR: usize = one::ONE + three::THREE;", + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let workspace = window.root(cx); + + let active_item = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }); + assert!( + active_item.is_none(), + "Expected no search panel to be active, but got: {active_item:?}" + ); + + workspace.update(cx, |workspace, cx| { + ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx) + }); + + let Some(search_view) = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }) else { + panic!("Search view expected to appear after new search event trigger") + }; + let search_view_id = search_view.id(); + + cx.spawn(|mut cx| async move { + window.dispatch_action(search_view_id, &ToggleFocus, &mut cx); + }) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.query_editor.is_focused(cx), + "Empty search view should be focused after the toggle focus event: no results panel to focus on", + ); + }); + + search_view.update(cx, |search_view, cx| { + let query_editor = &search_view.query_editor; + assert!( + query_editor.is_focused(cx), + "Search view should be focused after the new search view is activated", + ); + let query_text = query_editor.read(cx).text(cx); + assert!( + query_text.is_empty(), + "New search query should be empty but got '{query_text}'", + ); + let results_text = search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)); + assert!( + results_text.is_empty(), + "Empty search view should have no results but got '{results_text}'" + ); + }); + + search_view.update(cx, |search_view, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("sOMETHINGtHATsURELYdOESnOTeXIST", cx) + }); + search_view.search(cx); + }); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + let results_text = search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)); + assert!( + results_text.is_empty(), + "Search view for mismatching query should have no results but got '{results_text}'" + ); + assert!( + search_view.query_editor.is_focused(cx), + "Search view should be focused after mismatching query had been used in search", + ); + }); + cx.spawn( + |mut cx| async move { window.dispatch_action(search_view_id, &ToggleFocus, &mut cx) }, + ) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.query_editor.is_focused(cx), + "Search view with mismatching query should be focused after the toggle focus event: still no results panel to focus on", + ); + }); + + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx)); + search_view.search(cx); + }); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;", + "Search view results should match the query" + ); + assert!( + search_view.results_editor.is_focused(cx), + "Search view with mismatching query should be focused after search results are available", + ); + }); + cx.spawn(|mut cx| async move { + window.dispatch_action(search_view_id, &ToggleFocus, &mut cx); + }) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.results_editor.is_focused(cx), + "Search view with matching query should still have its results editor focused after the toggle focus event", + ); + }); + + workspace.update(cx, |workspace, cx| { + ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx) + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "two", "Query should be updated to first search result after search view 2nd open in a row"); + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;", + "Results should be unchanged after search view 2nd open in a row" + ); + assert!( + search_view.query_editor.is_focused(cx), + "Focus should be moved into query editor again after search view 2nd open in a row" + ); + }); + + cx.spawn(|mut cx| async move { + window.dispatch_action(search_view_id, &ToggleFocus, &mut cx); + }) + .detach(); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.results_editor.is_focused(cx), + "Search view with matching query should switch focus to the results editor after the toggle focus event", + ); + }); + } + + #[gpui::test] + async fn test_new_project_search_in_directory( + deterministic: Arc, + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a": { + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + }, + "b": { + "three.rs": "const THREE: usize = one::ONE + two::TWO;", + "four.rs": "const FOUR: usize = one::ONE + three::THREE;", + }, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let workspace = cx + .add_window(|cx| Workspace::test_new(project, cx)) + .root(cx); + + let active_item = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }); + assert!( + active_item.is_none(), + "Expected no search panel to be active, but got: {active_item:?}" + ); + + let one_file_entry = cx.update(|cx| { + workspace + .read(cx) + .project() + .read(cx) + .entry_for_path(&(worktree_id, "a/one.rs").into(), cx) + .expect("no entry for /a/one.rs file") + }); + assert!(one_file_entry.is_file()); + workspace.update(cx, |workspace, cx| { + ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx) + }); + let active_search_entry = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }); + assert!( + active_search_entry.is_none(), + "Expected no search panel to be active for file entry" + ); + + let a_dir_entry = cx.update(|cx| { + workspace + .read(cx) + .project() + .read(cx) + .entry_for_path(&(worktree_id, "a").into(), cx) + .expect("no entry for /a/ directory") + }); + assert!(a_dir_entry.is_dir()); + workspace.update(cx, |workspace, cx| { + ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx) + }); + + let Some(search_view) = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + }) else { + panic!("Search view expected to appear after new search in directory event trigger") + }; + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert!( + search_view.query_editor.is_focused(cx), + "On new search in directory, focus should be moved into query editor" + ); + search_view.excluded_files_editor.update(cx, |editor, cx| { + assert!( + editor.display_text(cx).is_empty(), + "New search in directory should not have any excluded files" + ); + }); + search_view.included_files_editor.update(cx, |editor, cx| { + assert_eq!( + editor.display_text(cx), + a_dir_entry.path.to_str().unwrap(), + "New search in directory should have included dir entry path" + ); + }); + }); + + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("const", cx)); + search_view.search(cx); + }); + deterministic.run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert_eq!( + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;", + "New search in directory should have a filter that matches a certain directory" + ); + }); + } + + #[gpui::test] + async fn test_search_query_history(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + "three.rs": "const THREE: usize = one::ONE + two::TWO;", + "four.rs": "const FOUR: usize = one::ONE + three::THREE;", + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let workspace = window.root(cx); + workspace.update(cx, |workspace, cx| { + ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx) + }); + + let search_view = cx.read(|cx| { + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("Search view expected to appear after new search event trigger") + }); + + let search_bar = window.add_view(cx, |cx| { + let mut search_bar = ProjectSearchBar::new(); + search_bar.set_active_pane_item(Some(&search_view), cx); + // search_bar.show(cx); + search_bar + }); + + // Add 3 search items into the history + another unsubmitted one. + search_view.update(cx, |search_view, cx| { + search_view.search_options = SearchOptions::CASE_SENSITIVE; + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx)); + search_view.search(cx); + }); + cx.foreground().run_until_parked(); + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx)); + search_view.search(cx); + }); + cx.foreground().run_until_parked(); + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx)); + search_view.search(cx); + }); + cx.foreground().run_until_parked(); + search_view.update(cx, |search_view, cx| { + search_view.query_editor.update(cx, |query_editor, cx| { + query_editor.set_text("JUST_TEXT_INPUT", cx) + }); + }); + cx.foreground().run_until_parked(); + + // Ensure that the latest input with search settings is active. + search_view.update(cx, |search_view, cx| { + assert_eq!( + search_view.query_editor.read(cx).text(cx), + "JUST_TEXT_INPUT" + ); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Next history query after the latest should set the query to the empty string. + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), ""); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), ""); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // First previous query for empty current query should set the query to the latest submitted one. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Further previous items should go over the history in reverse order. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Previous items should never go behind the first history item. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // Next items should go over the history in the original order. + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx)); + search_view.search(cx); + }); + cx.foreground().run_until_parked(); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + + // New search input should add another entry to history and move the selection to the end of the history. + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW"); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }); + search_view.update(cx, |search_view, cx| { + assert_eq!(search_view.query_editor.read(cx).text(cx), ""); + assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE); + }); + } + + pub fn init_test(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); + let fonts = cx.font_cache(); + let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default); + theme.search.match_background = Color::red(); + + cx.update(|cx| { + cx.set_global(SettingsStore::test(cx)); + cx.set_global(ActiveSearches::default()); + settings::register::(cx); + + theme::init((), cx); + cx.update_global::(|store, _| { + let mut settings = store.get::(None).clone(); + settings.theme = Arc::new(theme); + store.override_global(settings) + }); + + language::init(cx); + client::init_settings(cx); + editor::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + super::init(cx); + }); + } +} diff --git a/crates/search2/src/search.rs b/crates/search2/src/search.rs new file mode 100644 index 0000000000000000000000000000000000000000..12152701bc69fb9dce23ce00267dd4bea55dcb19 --- /dev/null +++ b/crates/search2/src/search.rs @@ -0,0 +1,117 @@ +use bitflags::bitflags; +pub use buffer_search::BufferSearchBar; +use gpui::{actions, Action, AppContext, RenderOnce}; +pub use mode::SearchMode; +use project::search::SearchQuery; +use ui::ButtonVariant; +//pub use project_search::{ProjectSearchBar, ProjectSearchView}; +// use theme::components::{ +// action_button::Button, svg::Svg, ComponentExt, IconButtonStyle, ToggleIconButtonStyle, +// }; + +pub mod buffer_search; +mod history; +mod mode; +//pub mod project_search; +pub(crate) mod search_bar; + +pub fn init(cx: &mut AppContext) { + buffer_search::init(cx); + //project_search::init(cx); +} + +actions!( + CycleMode, + ToggleWholeWord, + ToggleCaseSensitive, + ToggleReplace, + SelectNextMatch, + SelectPrevMatch, + SelectAllMatches, + NextHistoryQuery, + PreviousHistoryQuery, + ActivateTextMode, + ActivateSemanticMode, + ActivateRegexMode, + ReplaceAll, + ReplaceNext, +); + +bitflags! { + #[derive(Default)] + pub struct SearchOptions: u8 { + const NONE = 0b000; + const WHOLE_WORD = 0b001; + const CASE_SENSITIVE = 0b010; + } +} + +impl SearchOptions { + pub fn label(&self) -> &'static str { + match *self { + SearchOptions::WHOLE_WORD => "Match Whole Word", + SearchOptions::CASE_SENSITIVE => "Match Case", + _ => panic!("{:?} is not a named SearchOption", self), + } + } + + pub fn icon(&self) -> ui::Icon { + match *self { + SearchOptions::WHOLE_WORD => ui::Icon::WholeWord, + SearchOptions::CASE_SENSITIVE => ui::Icon::CaseSensitive, + _ => panic!("{:?} is not a named SearchOption", self), + } + } + + pub fn to_toggle_action(&self) -> Box { + match *self { + SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord), + SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive), + _ => panic!("{:?} is not a named SearchOption", self), + } + } + + pub fn none() -> SearchOptions { + SearchOptions::NONE + } + + pub fn from_query(query: &SearchQuery) -> SearchOptions { + let mut options = SearchOptions::NONE; + options.set(SearchOptions::WHOLE_WORD, query.whole_word()); + options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive()); + options + } + + pub fn as_button(&self, active: bool) -> impl RenderOnce { + ui::IconButton::new(0, self.icon()) + .on_click({ + let action = self.to_toggle_action(); + move |_, cx| { + cx.dispatch_action(action.boxed_clone()); + } + }) + .variant(ui::ButtonVariant::Ghost) + .when(active, |button| button.variant(ButtonVariant::Filled)) + } +} + +fn toggle_replace_button(active: bool) -> impl RenderOnce { + // todo: add toggle_replace button + ui::IconButton::new(0, ui::Icon::Replace) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(ToggleReplace)); + cx.notify(); + }) + .variant(ui::ButtonVariant::Ghost) + .when(active, |button| button.variant(ButtonVariant::Filled)) +} + +fn render_replace_button( + action: impl Action + 'static + Send + Sync, + icon: ui::Icon, +) -> impl RenderOnce { + // todo: add tooltip + ui::IconButton::new(0, icon).on_click(move |_, cx| { + cx.dispatch_action(action.boxed_clone()); + }) +} diff --git a/crates/search2/src/search_bar.rs b/crates/search2/src/search_bar.rs new file mode 100644 index 0000000000000000000000000000000000000000..da097b43a66207b62fc914c1e11caf3c4eccd81e --- /dev/null +++ b/crates/search2/src/search_bar.rs @@ -0,0 +1,35 @@ +use gpui::{MouseDownEvent, RenderOnce, WindowContext}; +use ui::{Button, ButtonVariant, IconButton}; + +use crate::mode::SearchMode; + +pub(super) fn render_nav_button( + icon: ui::Icon, + _active: bool, + on_click: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static, +) -> impl RenderOnce { + // let tooltip_style = cx.theme().tooltip.clone(); + // let cursor_style = if active { + // CursorStyle::PointingHand + // } else { + // CursorStyle::default() + // }; + // enum NavButton {} + IconButton::new("search-nav-button", icon).on_click(on_click) +} + +pub(crate) fn render_search_mode_button( + mode: SearchMode, + is_active: bool, + on_click: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static, +) -> Button { + let button_variant = if is_active { + ButtonVariant::Filled + } else { + ButtonVariant::Ghost + }; + + Button::new(mode.label()) + .on_click(on_click) + .variant(button_variant) +} diff --git a/crates/settings2/src/settings_file.rs b/crates/settings2/src/settings_file.rs index fc4ad5882e25b6c452a412f107e7535000a6cff8..c28e281895771a398e8a214961687df5002b1ccd 100644 --- a/crates/settings2/src/settings_file.rs +++ b/crates/settings2/src/settings_file.rs @@ -77,6 +77,7 @@ pub fn handle_settings_file_changes( }); cx.spawn(move |mut cx| async move { while let Some(user_settings_content) = user_settings_file_rx.next().await { + eprintln!("settings file changed"); let result = cx.update_global(|store: &mut SettingsStore, cx| { store .set_user_settings(&user_settings_content, cx) diff --git a/crates/storybook2/src/stories/colors.rs b/crates/storybook2/src/stories/colors.rs index 4f8c54fa6fa67404453737f33a79113e018e346f..8a628a01da903b2c0bcbb2ab32b913e00d45670e 100644 --- a/crates/storybook2/src/stories/colors.rs +++ b/crates/storybook2/src/stories/colors.rs @@ -6,7 +6,7 @@ use ui::prelude::*; pub struct ColorsStory; impl Render for ColorsStory { - type Element = Div; + type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { let color_scales = default_color_scales(); @@ -28,7 +28,7 @@ impl Render for ColorsStory { div() .w(px(75.)) .line_height(px(24.)) - .child(scale.name().to_string()), + .child(scale.name().clone()), ) .child( div() diff --git a/crates/storybook2/src/stories/focus.rs b/crates/storybook2/src/stories/focus.rs index 571882f1f29c92eeed4e8ec56374879280bf27af..7ddeec08bfc27cd1de13b16714458be6534662dc 100644 --- a/crates/storybook2/src/stories/focus.rs +++ b/crates/storybook2/src/stories/focus.rs @@ -26,8 +26,8 @@ impl FocusStory { } } -impl Render for FocusStory { - type Element = Focusable>>; +impl Render for FocusStory { + type Element = Focusable>; fn render(&mut self, cx: &mut gpui::ViewContext) -> Self::Element { let theme = cx.theme(); @@ -42,18 +42,20 @@ impl Render for FocusStory { .id("parent") .focusable() .key_context("parent") - .on_action(|_, action: &ActionA, cx| { + .on_action(cx.listener(|_, action: &ActionA, cx| { println!("Action A dispatched on parent"); - }) - .on_action(|_, action: &ActionB, cx| { + })) + .on_action(cx.listener(|_, action: &ActionB, cx| { println!("Action B dispatched on parent"); - }) - .on_focus(|_, _, _| println!("Parent focused")) - .on_blur(|_, _, _| println!("Parent blurred")) - .on_focus_in(|_, _, _| println!("Parent focus_in")) - .on_focus_out(|_, _, _| println!("Parent focus_out")) - .on_key_down(|_, event, phase, _| println!("Key down on parent {:?}", event)) - .on_key_up(|_, event, phase, _| println!("Key up on parent {:?}", event)) + })) + .on_focus(cx.listener(|_, _, _| println!("Parent focused"))) + .on_blur(cx.listener(|_, _, _| println!("Parent blurred"))) + .on_focus_in(cx.listener(|_, _, _| println!("Parent focus_in"))) + .on_focus_out(cx.listener(|_, _, _| println!("Parent focus_out"))) + .on_key_down( + cx.listener(|_, event, phase, _| println!("Key down on parent {:?}", event)), + ) + .on_key_up(cx.listener(|_, event, phase, _| println!("Key up on parent {:?}", event))) .size_full() .bg(color_1) .focus(|style| style.bg(color_2)) @@ -61,38 +63,42 @@ impl Render for FocusStory { div() .track_focus(&self.child_1_focus) .key_context("child-1") - .on_action(|_, action: &ActionB, cx| { + .on_action(cx.listener(|_, action: &ActionB, cx| { println!("Action B dispatched on child 1 during"); - }) + })) .w_full() .h_6() .bg(color_4) .focus(|style| style.bg(color_5)) .in_focus(|style| style.bg(color_6)) - .on_focus(|_, _, _| println!("Child 1 focused")) - .on_blur(|_, _, _| println!("Child 1 blurred")) - .on_focus_in(|_, _, _| println!("Child 1 focus_in")) - .on_focus_out(|_, _, _| println!("Child 1 focus_out")) - .on_key_down(|_, event, phase, _| println!("Key down on child 1 {:?}", event)) - .on_key_up(|_, event, phase, _| println!("Key up on child 1 {:?}", event)) + .on_focus(cx.listener(|_, _, _| println!("Child 1 focused"))) + .on_blur(cx.listener(|_, _, _| println!("Child 1 blurred"))) + .on_focus_in(cx.listener(|_, _, _| println!("Child 1 focus_in"))) + .on_focus_out(cx.listener(|_, _, _| println!("Child 1 focus_out"))) + .on_key_down( + cx.listener(|_, event, _| println!("Key down on child 1 {:?}", event)), + ) + .on_key_up(cx.listener(|_, event, _| println!("Key up on child 1 {:?}", event))) .child("Child 1"), ) .child( div() .track_focus(&self.child_2_focus) .key_context("child-2") - .on_action(|_, action: &ActionC, cx| { + .on_action(cx.listener(|_, action: &ActionC, cx| { println!("Action C dispatched on child 2"); - }) + })) .w_full() .h_6() .bg(color_4) - .on_focus(|_, _, _| println!("Child 2 focused")) - .on_blur(|_, _, _| println!("Child 2 blurred")) - .on_focus_in(|_, _, _| println!("Child 2 focus_in")) - .on_focus_out(|_, _, _| println!("Child 2 focus_out")) - .on_key_down(|_, event, phase, _| println!("Key down on child 2 {:?}", event)) - .on_key_up(|_, event, phase, _| println!("Key up on child 2 {:?}", event)) + .on_focus(cx.listener(|_, _, _| println!("Child 2 focused"))) + .on_blur(cx.listener(|_, _, _| println!("Child 2 blurred"))) + .on_focus_in(cx.listener(|_, _, _| println!("Child 2 focus_in"))) + .on_focus_out(cx.listener(|_, _, _| println!("Child 2 focus_out"))) + .on_key_down( + cx.listener(|_, event, _| println!("Key down on child 2 {:?}", event)), + ) + .on_key_up(cx.listener(|_, event, _| println!("Key up on child 2 {:?}", event))) .child("Child 2"), ) } diff --git a/crates/storybook2/src/stories/kitchen_sink.rs b/crates/storybook2/src/stories/kitchen_sink.rs index 507aa8db2d5db7f0d24fd33aaacc34f04a700170..b59e00bf25d84fc7d0144fd020c60217dffcf7f3 100644 --- a/crates/storybook2/src/stories/kitchen_sink.rs +++ b/crates/storybook2/src/stories/kitchen_sink.rs @@ -12,7 +12,7 @@ impl KitchenSinkStory { } impl Render for KitchenSinkStory { - type Element = Stateful>; + type Element = Stateful
; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { let component_stories = ComponentStory::iter() diff --git a/crates/storybook2/src/stories/picker.rs b/crates/storybook2/src/stories/picker.rs index a3f9ef5eb82a018619c52f314c00d2578d4e9b7b..7c2412a02ff2cccd2316aa06e65a7b57a79541a5 100644 --- a/crates/storybook2/src/stories/picker.rs +++ b/crates/storybook2/src/stories/picker.rs @@ -1,5 +1,7 @@ use fuzzy::StringMatchCandidate; -use gpui::{div, prelude::*, Div, KeyBinding, Render, Styled, Task, View, WindowContext}; +use gpui::{ + div, prelude::*, Div, KeyBinding, Render, SharedString, Styled, Task, View, WindowContext, +}; use picker::{Picker, PickerDelegate}; use std::sync::Arc; use theme2::ActiveTheme; @@ -54,7 +56,8 @@ impl PickerDelegate for Delegate { let Some(candidate_ix) = self.matches.get(ix) else { return div(); }; - let candidate = self.candidates[*candidate_ix].string.clone(); + // TASK: Make StringMatchCandidate::string a SharedString + let candidate = SharedString::from(self.candidates[*candidate_ix].string.clone()); div() .text_color(colors.text) @@ -202,7 +205,7 @@ impl PickerStory { } } -impl Render for PickerStory { +impl Render for PickerStory { type Element = Div; fn render(&mut self, cx: &mut gpui::ViewContext) -> Self::Element { diff --git a/crates/storybook2/src/stories/scroll.rs b/crates/storybook2/src/stories/scroll.rs index f1bb7b4e7cacb05b0e2cd43a7eae3cfe3275092c..bbab0b1d110a33781447f500c14a01df0ea776e8 100644 --- a/crates/storybook2/src/stories/scroll.rs +++ b/crates/storybook2/src/stories/scroll.rs @@ -10,7 +10,7 @@ impl ScrollStory { } } -impl Render for ScrollStory { +impl Render for ScrollStory { type Element = Stateful>; fn render(&mut self, cx: &mut gpui::ViewContext) -> Self::Element { diff --git a/crates/storybook2/src/stories/text.rs b/crates/storybook2/src/stories/text.rs index 6fc76ab9073c770dba90626611c83313c7b6445a..c26e5fd3f1adc78aa3e9e8bf99f8c8f26666db78 100644 --- a/crates/storybook2/src/stories/text.rs +++ b/crates/storybook2/src/stories/text.rs @@ -1,4 +1,7 @@ -use gpui::{div, white, Div, ParentComponent, Render, Styled, View, VisualContext, WindowContext}; +use gpui::{ + blue, div, red, white, Div, ParentElement, Render, Styled, View, VisualContext, WindowContext, +}; +use ui::v_stack; pub struct TextStory; @@ -9,13 +12,49 @@ impl TextStory { } impl Render for TextStory { - type Element = Div; + type Element = Div; fn render(&mut self, cx: &mut gpui::ViewContext) -> Self::Element { - div().size_full().bg(white()).child(concat!( - "The quick brown fox jumps over the lazy dog. ", - "Meanwhile, the lazy dog decided it was time for a change. ", - "He started daily workout routines, ate healthier and became the fastest dog in town.", - )) + v_stack() + .bg(blue()) + .child( + div() + .flex() + .child(div().max_w_96().bg(white()).child(concat!( + "max-width: 96. The quick brown fox jumps over the lazy dog. ", + "Meanwhile, the lazy dog decided it was time for a change. ", + "He started daily workout routines, ate healthier and became the fastest dog in town.", + ))), + ) + .child(div().h_5()) + .child(div().flex().flex_col().w_96().bg(white()).child(concat!( + "flex-col. width: 96; The quick brown fox jumps over the lazy dog. ", + "Meanwhile, the lazy dog decided it was time for a change. ", + "He started daily workout routines, ate healthier and became the fastest dog in town.", + ))) + .child(div().h_5()) + .child( + div() + .flex() + .child(div().min_w_96().bg(white()).child(concat!( + "min-width: 96. The quick brown fox jumps over the lazy dog. ", + "Meanwhile, the lazy dog decided it was time for a change. ", + "He started daily workout routines, ate healthier and became the fastest dog in town.", +)))) + .child(div().h_5()) + .child(div().flex().w_96().bg(white()).child(div().overflow_hidden().child(concat!( + "flex-row. width 96. overflow-hidden. The quick brown fox jumps over the lazy dog. ", + "Meanwhile, the lazy dog decided it was time for a change. ", + "He started daily workout routines, ate healthier and became the fastest dog in town.", + )))) + // NOTE: When rendering text in a horizonal flex container, + // Taffy will not pass width constraints down from the parent. + // To fix this, render text in a praent with overflow: hidden, which + .child(div().h_5()) + .child(div().flex().w_96().bg(red()).child(concat!( + "flex-row. width 96. The quick brown fox jumps over the lazy dog. ", + "Meanwhile, the lazy dog decided it was time for a change. ", + "He started daily workout routines, ate healthier and became the fastest dog in town.", + ))) } } diff --git a/crates/storybook2/src/stories/z_index.rs b/crates/storybook2/src/stories/z_index.rs index 46ec0f4a3511ea2b3a3cc7999e203a62d35a01d6..087ed913fd711c3e101efb1d400cd7dacfbd2556 100644 --- a/crates/storybook2/src/stories/z_index.rs +++ b/crates/storybook2/src/stories/z_index.rs @@ -1,4 +1,4 @@ -use gpui::{px, rgb, Div, Hsla, Render}; +use gpui::{px, rgb, Div, Hsla, Render, RenderOnce}; use ui::prelude::*; use crate::story::Story; @@ -7,7 +7,7 @@ use crate::story::Story; /// [https://developer.mozilla.org/en-US/docs/Web/CSS/z-index](https://developer.mozilla.org/en-US/docs/Web/CSS/z-index). pub struct ZIndexStory; -impl Render for ZIndexStory { +impl Render for ZIndexStory { type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { @@ -79,17 +79,15 @@ trait Styles: Styled + Sized { impl Styles for Div {} -#[derive(Component)] +#[derive(RenderOnce)] struct ZIndexExample { z_index: u32, } -impl ZIndexExample { - pub fn new(z_index: u32) -> Self { - Self { z_index } - } +impl Component for ZIndexExample { + type Rendered = Div; - fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + fn render(self, view: &mut V, cx: &mut ViewContext) -> Self::Rendered { div() .relative() .size_full() @@ -109,14 +107,14 @@ impl ZIndexExample { // HACK: Simulate `text-align: center`. .pl(px(24.)) .z_index(self.z_index) - .child(format!( + .child(SharedString::from(format!( "z-index: {}", if self.z_index == 0 { "auto".to_string() } else { self.z_index.to_string() } - )), + ))), ) // Blue blocks. .child( @@ -173,3 +171,9 @@ impl ZIndexExample { ) } } + +impl ZIndexExample { + pub fn new(z_index: u32) -> Self { + Self { z_index } + } +} diff --git a/crates/storybook2/src/storybook2.rs b/crates/storybook2/src/storybook2.rs index a0bc7cd72f10e25fc68674071afce253582cacf9..2a22d91382c45cc2201725f35d32ee9cccc62297 100644 --- a/crates/storybook2/src/storybook2.rs +++ b/crates/storybook2/src/storybook2.rs @@ -105,7 +105,7 @@ impl StoryWrapper { } } -impl Render for StoryWrapper { +impl Render for StoryWrapper { type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { diff --git a/crates/storybook3/src/storybook3.rs b/crates/storybook3/src/storybook3.rs index 291f8ce2ac451ca7c49e72776fcd4b7411644c6d..cb64bd7f0dfabdf11724bf09cc0b6cff3846d34b 100644 --- a/crates/storybook3/src/storybook3.rs +++ b/crates/storybook3/src/storybook3.rs @@ -1,9 +1,9 @@ use anyhow::Result; -use gpui::AssetSource; use gpui::{ div, px, size, AnyView, Bounds, Div, Render, ViewContext, VisualContext, WindowBounds, WindowOptions, }; +use gpui::{white, AssetSource}; use settings::{default_settings, Settings, SettingsStore}; use std::borrow::Cow; use std::sync::Arc; @@ -56,18 +56,32 @@ fn main() { } struct TestView { + #[allow(unused)] story: AnyView, } -impl Render for TestView { +impl Render for TestView { type Element = Div; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { div() .flex() + .bg(gpui::blue()) .flex_col() .size_full() .font("Helvetica") - .child(self.story.clone()) + .child(div().h_5()) + .child( + div() + .flex() + .w_96() + .bg(white()) + .relative() + .child(div().child(concat!( + "The quick brown fox jumps over the lazy dog. ", + "Meanwhile, the lazy dog decided it was time for a change. ", + "He started daily workout routines, ate healthier and became the fastest dog in town.", + ))), + ) } } diff --git a/crates/terminal_view2/src/terminal_panel.rs b/crates/terminal_view2/src/terminal_panel.rs index 944cd912bebf57ff7153dcac7ee0452734632e1e..b6582b07b194331f0e33f7e5b6b70557bfa50cbc 100644 --- a/crates/terminal_view2/src/terminal_panel.rs +++ b/crates/terminal_view2/src/terminal_panel.rs @@ -4,7 +4,7 @@ use crate::TerminalView; use db::kvp::KEY_VALUE_STORE; use gpui::{ actions, div, serde_json, AppContext, AsyncWindowContext, Div, Entity, EventEmitter, - FocusHandle, FocusableView, ParentComponent, Render, Subscription, Task, View, ViewContext, + FocusHandle, FocusableView, ParentElement, Render, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use project::Fs; @@ -336,7 +336,7 @@ impl TerminalPanel { impl EventEmitter for TerminalPanel {} impl Render for TerminalPanel { - type Element = Div; + type Element = Div; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { div().child(self.pane.clone()) diff --git a/crates/terminal_view2/src/terminal_view.rs b/crates/terminal_view2/src/terminal_view.rs index b6ab7e86b9191fa6910e5632158ae0c587059c21..5a5f74f9e1c7ec3f52305f136c5589d61108bbdb 100644 --- a/crates/terminal_view2/src/terminal_view.rs +++ b/crates/terminal_view2/src/terminal_view.rs @@ -9,11 +9,10 @@ pub mod terminal_panel; // use crate::terminal_element::TerminalElement; use editor::{scroll::autoscroll::Autoscroll, Editor}; use gpui::{ - actions, div, img, red, Action, AnyElement, AppContext, Component, DispatchPhase, Div, - EventEmitter, FocusEvent, FocusHandle, Focusable, FocusableComponent, FocusableView, - InputHandler, InteractiveComponent, KeyDownEvent, Keystroke, Model, MouseButton, - ParentComponent, Pixels, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, - WeakView, + actions, div, Action, AnyElement, AppContext, Div, Element, EventEmitter, FocusEvent, + FocusHandle, Focusable, FocusableElement, FocusableView, InputHandler, InteractiveElement, + KeyDownEvent, Keystroke, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Render, + SharedString, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use language::Bias; use persistence::TERMINAL_DB; @@ -32,7 +31,7 @@ use workspace::{ notifications::NotifyResultExt, register_deserializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem}, - ui::{ContextMenu, Label}, + ui::{ContextMenu, Icon, IconElement, Label, ListItem}, CloseActiveItem, NewCenterTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, }; @@ -64,7 +63,6 @@ pub struct SendKeystroke(String); actions!(Clear, Copy, Paste, ShowCharacterPalette, SearchTest); pub fn init(cx: &mut AppContext) { - workspace::ui::init(cx); terminal_panel::init(cx); terminal::init(cx); @@ -300,11 +298,10 @@ impl TerminalView { position: gpui::Point, cx: &mut ViewContext, ) { - self.context_menu = Some(cx.build_view(|cx| { - ContextMenu::new(cx) - .entry(Label::new("Clear"), Box::new(Clear)) - .entry( - Label::new("Close"), + self.context_menu = Some(ContextMenu::build(cx, |menu, _| { + menu.action(ListItem::new("clear", Label::new("Clear")), Box::new(Clear)) + .action( + ListItem::new("close", Label::new("Close")), Box::new(CloseActiveItem { save_intent: None }), ) })); @@ -507,12 +504,7 @@ pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option, - ) { + fn key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext) { self.clear_bel(cx); self.pause_cursor_blinking(cx); @@ -540,7 +532,7 @@ impl TerminalView { } impl Render for TerminalView { - type Element = Focusable>; + type Element = Focusable
; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { let terminal_handle = self.terminal.clone().downgrade(); @@ -554,14 +546,14 @@ impl Render for TerminalView { div() .z_index(0) .absolute() - .on_key_down(Self::key_down) - .on_action(TerminalView::send_text) - .on_action(TerminalView::send_keystroke) - .on_action(TerminalView::copy) - .on_action(TerminalView::paste) - .on_action(TerminalView::clear) - .on_action(TerminalView::show_character_palette) - .on_action(TerminalView::select_all) + .on_key_down(cx.listener(Self::key_down)) + .on_action(cx.listener(TerminalView::send_text)) + .on_action(cx.listener(TerminalView::send_keystroke)) + .on_action(cx.listener(TerminalView::copy)) + .on_action(cx.listener(TerminalView::paste)) + .on_action(cx.listener(TerminalView::clear)) + .on_action(cx.listener(TerminalView::show_character_palette)) + .on_action(cx.listener(TerminalView::select_all)) // todo!() .child( "TERMINAL HERE", // TerminalElement::new( @@ -571,19 +563,22 @@ impl Render for TerminalView { // self.can_navigate_to_selected_word, // ) ) - .on_mouse_down(MouseButton::Right, |this, event, cx| { - this.deploy_context_menu(event.position, cx); - cx.notify(); - }), + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, cx| { + this.deploy_context_menu(event.position, cx); + cx.notify(); + }), + ), ) .children( self.context_menu .clone() - .map(|context_menu| div().z_index(1).absolute().child(context_menu.render())), + .map(|context_menu| div().z_index(1).absolute().child(context_menu)), ) .track_focus(&self.focus_handle) - .on_focus_in(Self::focus_in) - .on_focus_out(Self::focus_out) + .on_focus_in(cx.listener(Self::focus_in)) + .on_focus_out(cx.listener(Self::focus_out)) } } @@ -748,17 +743,13 @@ impl Item for TerminalView { Some(self.terminal().read(cx).title().into()) } - fn tab_content( - &self, - _detail: Option, - cx: &gpui::AppContext, - ) -> AnyElement { + fn tab_content(&self, _detail: Option, cx: &WindowContext) -> AnyElement { let title = self.terminal().read(cx).title(); div() - .child(img().uri("icons/terminal.svg").bg(red())) - .child(title) - .render() + .child(IconElement::new(Icon::Terminal)) + .child(Label::new(title)) + .into_any() } fn clone_on_split( @@ -794,7 +785,7 @@ impl Item for TerminalView { // } fn breadcrumb_location(&self) -> ToolbarItemLocation { - ToolbarItemLocation::PrimaryLeft { flex: None } + ToolbarItemLocation::PrimaryLeft } fn breadcrumbs(&self, _: &theme::Theme, cx: &AppContext) -> Option> { diff --git a/crates/theme2/src/settings.rs b/crates/theme2/src/settings.rs index 01951f2ed0adaef94933e6169f85a74a102201a4..15b578d4b0e10855d0368ce9d31167847ae8944b 100644 --- a/crates/theme2/src/settings.rs +++ b/crates/theme2/src/settings.rs @@ -184,7 +184,7 @@ impl settings::Settings for ThemeSettings { ) -> schemars::schema::RootSchema { let mut root_schema = generator.root_schema_for::(); let theme_names = cx - .global::>() + .global::() .list_names(params.staff_mode) .map(|theme_name| Value::String(theme_name.to_string())) .collect(); diff --git a/crates/theme2/src/story.rs b/crates/theme2/src/story.rs index 4296d4f99c4e2f4dfad0cbf1b60eef90efa3d0d0..5e484e12cd5268833bafa18442fffeca20c4af1e 100644 --- a/crates/theme2/src/story.rs +++ b/crates/theme2/src/story.rs @@ -1,11 +1,11 @@ -use gpui::{div, Component, Div, ParentComponent, Styled, ViewContext}; +use gpui::{div, Div, Element, ParentElement, SharedString, Styled, WindowContext}; use crate::ActiveTheme; pub struct Story {} impl Story { - pub fn container(cx: &mut ViewContext) -> Div { + pub fn container(cx: &mut WindowContext) -> Div { div() .size_full() .flex() @@ -16,23 +16,23 @@ impl Story { .bg(cx.theme().colors().background) } - pub fn title(cx: &mut ViewContext, title: &str) -> impl Component { + pub fn title(cx: &mut WindowContext, title: SharedString) -> impl Element { div() .text_xl() .text_color(cx.theme().colors().text) - .child(title.to_owned()) + .child(title) } - pub fn title_for(cx: &mut ViewContext) -> impl Component { - Self::title(cx, std::any::type_name::()) + pub fn title_for(cx: &mut WindowContext) -> impl Element { + Self::title(cx, std::any::type_name::().into()) } - pub fn label(cx: &mut ViewContext, label: &str) -> impl Component { + pub fn label(cx: &mut WindowContext, label: impl Into) -> impl Element { div() .mt_4() .mb_2() .text_xs() .text_color(cx.theme().colors().text) - .child(label.to_owned()) + .child(label.into()) } } diff --git a/crates/theme2/src/styles/players.rs b/crates/theme2/src/styles/players.rs index dfb0a6ff4eb448cf123e99c346cfe107a4b78318..a4734d1c00c7d1d63c5719585974c45f1bae887c 100644 --- a/crates/theme2/src/styles/players.rs +++ b/crates/theme2/src/styles/players.rs @@ -1,6 +1,6 @@ use gpui::Hsla; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default)] pub struct PlayerColor { pub cursor: Hsla, pub background: Hsla, @@ -143,12 +143,12 @@ use crate::{amber, blue, jade, lime, orange, pink, purple, red}; mod stories { use super::*; use crate::{ActiveTheme, Story}; - use gpui::{div, img, px, Div, ParentComponent, Render, Styled, ViewContext}; + use gpui::{div, img, px, Div, ParentElement, Render, Styled, ViewContext}; pub struct PlayerStory; impl Render for PlayerStory { - type Element = Div; + type Element = Div; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { Story::container(cx).child( @@ -156,7 +156,7 @@ mod stories { .flex() .flex_col() .gap_4() - .child(Story::title_for::<_, PlayerColors>(cx)) + .child(Story::title_for::(cx)) .child(Story::label(cx, "Player Colors")) .child( div() diff --git a/crates/theme2/src/theme2.rs b/crates/theme2/src/theme2.rs index 05e41ba368bdaf60616383e24f37df4445b07306..39c5924fb94bafe58cdc5a203104b8ddd6aac494 100644 --- a/crates/theme2/src/theme2.rs +++ b/crates/theme2/src/theme2.rs @@ -63,6 +63,12 @@ impl ActiveTheme for AppContext { } } +// impl<'a> ActiveTheme for WindowContext<'a> { +// fn theme(&self) -> &Arc { +// &ThemeSettings::get_global(self.app()).active_theme +// } +// } + pub struct ThemeFamily { pub id: String, pub name: SharedString, @@ -130,7 +136,7 @@ impl Theme { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub struct DiagnosticStyle { pub error: Hsla, pub warning: Hsla, diff --git a/crates/ui2/Cargo.toml b/crates/ui2/Cargo.toml index 0a7de6299d1f0999df813ced30902c039615f662..0fa277dc89933dc957975838957d887332b702be 100644 --- a/crates/ui2/Cargo.toml +++ b/crates/ui2/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2021" publish = false +[lib] +name = "ui2" +path = "src/ui2.rs" + [dependencies] anyhow.workspace = true chrono = "0.4" @@ -18,5 +22,5 @@ theme2 = { path = "../theme2" } rand = "0.8" [features] -default = ["stories"] +default = [] stories = ["dep:itertools"] diff --git a/crates/ui2/docs/hello-world.md b/crates/ui2/docs/hello-world.md index e8ed3bb9445464e310e22dbc40d9773bf419d2b3..f48dd460b83abcf9bd3a9ccb6528adef06ddf04d 100644 --- a/crates/ui2/docs/hello-world.md +++ b/crates/ui2/docs/hello-world.md @@ -49,13 +49,13 @@ use gpui::hsla impl TodoList { // ... - fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Element { div().size_4().bg(hsla(50.0/360.0, 1.0, 0.5, 1.0)) } } ~~~ -Every component needs a render method, and it should return `impl Component`. This basic component will render a 16x16px yellow square on the screen. +Every component needs a render method, and it should return `impl Element`. This basic component will render a 16x16px yellow square on the screen. A couple of questions might come to mind: @@ -87,7 +87,7 @@ We can access the current theme's colors like this: ~~~rust impl TodoList { // ... - fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Element { let color = cx.theme().colors() div().size_4().hsla(50.0/360.0, 1.0, 0.5, 1.0) @@ -102,7 +102,7 @@ use gpui::hsla impl TodoList { // ... - fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Element { let color = cx.theme().colors() div().size_4().bg(color.surface) @@ -117,7 +117,7 @@ use gpui::hsla impl TodoList { // ... - fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Element { let color = cx.theme().colors() div() diff --git a/crates/ui2/src/components.rs b/crates/ui2/src/components.rs index e7b2d9cf0f02929fef29ec5f41fffc401ad7fe2c..4d7e3c14b6b88456848938e9351e147a6fefbec8 100644 --- a/crates/ui2/src/components.rs +++ b/crates/ui2/src/components.rs @@ -2,56 +2,34 @@ mod avatar; mod button; mod checkbox; mod context_menu; -mod details; +mod disclosure; mod divider; -mod elevated_surface; -mod facepile; mod icon; mod icon_button; -mod indicator; mod input; mod keybinding; mod label; mod list; -mod modal; -mod notification_toast; -mod palette; -mod panel; -mod player; -mod player_stack; mod slot; mod stack; -mod tab; -mod toast; +mod stories; mod toggle; -mod tool_divider; mod tooltip; pub use avatar::*; pub use button::*; pub use checkbox::*; pub use context_menu::*; -pub use details::*; +pub use disclosure::*; pub use divider::*; -pub use elevated_surface::*; -pub use facepile::*; pub use icon::*; pub use icon_button::*; -pub use indicator::*; pub use input::*; pub use keybinding::*; pub use label::*; pub use list::*; -pub use modal::*; -pub use notification_toast::*; -pub use palette::*; -pub use panel::*; -pub use player::*; -pub use player_stack::*; pub use slot::*; pub use stack::*; -pub use tab::*; -pub use toast::*; +pub use stories::*; pub use toggle::*; -pub use tool_divider::*; pub use tooltip::*; diff --git a/crates/ui2/src/components/avatar.rs b/crates/ui2/src/components/avatar.rs index d083d8fd463e144e236e5c625caa92f237f3fce4..364a1454946d51970723938da672b21993f9484f 100644 --- a/crates/ui2/src/components/avatar.rs +++ b/crates/ui2/src/components/avatar.rs @@ -1,27 +1,23 @@ -use gpui::img; - use crate::prelude::*; +use gpui::{img, Img, RenderOnce}; + +#[derive(Debug, Default, PartialEq, Clone)] +pub enum Shape { + #[default] + Circle, + RoundedRectangle, +} -#[derive(Component)] +#[derive(RenderOnce)] pub struct Avatar { src: SharedString, shape: Shape, } -impl Avatar { - pub fn new(src: impl Into) -> Self { - Self { - src: src.into(), - shape: Shape::Circle, - } - } - - pub fn shape(mut self, shape: Shape) -> Self { - self.shape = shape; - self - } +impl Component for Avatar { + type Rendered = Img; - fn render(self, _view: &mut V, cx: &mut ViewContext) -> impl Component { + fn render(self, _: &mut WindowContext) -> Self::Rendered { let mut img = img(); if self.shape == Shape::Circle { @@ -37,30 +33,16 @@ impl Avatar { } } -#[cfg(feature = "stories")] -pub use stories::*; - -#[cfg(feature = "stories")] -mod stories { - use super::*; - use crate::Story; - use gpui::{Div, Render}; - - pub struct AvatarStory; - - impl Render for AvatarStory { - type Element = Div; - - fn render(&mut self, cx: &mut ViewContext) -> Self::Element { - Story::container(cx) - .child(Story::title_for::<_, Avatar>(cx)) - .child(Story::label(cx, "Default")) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/1714999?v=4", - )) - .child(Avatar::new( - "https://avatars.githubusercontent.com/u/326587?v=4", - )) +impl Avatar { + pub fn new(src: impl Into) -> Self { + Self { + src: src.into(), + shape: Shape::Circle, } } + + pub fn shape(mut self, shape: Shape) -> Self { + self.shape = shape; + self + } } diff --git a/crates/ui2/src/components/button.rs b/crates/ui2/src/components/button.rs index de055bcd5c3f185a7c049cb58eaa64f523fec9e5..a5176be52ea6b5fa85db6e7ebea943cae677d95b 100644 --- a/crates/ui2/src/components/button.rs +++ b/crates/ui2/src/components/button.rs @@ -1,25 +1,28 @@ -use std::sync::Arc; +use std::rc::Rc; -use gpui::{div, DefiniteLength, Hsla, MouseButton, StatefulInteractiveComponent, WindowContext}; +use gpui::{ + DefiniteLength, Div, Hsla, MouseButton, MouseDownEvent, RenderOnce, StatefulInteractiveElement, + WindowContext, +}; use crate::prelude::*; -use crate::{h_stack, Icon, IconButton, IconElement, Label, LineHeightStyle, TextColor}; +use crate::{h_stack, Color, Icon, IconButton, IconElement, Label, LineHeightStyle}; /// Provides the flexibility to use either a standard /// button or an icon button in a given context. -pub enum ButtonOrIconButton { - Button(Button), - IconButton(IconButton), +pub enum ButtonOrIconButton { + Button(Button), + IconButton(IconButton), } -impl From> for ButtonOrIconButton { - fn from(value: Button) -> Self { +impl From