diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9ef1531e7..009fcc8337 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -771,7 +771,7 @@ jobs: timeout-minutes: 120 name: Create a Windows installer runs-on: [self-hosted, Windows, X64] - if: false && (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling')) + if: (startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling')) needs: [windows_tests] env: AZURE_TENANT_ID: ${{ secrets.AZURE_SIGNING_TENANT_ID }} diff --git a/Cargo.lock b/Cargo.lock index f68136d978..f4c328c957 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "assistant_tools", "chrono", "client", + "cloud_llm_client", "collections", "component", "context_server", @@ -132,7 +133,6 @@ dependencies = [ "uuid", "workspace", "workspace-hack", - "zed_llm_client", "zstd", ] @@ -187,6 +187,7 @@ name = "agent_settings" version = "0.1.0" dependencies = [ "anyhow", + "cloud_llm_client", "collections", "fs", "gpui", @@ -198,7 +199,6 @@ dependencies = [ "serde_json_lenient", "settings", "workspace-hack", - "zed_llm_client", ] [[package]] @@ -221,6 +221,7 @@ dependencies = [ "buffer_diff", "chrono", "client", + "cloud_llm_client", "collections", "command_palette_hooks", "component", @@ -292,7 +293,6 @@ dependencies = [ "workspace", "workspace-hack", "zed_actions", - "zed_llm_client", ] [[package]] @@ -685,6 +685,7 @@ dependencies = [ "chrono", "client", "clock", + "cloud_llm_client", "collections", "context_server", "fs", @@ -718,7 +719,6 @@ dependencies = [ "uuid", "workspace", "workspace-hack", - "zed_llm_client", ] [[package]] @@ -826,6 +826,7 @@ dependencies = [ "chrono", "client", "clock", + "cloud_llm_client", "collections", "component", "derive_more 0.99.19", @@ -879,7 +880,6 @@ dependencies = [ "which 6.0.3", "workspace", "workspace-hack", - "zed_llm_client", "zlog", ] @@ -2974,6 +2974,7 @@ dependencies = [ "base64 0.22.1", "chrono", "clock", + "cloud_llm_client", "cocoa 0.26.0", "collections", "credentials_provider", @@ -3016,7 +3017,6 @@ dependencies = [ "windows 0.61.1", "workspace-hack", "worktree", - "zed_llm_client", ] [[package]] @@ -3029,6 +3029,19 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cloud_llm_client" +version = "0.1.0" +dependencies = [ + "anyhow", + "pretty_assertions", + "serde", + "serde_json", + "strum 0.27.1", + "uuid", + "workspace-hack", +] + [[package]] name = "clru" version = "0.6.2" @@ -3155,6 +3168,7 @@ dependencies = [ "chrono", "client", "clock", + "cloud_llm_client", "collab_ui", "collections", "command_palette_hooks", @@ -3241,7 +3255,6 @@ dependencies = [ "workspace", "workspace-hack", "worktree", - "zed_llm_client", "zlog", ] @@ -3682,17 +3695,6 @@ dependencies = [ "libm", ] -[[package]] -name = "coreaudio-rs" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" -dependencies = [ - "bitflags 1.3.2", - "core-foundation-sys", - "coreaudio-sys", -] - [[package]] name = "coreaudio-rs" version = "0.12.1" @@ -3750,29 +3752,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cpal" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" -dependencies = [ - "alsa", - "core-foundation-sys", - "coreaudio-rs 0.11.3", - "dasp_sample", - "jni", - "js-sys", - "libc", - "mach2", - "ndk 0.8.0", - "ndk-context", - "oboe", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows 0.54.0", -] - [[package]] name = "cpal" version = "0.16.0" @@ -3786,7 +3765,7 @@ dependencies = [ "js-sys", "libc", "mach2", - "ndk 0.9.0", + "ndk", "ndk-context", "num-derive", "num-traits", @@ -4790,7 +4769,6 @@ name = "docs_preprocessor" version = "0.1.0" dependencies = [ "anyhow", - "clap", "command_palette", "gpui", "mdbook", @@ -4801,6 +4779,7 @@ dependencies = [ "util", "workspace-hack", "zed", + "zlog", ] [[package]] @@ -5261,6 +5240,7 @@ dependencies = [ "chrono", "clap", "client", + "cloud_llm_client", "collections", "debug_adapter_extension", "dirs 4.0.0", @@ -5300,7 +5280,6 @@ dependencies = [ "uuid", "watch", "workspace-hack", - "zed_llm_client", ] [[package]] @@ -5365,6 +5344,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "extension" version = "0.1.0" @@ -6376,6 +6361,7 @@ dependencies = [ "call", "chrono", "client", + "cloud_llm_client", "collections", "command_palette_hooks", "component", @@ -6418,7 +6404,6 @@ dependencies = [ "workspace", "workspace-hack", "zed_actions", - "zed_llm_client", "zlog", ] @@ -7380,8 +7365,9 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-cursor", - "wayland-protocols", + "wayland-protocols 0.31.2", "wayland-protocols-plasma", + "wayland-protocols-wlr", "windows 0.61.1", "windows-core 0.61.0", "windows-numerics", @@ -7740,12 +7726,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - [[package]] name = "html5ever" version = "0.27.0" @@ -8384,6 +8364,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "cloud_llm_client", "copilot", "editor", "feature_flags", @@ -8406,7 +8387,6 @@ dependencies = [ "workspace", "workspace-hack", "zed_actions", - "zed_llm_client", "zeta", ] @@ -9088,6 +9068,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "client", + "cloud_llm_client", "collections", "futures 0.3.31", "gpui", @@ -9105,7 +9086,6 @@ dependencies = [ "thiserror 2.0.12", "util", "workspace-hack", - "zed_llm_client", ] [[package]] @@ -9121,6 +9101,7 @@ dependencies = [ "bedrock", "chrono", "client", + "cloud_llm_client", "collections", "component", "convert_case 0.8.0", @@ -9162,7 +9143,6 @@ dependencies = [ "vercel", "workspace-hack", "x_ai", - "zed_llm_client", ] [[package]] @@ -9593,7 +9573,7 @@ dependencies = [ "core-foundation 0.10.0", "core-video", "coreaudio-rs 0.12.1", - "cpal 0.16.0", + "cpal", "futures 0.3.31", "gpui", "gpui_tokio", @@ -10364,20 +10344,6 @@ dependencies = [ "workspace-hack", ] -[[package]] -name = "ndk" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" -dependencies = [ - "bitflags 2.9.0", - "jni-sys", - "log", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", -] - [[package]] name = "ndk" version = "0.9.0" @@ -10387,7 +10353,7 @@ dependencies = [ "bitflags 2.9.0", "jni-sys", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum", "thiserror 1.0.69", ] @@ -10398,15 +10364,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -10976,29 +10933,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "oboe" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" -dependencies = [ - "jni", - "ndk 0.8.0", - "ndk-context", - "num-derive", - "num-traits", - "oboe-sys", -] - -[[package]] -name = "oboe-sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" -dependencies = [ - "cc", -] - [[package]] name = "ollama" version = "0.1.0" @@ -11019,9 +10953,12 @@ dependencies = [ "anyhow", "command_palette_hooks", "db", + "editor", "feature_flags", "fs", "gpui", + "language", + "project", "settings", "theme", "ui", @@ -13778,12 +13715,15 @@ dependencies = [ [[package]] name = "rodio" -version = "0.20.1" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +checksum = "e40ecf59e742e03336be6a3d53755e789fd05a059fa22dfa0ed624722319e183" dependencies = [ - "cpal 0.15.3", - "hound", + "cpal", + "dasp_sample", + "num-rational", + "symphonia", + "tracing", ] [[package]] @@ -15804,6 +15744,66 @@ dependencies = [ "zeno", ] +[[package]] +name = "symphonia" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" +dependencies = [ + "lazy_static", + "symphonia-codec-pcm", + "symphonia-core", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-core" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "798306779e3dc7d5231bd5691f5a813496dc79d3f56bf82e25789f2094e022c3" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc622b9841a10089c5b18e99eb904f4341615d5aa55bbf4eedde1be721a4023c" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + [[package]] name = "syn" version = "1.0.109" @@ -18384,9 +18384,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7208998eaa3870dad37ec8836979581506e0c5c64c20c9e79e9d2a10d6f47bf" +checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" dependencies = [ "cc", "downcast-rs", @@ -18398,9 +18398,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.8" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2120de3d33638aaef5b9f4472bff75f07c56379cf76ea320bd3a3d65ecaf73f" +checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" dependencies = [ "bitflags 2.9.0", "rustix 0.38.44", @@ -18431,6 +18431,18 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +dependencies = [ + "bitflags 2.9.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.2.0" @@ -18440,7 +18452,20 @@ dependencies = [ "bitflags 2.9.0", "wayland-backend", "wayland-client", - "wayland-protocols", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +dependencies = [ + "bitflags 2.9.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.8", "wayland-scanner", ] @@ -18504,11 +18529,11 @@ name = "web_search" version = "0.1.0" dependencies = [ "anyhow", + "cloud_llm_client", "collections", "gpui", "serde", "workspace-hack", - "zed_llm_client", ] [[package]] @@ -18517,6 +18542,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "cloud_llm_client", "futures 0.3.31", "gpui", "http_client", @@ -18525,7 +18551,6 @@ dependencies = [ "serde_json", "web_search", "workspace-hack", - "zed_llm_client", ] [[package]] @@ -19691,14 +19716,12 @@ dependencies = [ "cc", "chrono", "cipher", - "clang-sys", "clap", "clap_builder", "codespan-reporting 0.12.0", "concurrent-queue", "core-foundation 0.9.4", "core-foundation-sys", - "coreaudio-sys", "cranelift-codegen", "crc32fast", "crossbeam-epoch", @@ -20355,7 +20378,7 @@ dependencies = [ [[package]] name = "zed_emmet" -version = "0.0.3" +version = "0.0.4" dependencies = [ "zed_extension_api 0.1.0", ] @@ -20394,19 +20417,6 @@ dependencies = [ "zed_extension_api 0.1.0", ] -[[package]] -name = "zed_llm_client" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6607f74dee2a18a9ce0f091844944a0e59881359ab62e0768fb0618f55d4c1dc" -dependencies = [ - "anyhow", - "serde", - "serde_json", - "strum 0.27.1", - "uuid", -] - [[package]] name = "zed_proto" version = "0.2.2" @@ -20586,6 +20596,7 @@ dependencies = [ "call", "client", "clock", + "cloud_llm_client", "collections", "command_palette_hooks", "copilot", @@ -20627,7 +20638,6 @@ dependencies = [ "workspace-hack", "worktree", "zed_actions", - "zed_llm_client", "zlog", ] diff --git a/Cargo.toml b/Cargo.toml index d733f2242e..df1e1d7467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/cli", "crates/client", "crates/clock", + "crates/cloud_llm_client", "crates/collab", "crates/collab_ui", "crates/collections", @@ -70,7 +71,6 @@ members = [ "crates/gpui", "crates/gpui_macros", "crates/gpui_tokio", - "crates/html_to_markdown", "crates/http_client", "crates/http_client_tls", @@ -251,6 +251,7 @@ channel = { path = "crates/channel" } cli = { path = "crates/cli" } client = { path = "crates/client" } clock = { path = "crates/clock" } +cloud_llm_client = { path = "crates/cloud_llm_client" } collab = { path = "crates/collab" } collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections" } @@ -645,7 +646,6 @@ which = "6.0.0" windows-core = "0.61" wit-component = "0.221" workspace-hack = "0.1.0" -zed_llm_client = "= 0.8.6" zstd = "0.11" [workspace.dependencies.async-stripe] diff --git a/README.md b/README.md index 4c794efc3d..38547c1ca4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Zed +[![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev) [![CI](https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/ci.yml) Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter). diff --git a/assets/badge/v0.json b/assets/badge/v0.json new file mode 100644 index 0000000000..c7d18bb42b --- /dev/null +++ b/assets/badge/v0.json @@ -0,0 +1,8 @@ +{ + "label": "", + "message": "Zed", + "logoSvg": "", + "logoWidth": 16, + "labelColor": "black", + "color": "white" +} diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index a4f812b2fc..e36e093e22 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -495,7 +495,7 @@ "shift-f12": "editor::GoToImplementation", "alt-ctrl-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", - "ctrl-m": "editor::MoveToEnclosingBracket", + "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains "ctrl-|": "editor::MoveToEnclosingBracket", "ctrl-{": "editor::Fold", "ctrl-}": "editor::UnfoldLines", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index eded8c73e6..0114e2da1d 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -549,7 +549,7 @@ "alt-cmd-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", "cmd-|": "editor::MoveToEnclosingBracket", - "ctrl-m": "editor::MoveToEnclosingBracket", + "ctrl-m": "editor::MoveToEnclosingBracket", // From Jetbrains "alt-cmd-[": "editor::Fold", "alt-cmd-]": "editor::UnfoldLines", "cmd-k cmd-l": "editor::ToggleFold", diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 3b9f0842bd..8aa07da330 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1594,6 +1594,8 @@ mod tests { name: "test", connection, child_status: io_task, + current_thread: thread_rc, + agent_state: Default::default(), }; AcpThread::new( diff --git a/crates/acp_thread/src/old_acp_support.rs b/crates/acp_thread/src/old_acp_support.rs index 4d06f81d06..718ad0da03 100644 --- a/crates/acp_thread/src/old_acp_support.rs +++ b/crates/acp_thread/src/old_acp_support.rs @@ -13,6 +13,7 @@ use std::{ rc::Rc, }; use ui::App; +use util::ResultExt as _; use crate::{AcpThread, AgentConnection}; @@ -52,7 +53,7 @@ impl acp_old::Client for OldAcpClientDelegate { thread.push_assistant_content_block(thought.into(), true, cx) } }) - .ok(); + .log_err(); })?; Ok(()) @@ -371,6 +372,7 @@ pub struct OldAcpAgentConnection { pub connection: acp_old::AgentConnection, pub child_status: Task>, pub agent_state: Rc>, + pub current_thread: Rc>>, } impl AgentConnection for OldAcpAgentConnection { @@ -386,6 +388,7 @@ impl AgentConnection for OldAcpAgentConnection { } .into_any(), ); + let current_thread = self.current_thread.clone(); cx.spawn(async move |cx| { let result = task.await?; let result = acp_old::InitializeParams::response_from_any(result)?; @@ -399,6 +402,7 @@ impl AgentConnection for OldAcpAgentConnection { let session_id = acp::SessionId("acp-old-no-id".into()); AcpThread::new("Gemini", self.clone(), project, session_id, cx) }); + current_thread.replace(thread.downgrade()); thread }) }) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 135363ab65..c89a7f3303 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -25,6 +25,7 @@ assistant_context.workspace = true assistant_tool.workspace = true chrono.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true component.workspace = true context_server.workspace = true @@ -35,9 +36,9 @@ futures.workspace = true git.workspace = true gpui.workspace = true heed.workspace = true +http_client.workspace = true icons.workspace = true indoc.workspace = true -http_client.workspace = true itertools.workspace = true language.workspace = true language_model.workspace = true @@ -63,7 +64,6 @@ time.workspace = true util.workspace = true uuid.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true zstd.workspace = true [dev-dependencies] diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 1b8aa012a1..0e5da2d43b 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -13,6 +13,7 @@ use anyhow::{Result, anyhow}; use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet}; use chrono::{DateTime, Utc}; use client::{ModelRequestUsage, RequestUsage}; +use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; use collections::HashMap; use feature_flags::{self, FeatureFlagAppExt}; use futures::{FutureExt, StreamExt as _, future::Shared}; @@ -49,7 +50,6 @@ use std::{ use thiserror::Error; use util::{ResultExt as _, post_inc}; use uuid::Uuid; -use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; const MAX_RETRY_ATTEMPTS: u8 = 4; const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); @@ -1681,7 +1681,7 @@ impl Thread { let completion_mode = request .mode - .unwrap_or(zed_llm_client::CompletionMode::Normal); + .unwrap_or(cloud_llm_client::CompletionMode::Normal); self.last_received_chunk_at = Some(Instant::now()); diff --git a/crates/agent_servers/src/acp_connection.rs b/crates/agent_servers/src/acp_connection.rs index 96067fe520..95c09e2c52 100644 --- a/crates/agent_servers/src/acp_connection.rs +++ b/crates/agent_servers/src/acp_connection.rs @@ -32,6 +32,7 @@ impl AcpConnection { pub async fn stdio( server_name: &'static str, command: AgentServerCommand, + working_directory: Option>, cx: &mut AsyncApp, ) -> Result { let client: Arc = ContextServer::stdio( @@ -41,6 +42,7 @@ impl AcpConnection { args: command.args, env: command.env, }, + working_directory, ) .into(); ContextServer::start(client.clone(), cx).await?; diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index 06d8d10a91..3e774ed83e 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -38,6 +38,7 @@ impl AgentServer for Codex { ) -> Task>> { let project = project.clone(); let server_name = self.name(); + let working_directory = project.read(cx).active_project_directory(cx); cx.spawn(async move |cx| { let settings = cx.read_global(|settings: &SettingsStore, _| { settings.get::(None).codex.clone() @@ -50,7 +51,7 @@ impl AgentServer for Codex { }; // todo! check supported version - let conn = AcpConnection::stdio(server_name, command, cx).await?; + let conn = AcpConnection::stdio(server_name, command, working_directory, cx).await?; Ok(Rc::new(conn) as _) }) } @@ -70,7 +71,7 @@ pub(crate) mod tests { AgentServerCommand { path: cli_path, - args: vec!["mcp".into()], + args: vec![], env: None, } } diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index aca9001c79..e9c72eabc9 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -12,7 +12,6 @@ use futures::{FutureExt, StreamExt, channel::mpsc, select}; use gpui::{Entity, TestAppContext}; use indoc::indoc; use project::{FakeFs, Project}; -use serde_json::json; use settings::{Settings, SettingsStore}; use util::path; @@ -27,7 +26,11 @@ pub async fn test_basic(server: impl AgentServer + 'static, cx: &mut TestAppCont .unwrap(); thread.read_with(cx, |thread, _| { - assert_eq!(thread.entries().len(), 2); + assert!( + thread.entries().len() >= 2, + "Expected at least 2 entries. Got: {:?}", + thread.entries() + ); assert!(matches!( thread.entries()[0], AgentThreadEntry::UserMessage(_) @@ -108,19 +111,19 @@ pub async fn test_path_mentions(server: impl AgentServer + 'static, cx: &mut Tes } pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestAppContext) { - let fs = init_test(cx).await; - fs.insert_tree( - path!("/private/tmp"), - json!({"foo": "Lorem ipsum dolor", "bar": "bar", "baz": "baz"}), - ) - .await; - let project = Project::test(fs, [path!("/private/tmp").as_ref()], cx).await; + let _fs = init_test(cx).await; + + let tempdir = tempfile::tempdir().unwrap(); + let foo_path = tempdir.path().join("foo"); + std::fs::write(&foo_path, "Lorem ipsum dolor").expect("failed to write file"); + + let project = Project::example([tempdir.path()], &mut cx.to_async()).await; let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; thread .update(cx, |thread, cx| { thread.send_raw( - "Read the '/private/tmp/foo' file and tell me what you see.", + &format!("Read {} and tell me what you see.", foo_path.display()), cx, ) }) @@ -143,6 +146,8 @@ pub async fn test_tool_call(server: impl AgentServer + 'static, cx: &mut TestApp .any(|entry| { matches!(entry, AgentThreadEntry::AssistantMessage(_)) }) ); }); + + drop(tempdir); } pub async fn test_tool_call_with_confirmation( @@ -155,7 +160,7 @@ pub async fn test_tool_call_with_confirmation( let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; let full_turn = thread.update(cx, |thread, cx| { thread.send_raw( - r#"Run `touch hello.txt && echo "Hello, world!" | tee hello.txt`"#, + r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, cx, ) }); @@ -175,10 +180,10 @@ pub async fn test_tool_call_with_confirmation( ) .await; - let tool_call_id = thread.read_with(cx, |thread, _cx| { + let tool_call_id = thread.read_with(cx, |thread, cx| { let AgentThreadEntry::ToolCall(ToolCall { id, - content, + label, status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) = &thread @@ -190,7 +195,8 @@ pub async fn test_tool_call_with_confirmation( panic!(); }; - assert!(content.iter().any(|c| c.to_markdown(_cx).contains("touch"))); + let label = label.read(cx).source(); + assert!(label.contains("touch"), "Got: {}", label); id.clone() }); @@ -242,7 +248,7 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon let thread = new_test_thread(server, project.clone(), "/private/tmp", cx).await; let full_turn = thread.update(cx, |thread, cx| { thread.send_raw( - r#"Run `touch hello.txt && echo "Hello, world!" >> hello.txt`"#, + r#"Run exactly `touch hello.txt && echo "Hello, world!" | tee hello.txt` in the terminal."#, cx, ) }); @@ -262,10 +268,10 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon ) .await; - thread.read_with(cx, |thread, _cx| { + thread.read_with(cx, |thread, cx| { let AgentThreadEntry::ToolCall(ToolCall { id, - content, + label, status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) = &thread.entries()[first_tool_call_ix] @@ -273,7 +279,8 @@ pub async fn test_cancel(server: impl AgentServer + 'static, cx: &mut TestAppCon panic!("{:?}", thread.entries()[1]); }; - assert!(content.iter().any(|c| c.to_markdown(_cx).contains("touch"))); + let label = label.read(cx).source(); + assert!(label.contains("touch"), "Got: {}", label); id.clone() }); diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 07c4e1b539..9b7fde42bf 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -41,6 +41,7 @@ impl AgentServer for Gemini { ) -> Task>> { let project = project.clone(); let server_name = self.name(); + let working_directory = project.read(cx).active_project_directory(cx); cx.spawn(async move |cx| { let settings = cx.read_global(|settings: &SettingsStore, _| { settings.get::(None).gemini.clone() @@ -53,7 +54,7 @@ impl AgentServer for Gemini { }; // todo! check supported version - let conn = AcpConnection::stdio(server_name, command, cx).await?; + let conn = AcpConnection::stdio(server_name, command, working_directory, cx).await?; Ok(Rc::new(conn) as _) }) } diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 3afe5ae547..d34396a5d3 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -13,6 +13,7 @@ path = "src/agent_settings.rs" [dependencies] anyhow.workspace = true +cloud_llm_client.workspace = true collections.workspace = true gpui.workspace = true language_model.workspace = true @@ -20,7 +21,6 @@ schemars.workspace = true serde.workspace = true settings.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true [dev-dependencies] fs.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 13b966608c..4e872c78d7 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -321,11 +321,11 @@ pub enum CompletionMode { Burn, } -impl From for zed_llm_client::CompletionMode { +impl From for cloud_llm_client::CompletionMode { fn from(value: CompletionMode) -> Self { match value { - CompletionMode::Normal => zed_llm_client::CompletionMode::Normal, - CompletionMode::Burn => zed_llm_client::CompletionMode::Max, + CompletionMode::Normal => cloud_llm_client::CompletionMode::Normal, + CompletionMode::Burn => cloud_llm_client::CompletionMode::Max, } } } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index fbd53e8d09..95fd2b1757 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -31,6 +31,7 @@ audio.workspace = true buffer_diff.workspace = true chrono.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true command_palette_hooks.workspace = true component.workspace = true @@ -46,9 +47,9 @@ futures.workspace = true fuzzy.workspace = true gpui.workspace = true html_to_markdown.workspace = true -indoc.workspace = true http_client.workspace = true indexed_docs.workspace = true +indoc.workspace = true inventory.workspace = true itertools.workspace = true jsonschema.workspace = true @@ -97,7 +98,6 @@ watch.workspace = true workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true -zed_llm_client.workspace = true [dev-dependencies] assistant_tools.workspace = true diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index e27c318221..04a093c7d0 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -14,6 +14,7 @@ use agent_settings::{AgentSettings, NotifyWhenAgentWaiting}; use anyhow::Context as _; use assistant_tool::ToolUseStatus; use audio::{Audio, Sound}; +use cloud_llm_client::CompletionIntent; use collections::{HashMap, HashSet}; use editor::actions::{MoveUp, Paste}; use editor::scroll::Autoscroll; @@ -52,7 +53,6 @@ use util::ResultExt as _; use util::markdown::MarkdownCodeBlock; use workspace::{CollaboratorId, Workspace}; use zed_actions::assistant::OpenRulesLibrary; -use zed_llm_client::CompletionIntent; const CODEBLOCK_CONTAINER_GROUP: &str = "codeblock_container"; const EDIT_PREVIOUS_MESSAGE_MIN_LINES: usize = 1; diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 61a65de50b..91217cb030 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -44,6 +44,7 @@ use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; use client::{DisableAiSettings, UserStore, zed_urls}; +use cloud_llm_client::{CompletionIntent, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use feature_flags::{self, FeatureFlagAppExt}; use fs::Fs; @@ -80,7 +81,6 @@ use zed_actions::{ agent::{OpenConfiguration, OpenOnboardingModal, ResetOnboarding, ToggleModelSelector}, assistant::{OpenRulesLibrary, ToggleFocus}, }; -use zed_llm_client::{CompletionIntent, UsageLimit}; const AGENT_PANEL_KEY: &str = "agent_panel"; diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 64498e9281..615142b73d 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -6,6 +6,7 @@ use agent::{ use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; use client::telemetry::Telemetry; +use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; use futures::{ @@ -35,7 +36,6 @@ use std::{ }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; -use zed_llm_client::CompletionIntent; pub struct BufferCodegen { alternatives: Vec>, diff --git a/crates/agent_ui/src/debug.rs b/crates/agent_ui/src/debug.rs index ff6538dc85..bd34659210 100644 --- a/crates/agent_ui/src/debug.rs +++ b/crates/agent_ui/src/debug.rs @@ -1,10 +1,10 @@ #![allow(unused, dead_code)] use client::{ModelRequestUsage, RequestUsage}; +use cloud_llm_client::{Plan, UsageLimit}; use gpui::Global; use std::ops::{Deref, DerefMut}; use ui::prelude::*; -use zed_llm_client::{Plan, UsageLimit}; /// Debug only: Used for testing various account states /// diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index c160f1de04..082d1dfb51 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -18,6 +18,7 @@ use agent_settings::{AgentSettings, CompletionMode}; use ai_onboarding::ApiKeysWithProviders; use buffer_diff::BufferDiff; use client::UserStore; +use cloud_llm_client::CompletionIntent; use collections::{HashMap, HashSet}; use editor::actions::{MoveUp, Paste}; use editor::display_map::CreaseId; @@ -53,7 +54,6 @@ use util::ResultExt as _; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::Chat; use zed_actions::agent::ToggleModelSelector; -use zed_llm_client::CompletionIntent; use crate::context_picker::{ContextPicker, ContextPickerCompletionProvider, crease_for_mention}; use crate::context_strip::{ContextStrip, ContextStripEvent, SuggestContextKind}; @@ -1300,11 +1300,11 @@ impl MessageEditor { let plan = user_store .current_plan() .map(|plan| match plan { - Plan::Free => zed_llm_client::Plan::ZedFree, - Plan::ZedPro => zed_llm_client::Plan::ZedPro, - Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, + Plan::Free => cloud_llm_client::Plan::ZedFree, + Plan::ZedPro => cloud_llm_client::Plan::ZedPro, + Plan::ZedProTrial => cloud_llm_client::Plan::ZedProTrial, }) - .unwrap_or(zed_llm_client::Plan::ZedFree); + .unwrap_or(cloud_llm_client::Plan::ZedFree); let usage = user_store.model_request_usage()?; diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 91867957cd..bcbc308c99 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -10,6 +10,7 @@ use agent::{ use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; use client::telemetry::Telemetry; +use cloud_llm_client::CompletionIntent; use collections::{HashMap, VecDeque}; use editor::{MultiBuffer, actions::SelectAll}; use fs::Fs; @@ -27,7 +28,6 @@ use terminal_view::TerminalView; use ui::prelude::*; use util::ResultExt; use workspace::{Toast, Workspace, notifications::NotificationId}; -use zed_llm_client::CompletionIntent; pub fn init( fs: Arc, diff --git a/crates/agent_ui/src/ui/preview/usage_callouts.rs b/crates/agent_ui/src/ui/preview/usage_callouts.rs index 45af41395b..64869a6ec7 100644 --- a/crates/agent_ui/src/ui/preview/usage_callouts.rs +++ b/crates/agent_ui/src/ui/preview/usage_callouts.rs @@ -1,8 +1,8 @@ use client::{ModelRequestUsage, RequestUsage, zed_urls}; +use cloud_llm_client::{Plan, UsageLimit}; use component::{empty_example, example_group_with_title, single_example}; use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; use ui::{Callout, prelude::*}; -use zed_llm_client::{Plan, UsageLimit}; #[derive(IntoElement, RegisterComponent)] pub struct UsageCallout { diff --git a/crates/assistant_context/Cargo.toml b/crates/assistant_context/Cargo.toml index f35dc43340..8f5ff98790 100644 --- a/crates/assistant_context/Cargo.toml +++ b/crates/assistant_context/Cargo.toml @@ -19,6 +19,7 @@ assistant_slash_commands.workspace = true chrono.workspace = true client.workspace = true clock.workspace = true +cloud_llm_client.workspace = true collections.workspace = true context_server.workspace = true fs.workspace = true @@ -48,7 +49,6 @@ util.workspace = true uuid.workspace = true workspace-hack.workspace = true workspace.workspace = true -zed_llm_client.workspace = true [dev-dependencies] indoc.workspace = true diff --git a/crates/assistant_context/src/assistant_context.rs b/crates/assistant_context/src/assistant_context.rs index 136468e084..4518bbff79 100644 --- a/crates/assistant_context/src/assistant_context.rs +++ b/crates/assistant_context/src/assistant_context.rs @@ -11,6 +11,7 @@ use assistant_slash_command::{ use assistant_slash_commands::FileCommandMetadata; use client::{self, Client, proto, telemetry::Telemetry}; use clock::ReplicaId; +use cloud_llm_client::CompletionIntent; use collections::{HashMap, HashSet}; use fs::{Fs, RenameOptions}; use futures::{FutureExt, StreamExt, future::Shared}; @@ -46,7 +47,6 @@ use text::{BufferSnapshot, ToPoint}; use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; use uuid::Uuid; -use zed_llm_client::CompletionIntent; pub use crate::context_store::*; diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml index 146800e094..d4b8fa3afc 100644 --- a/crates/assistant_tools/Cargo.toml +++ b/crates/assistant_tools/Cargo.toml @@ -21,9 +21,11 @@ assistant_tool.workspace = true buffer_diff.workspace = true chrono.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true component.workspace = true derive_more.workspace = true +diffy = "0.4.2" editor.workspace = true feature_flags.workspace = true futures.workspace = true @@ -63,8 +65,6 @@ web_search.workspace = true which.workspace = true workspace-hack.workspace = true workspace.workspace = true -zed_llm_client.workspace = true -diffy = "0.4.2" [dev-dependencies] lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/assistant_tools/src/edit_agent.rs b/crates/assistant_tools/src/edit_agent.rs index 0184dff36c..fed79434bb 100644 --- a/crates/assistant_tools/src/edit_agent.rs +++ b/crates/assistant_tools/src/edit_agent.rs @@ -7,6 +7,7 @@ mod streaming_fuzzy_matcher; use crate::{Template, Templates}; use anyhow::Result; use assistant_tool::ActionLog; +use cloud_llm_client::CompletionIntent; use create_file_parser::{CreateFileParser, CreateFileParserEvent}; pub use edit_parser::EditFormat; use edit_parser::{EditParser, EditParserEvent, EditParserMetrics}; @@ -29,7 +30,6 @@ use std::{cmp, iter, mem, ops::Range, path::PathBuf, pin::Pin, sync::Arc, task:: use streaming_diff::{CharOperation, StreamingDiff}; use streaming_fuzzy_matcher::StreamingFuzzyMatcher; use util::debug_panic; -use zed_llm_client::CompletionIntent; #[derive(Serialize)] struct CreateFilePromptTemplate { diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index 5eeca9c2c4..d4a12f22c5 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -6,6 +6,7 @@ use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{ ActionLog, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput, ToolUseStatus, }; +use cloud_llm_client::{WebSearchResponse, WebSearchResult}; use futures::{Future, FutureExt, TryFutureExt}; use gpui::{ AnyWindowHandle, App, AppContext, Context, Entity, IntoElement, Task, WeakEntity, Window, @@ -17,7 +18,6 @@ use serde::{Deserialize, Serialize}; use ui::{IconName, Tooltip, prelude::*}; use web_search::WebSearchRegistry; use workspace::Workspace; -use zed_llm_client::{WebSearchResponse, WebSearchResult}; #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct WebSearchToolInput { diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 960aaf8e08..d857a3eb2f 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -18,6 +18,6 @@ collections.workspace = true derive_more.workspace = true gpui.workspace = true parking_lot.workspace = true -rodio = { version = "0.20.0", default-features = false, features = ["wav"] } +rodio = { version = "0.21.1", default-features = false, features = ["wav", "playback", "tracing"] } util.workspace = true workspace-hack.workspace = true diff --git a/crates/audio/src/assets.rs b/crates/audio/src/assets.rs index 02da79dc24..fd5c935d87 100644 --- a/crates/audio/src/assets.rs +++ b/crates/audio/src/assets.rs @@ -3,12 +3,9 @@ use std::{io::Cursor, sync::Arc}; use anyhow::{Context as _, Result}; use collections::HashMap; use gpui::{App, AssetSource, Global}; -use rodio::{ - Decoder, Source, - source::{Buffered, SamplesConverter}, -}; +use rodio::{Decoder, Source, source::Buffered}; -type Sound = Buffered>>, f32>>; +type Sound = Buffered>>>; pub struct SoundRegistry { cache: Arc>>, @@ -48,7 +45,7 @@ impl SoundRegistry { .with_context(|| format!("No asset available for path {path}"))?? .into_owned(); let cursor = Cursor::new(bytes); - let source = Decoder::new(cursor)?.convert_samples::().buffered(); + let source = Decoder::new(cursor)?.buffered(); self.cache.lock().insert(name.to_string(), source.clone()); diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index e7b9a59e8f..44baa16aa2 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -1,7 +1,7 @@ use assets::SoundRegistry; use derive_more::{Deref, DerefMut}; use gpui::{App, AssetSource, BorrowAppContext, Global}; -use rodio::{OutputStream, OutputStreamHandle}; +use rodio::{OutputStream, OutputStreamBuilder}; use util::ResultExt; mod assets; @@ -37,8 +37,7 @@ impl Sound { #[derive(Default)] pub struct Audio { - _output_stream: Option, - output_handle: Option, + output_handle: Option, } #[derive(Deref, DerefMut)] @@ -51,11 +50,9 @@ impl Audio { Self::default() } - fn ensure_output_exists(&mut self) -> Option<&OutputStreamHandle> { + fn ensure_output_exists(&mut self) -> Option<&OutputStream> { if self.output_handle.is_none() { - let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip(); - self.output_handle = output_handle; - self._output_stream = _output_stream; + self.output_handle = OutputStreamBuilder::open_default_stream().log_err(); } self.output_handle.as_ref() @@ -69,7 +66,7 @@ impl Audio { cx.update_global::(|this, cx| { let output_handle = this.ensure_output_exists()?; let source = SoundRegistry::global(cx).get(sound.file()).log_err()?; - output_handle.play_raw(source).log_err()?; + output_handle.mixer().add(source); Some(()) }); } @@ -80,7 +77,6 @@ impl Audio { } cx.update_global::(|this, _| { - this._output_stream.take(); this.output_handle.take(); }); } diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index b741f515fd..dd97bd9ca4 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -22,6 +22,7 @@ async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manua base64.workspace = true chrono = { workspace = true, features = ["serde"] } clock.workspace = true +cloud_llm_client.workspace = true collections.workspace = true credentials_provider.workspace = true derive_more.workspace = true @@ -33,8 +34,8 @@ http_client.workspace = true http_client_tls.workspace = true httparse = "1.10" log.workspace = true -paths.workspace = true parking_lot.workspace = true +paths.workspace = true postage.workspace = true rand.workspace = true regex.workspace = true @@ -46,19 +47,18 @@ serde_json.workspace = true settings.workspace = true sha2.workspace = true smol.workspace = true +telemetry.workspace = true telemetry_events.workspace = true text.workspace = true thiserror.workspace = true time.workspace = true tiny_http.workspace = true tokio-socks = { version = "0.5.2", default-features = false, features = ["futures-io"] } +tokio.workspace = true url.workspace = true util.workspace = true -worktree.workspace = true -telemetry.workspace = true -tokio.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true +worktree.workspace = true [dev-dependencies] clock = { workspace = true, features = ["test-support"] } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 81bb95b514..e0f4a70b15 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -21,7 +21,7 @@ use futures::{ channel::oneshot, future::BoxFuture, }; use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions}; -use http_client::{AsyncBody, HttpClient, HttpClientWithUrl}; +use http_client::{AsyncBody, HttpClient, HttpClientWithUrl, http}; use parking_lot::RwLock; use postage::watch; use proxy::connect_proxy_stream; @@ -1138,7 +1138,7 @@ impl Client { .to_str() .map_err(EstablishConnectionError::other)? .to_string(); - Url::parse(&collab_url).with_context(|| format!("parsing colab rpc url {collab_url}")) + Url::parse(&collab_url).with_context(|| format!("parsing collab rpc url {collab_url}")) } } @@ -1158,6 +1158,7 @@ impl Client { let http = self.http.clone(); let proxy = http.proxy().cloned(); + let user_agent = http.user_agent().cloned(); let credentials = credentials.clone(); let rpc_url = self.rpc_url(http, release_channel); let system_id = self.telemetry.system_id(); @@ -1209,7 +1210,7 @@ impl Client { // We then modify the request to add our desired headers. let request_headers = request.headers_mut(); request_headers.insert( - "Authorization", + http::header::AUTHORIZATION, HeaderValue::from_str(&credentials.authorization_header())?, ); request_headers.insert( @@ -1221,6 +1222,9 @@ impl Client { "x-zed-release-channel", HeaderValue::from_str(release_channel.map(|r| r.dev_name()).unwrap_or("unknown"))?, ); + if let Some(user_agent) = user_agent { + request_headers.insert(http::header::USER_AGENT, user_agent); + } if let Some(system_id) = system_id { request_headers.insert("x-zed-system-id", HeaderValue::from_str(&system_id)?); } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 5ed258aa8e..a7dab2a8d3 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,6 +1,10 @@ use super::{Client, Status, TypedEnvelope, proto}; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; +use cloud_llm_client::{ + EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, + MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME, MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit, +}; use collections::{HashMap, HashSet, hash_map::Entry}; use derive_more::Deref; use feature_flags::FeatureFlagAppExt; @@ -17,10 +21,6 @@ use std::{ }; use text::ReplicaId; use util::{TryFutureExt as _, maybe}; -use zed_llm_client::{ - EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, - MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME, MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit, -}; pub type UserId = u64; diff --git a/crates/cloud_llm_client/Cargo.toml b/crates/cloud_llm_client/Cargo.toml new file mode 100644 index 0000000000..6f090d3c6e --- /dev/null +++ b/crates/cloud_llm_client/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cloud_llm_client" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/cloud_llm_client.rs" + +[dependencies] +anyhow.workspace = true +serde = { workspace = true, features = ["derive", "rc"] } +serde_json.workspace = true +strum = { workspace = true, features = ["derive"] } +uuid = { workspace = true, features = ["serde"] } +workspace-hack.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/cloud_llm_client/LICENSE-APACHE b/crates/cloud_llm_client/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/cloud_llm_client/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs new file mode 100644 index 0000000000..2488088a49 --- /dev/null +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -0,0 +1,370 @@ +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::Context as _; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumIter, EnumString}; +use uuid::Uuid; + +/// The name of the header used to indicate which version of Zed the client is running. +pub const ZED_VERSION_HEADER_NAME: &str = "x-zed-version"; + +/// The name of the header used to indicate when a request failed due to an +/// expired LLM token. +/// +/// The client may use this as a signal to refresh the token. +pub const EXPIRED_LLM_TOKEN_HEADER_NAME: &str = "x-zed-expired-token"; + +/// The name of the header used to indicate what plan the user is currently on. +pub const CURRENT_PLAN_HEADER_NAME: &str = "x-zed-plan"; + +/// The name of the header used to indicate the usage limit for model requests. +pub const MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME: &str = "x-zed-model-requests-usage-limit"; + +/// The name of the header used to indicate the usage amount for model requests. +pub const MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME: &str = "x-zed-model-requests-usage-amount"; + +/// The name of the header used to indicate the usage limit for edit predictions. +pub const EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME: &str = "x-zed-edit-predictions-usage-limit"; + +/// The name of the header used to indicate the usage amount for edit predictions. +pub const EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME: &str = "x-zed-edit-predictions-usage-amount"; + +/// The name of the header used to indicate the resource for which the subscription limit has been reached. +pub const SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME: &str = "x-zed-subscription-limit-resource"; + +pub const MODEL_REQUESTS_RESOURCE_HEADER_VALUE: &str = "model_requests"; +pub const EDIT_PREDICTIONS_RESOURCE_HEADER_VALUE: &str = "edit_predictions"; + +/// The name of the header used to indicate that the maximum number of consecutive tool uses has been reached. +pub const TOOL_USE_LIMIT_REACHED_HEADER_NAME: &str = "x-zed-tool-use-limit-reached"; + +/// The name of the header used to indicate the the minimum required Zed version. +/// +/// This can be used to force a Zed upgrade in order to continue communicating +/// with the LLM service. +pub const MINIMUM_REQUIRED_VERSION_HEADER_NAME: &str = "x-zed-minimum-required-version"; + +/// The name of the header used by the client to indicate to the server that it supports receiving status messages. +pub const CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME: &str = + "x-zed-client-supports-status-messages"; + +/// The name of the header used by the server to indicate to the client that it supports sending status messages. +pub const SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME: &str = + "x-zed-server-supports-status-messages"; + +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageLimit { + Limited(i32), + Unlimited, +} + +impl FromStr for UsageLimit { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "unlimited" => Ok(Self::Unlimited), + limit => limit + .parse::() + .map(Self::Limited) + .context("failed to parse limit"), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Plan { + #[default] + #[serde(alias = "Free")] + ZedFree, + #[serde(alias = "ZedPro")] + ZedPro, + #[serde(alias = "ZedProTrial")] + ZedProTrial, +} + +impl Plan { + pub fn as_str(&self) -> &'static str { + match self { + Plan::ZedFree => "zed_free", + Plan::ZedPro => "zed_pro", + Plan::ZedProTrial => "zed_pro_trial", + } + } + + pub fn model_requests_limit(&self) -> UsageLimit { + match self { + Plan::ZedPro => UsageLimit::Limited(500), + Plan::ZedProTrial => UsageLimit::Limited(150), + Plan::ZedFree => UsageLimit::Limited(50), + } + } + + pub fn edit_predictions_limit(&self) -> UsageLimit { + match self { + Plan::ZedPro => UsageLimit::Unlimited, + Plan::ZedProTrial => UsageLimit::Unlimited, + Plan::ZedFree => UsageLimit::Limited(2_000), + } + } +} + +impl FromStr for Plan { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "zed_free" => Ok(Plan::ZedFree), + "zed_pro" => Ok(Plan::ZedPro), + "zed_pro_trial" => Ok(Plan::ZedProTrial), + plan => Err(anyhow::anyhow!("invalid plan: {plan:?}")), + } + } +} + +#[derive( + Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize, EnumString, EnumIter, Display, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum LanguageModelProvider { + Anthropic, + OpenAi, + Google, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PredictEditsBody { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub outline: Option, + pub input_events: String, + pub input_excerpt: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub speculated_output: Option, + /// Whether the user provided consent for sampling this interaction. + #[serde(default, alias = "data_collection_permission")] + pub can_collect_data: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub diagnostic_groups: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PredictEditsResponse { + pub request_id: Uuid, + pub output_excerpt: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcceptEditPredictionBody { + pub request_id: Uuid, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionMode { + Normal, + Max, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionIntent { + UserPrompt, + ToolResults, + ThreadSummarization, + ThreadContextSummarization, + CreateFile, + EditFile, + InlineAssist, + TerminalInlineAssist, + GenerateGitCommitMessage, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CompletionBody { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub prompt_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub intent: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub mode: Option, + pub provider: LanguageModelProvider, + pub model: String, + pub provider_request: serde_json::Value, +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionRequestStatus { + Queued { + position: usize, + }, + Started, + Failed { + code: String, + message: String, + request_id: Uuid, + /// Retry duration in seconds. + retry_after: Option, + }, + UsageUpdated { + amount: usize, + limit: UsageLimit, + }, + ToolUseLimitReached, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionEvent { + Status(CompletionRequestStatus), + Event(T), +} + +impl CompletionEvent { + pub fn into_status(self) -> Option { + match self { + Self::Status(status) => Some(status), + Self::Event(_) => None, + } + } + + pub fn into_event(self) -> Option { + match self { + Self::Event(event) => Some(event), + Self::Status(_) => None, + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct WebSearchBody { + pub query: String, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct WebSearchResponse { + pub results: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct WebSearchResult { + pub title: String, + pub url: String, + pub text: String, +} + +#[derive(Serialize, Deserialize)] +pub struct CountTokensBody { + pub provider: LanguageModelProvider, + pub model: String, + pub provider_request: serde_json::Value, +} + +#[derive(Serialize, Deserialize)] +pub struct CountTokensResponse { + pub tokens: usize, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub struct LanguageModelId(pub Arc); + +impl std::fmt::Display for LanguageModelId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LanguageModel { + pub provider: LanguageModelProvider, + pub id: LanguageModelId, + pub display_name: String, + pub max_token_count: usize, + pub max_token_count_in_max_mode: Option, + pub max_output_tokens: usize, + pub supports_tools: bool, + pub supports_images: bool, + pub supports_thinking: bool, + pub supports_max_mode: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListModelsResponse { + pub models: Vec, + pub default_model: LanguageModelId, + pub default_fast_model: LanguageModelId, + pub recommended_models: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct GetSubscriptionResponse { + pub plan: Plan, + pub usage: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CurrentUsage { + pub model_requests: UsageData, + pub edit_predictions: UsageData, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UsageData { + pub used: u32, + pub limit: UsageLimit, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[test] + fn test_plan_deserialize_snake_case() { + let plan = serde_json::from_value::(json!("zed_free")).unwrap(); + assert_eq!(plan, Plan::ZedFree); + + let plan = serde_json::from_value::(json!("zed_pro")).unwrap(); + assert_eq!(plan, Plan::ZedPro); + + let plan = serde_json::from_value::(json!("zed_pro_trial")).unwrap(); + assert_eq!(plan, Plan::ZedProTrial); + } + + #[test] + fn test_plan_deserialize_aliases() { + let plan = serde_json::from_value::(json!("Free")).unwrap(); + assert_eq!(plan, Plan::ZedFree); + + let plan = serde_json::from_value::(json!("ZedPro")).unwrap(); + assert_eq!(plan, Plan::ZedPro); + + let plan = serde_json::from_value::(json!("ZedProTrial")).unwrap(); + assert_eq!(plan, Plan::ZedProTrial); + } + + #[test] + fn test_usage_limit_from_str() { + let limit = UsageLimit::from_str("unlimited").unwrap(); + assert!(matches!(limit, UsageLimit::Unlimited)); + + let limit = UsageLimit::from_str(&0.to_string()).unwrap(); + assert!(matches!(limit, UsageLimit::Limited(0))); + + let limit = UsageLimit::from_str(&50.to_string()).unwrap(); + assert!(matches!(limit, UsageLimit::Limited(50))); + + for value in ["not_a_number", "50xyz"] { + let limit = UsageLimit::from_str(value); + assert!(limit.is_err()); + } + } +} diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index d3b5048283..9af95317e6 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -23,13 +23,14 @@ async-stripe.workspace = true async-trait.workspace = true async-tungstenite.workspace = true aws-config = { version = "1.1.5" } -aws-sdk-s3 = { version = "1.15.0" } aws-sdk-kinesis = "1.51.0" +aws-sdk-s3 = { version = "1.15.0" } axum = { version = "0.6", features = ["json", "headers", "ws"] } axum-extra = { version = "0.4", features = ["erased-json"] } base64.workspace = true chrono.workspace = true clock.workspace = true +cloud_llm_client.workspace = true collections.workspace = true dashmap.workspace = true derive_more.workspace = true @@ -75,7 +76,6 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "re util.workspace = true uuid.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true [dev-dependencies] agent_settings.workspace = true diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 5cb26eb507..6cf3f68f54 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -100,7 +100,6 @@ impl std::fmt::Display for SystemIdHeader { pub fn routes(rpc_server: Arc) -> Router<(), Body> { Router::new() - .route("/user", get(update_or_create_authenticated_user)) .route("/users/look_up", get(look_up_user)) .route("/users/:id/access_tokens", post(create_access_token)) .route("/users/:id/refresh_llm_tokens", post(refresh_llm_tokens)) @@ -145,48 +144,6 @@ pub async fn validate_api_token(req: Request, next: Next) -> impl IntoR Ok::<_, Error>(next.run(req).await) } -#[derive(Debug, Deserialize)] -struct AuthenticatedUserParams { - github_user_id: i32, - github_login: String, - github_email: Option, - github_name: Option, - github_user_created_at: chrono::DateTime, -} - -#[derive(Debug, Serialize)] -struct AuthenticatedUserResponse { - user: User, - metrics_id: String, - feature_flags: Vec, -} - -async fn update_or_create_authenticated_user( - Query(params): Query, - Extension(app): Extension>, -) -> Result> { - let initial_channel_id = app.config.auto_join_channel_id; - - let user = app - .db - .update_or_create_user_by_github_account( - ¶ms.github_login, - params.github_user_id, - params.github_email.as_deref(), - params.github_name.as_deref(), - params.github_user_created_at, - initial_channel_id, - ) - .await?; - let metrics_id = app.db.get_user_metrics_id(user.id).await?; - let feature_flags = app.db.get_user_flags(user.id).await?; - Ok(Json(AuthenticatedUserResponse { - user, - metrics_id, - feature_flags, - })) -} - #[derive(Debug, Deserialize)] struct LookUpUserParams { identifier: String, @@ -353,9 +310,9 @@ async fn refresh_llm_tokens( #[derive(Debug, Serialize, Deserialize)] struct UpdatePlanBody { - pub plan: zed_llm_client::Plan, + pub plan: cloud_llm_client::Plan, pub subscription_period: SubscriptionPeriod, - pub usage: zed_llm_client::CurrentUsage, + pub usage: cloud_llm_client::CurrentUsage, pub trial_started_at: Option>, pub is_usage_based_billing_enabled: bool, pub is_account_too_young: bool, @@ -377,9 +334,9 @@ async fn update_plan( extract::Json(body): extract::Json, ) -> Result> { let plan = match body.plan { - zed_llm_client::Plan::ZedFree => proto::Plan::Free, - zed_llm_client::Plan::ZedPro => proto::Plan::ZedPro, - zed_llm_client::Plan::ZedProTrial => proto::Plan::ZedProTrial, + cloud_llm_client::Plan::ZedFree => proto::Plan::Free, + cloud_llm_client::Plan::ZedPro => proto::Plan::ZedPro, + cloud_llm_client::Plan::ZedProTrial => proto::Plan::ZedProTrial, }; let update_user_plan = proto::UpdateUserPlan { @@ -411,15 +368,15 @@ async fn update_plan( Ok(Json(UpdatePlanResponse {})) } -fn usage_limit_to_proto(limit: zed_llm_client::UsageLimit) -> proto::UsageLimit { +fn usage_limit_to_proto(limit: cloud_llm_client::UsageLimit) -> proto::UsageLimit { proto::UsageLimit { variant: Some(match limit { - zed_llm_client::UsageLimit::Limited(limit) => { + cloud_llm_client::UsageLimit::Limited(limit) => { proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { limit: limit as u32, }) } - zed_llm_client::UsageLimit::Unlimited => { + cloud_llm_client::UsageLimit::Unlimited => { proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) } }), diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index 1cb20173c1..0e15308ffe 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -1,11 +1,11 @@ use anyhow::{Context as _, bail}; use chrono::{DateTime, Utc}; +use cloud_llm_client::LanguageModelProvider; use collections::{HashMap, HashSet}; use sea_orm::ActiveValue; use std::{sync::Arc, time::Duration}; use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus}; use util::{ResultExt, maybe}; -use zed_llm_client::LanguageModelProvider; use crate::AppState; use crate::db::billing_subscription::{ @@ -87,6 +87,14 @@ async fn poll_stripe_events( stripe_client: &Arc, real_stripe_client: &stripe::Client, ) -> anyhow::Result<()> { + let feature_flags = app.db.list_feature_flags().await?; + let sync_events_using_cloud = feature_flags + .iter() + .any(|flag| flag.flag == "cloud-stripe-events-polling" && flag.enabled_for_all); + if sync_events_using_cloud { + return Ok(()); + } + fn event_type_to_string(event_type: EventType) -> String { // Calling `to_string` on `stripe::EventType` members gives us a quoted string, // so we need to unquote it. @@ -569,6 +577,14 @@ async fn sync_model_request_usage_with_stripe( llm_db: &Arc, stripe_billing: &Arc, ) -> anyhow::Result<()> { + let feature_flags = app.db.list_feature_flags().await?; + let sync_model_request_usage_using_cloud = feature_flags + .iter() + .any(|flag| flag.flag == "cloud-stripe-usage-meters-sync" && flag.enabled_for_all); + if sync_model_request_usage_using_cloud { + return Ok(()); + } + log::info!("Stripe usage sync: Starting"); let started_at = Utc::now(); diff --git a/crates/collab/src/api/contributors.rs b/crates/collab/src/api/contributors.rs index 9296c1d428..8cfef0ad7e 100644 --- a/crates/collab/src/api/contributors.rs +++ b/crates/collab/src/api/contributors.rs @@ -8,7 +8,6 @@ use axum::{ use chrono::{NaiveDateTime, SecondsFormat}; use serde::{Deserialize, Serialize}; -use crate::api::AuthenticatedUserParams; use crate::db::ContributorSelector; use crate::{AppState, Result}; @@ -104,9 +103,18 @@ impl RenovateBot { } } +#[derive(Debug, Deserialize)] +struct AddContributorBody { + github_user_id: i32, + github_login: String, + github_email: Option, + github_name: Option, + github_user_created_at: chrono::DateTime, +} + async fn add_contributor( Extension(app): Extension>, - extract::Json(params): extract::Json, + extract::Json(params): extract::Json, ) -> Result<()> { let initial_channel_id = app.config.auto_join_channel_id; app.db diff --git a/crates/collab/src/db/tables/billing_subscription.rs b/crates/collab/src/db/tables/billing_subscription.rs index 43198f9859..522973dbc9 100644 --- a/crates/collab/src/db/tables/billing_subscription.rs +++ b/crates/collab/src/db/tables/billing_subscription.rs @@ -95,7 +95,7 @@ pub enum SubscriptionKind { ZedFree, } -impl From for zed_llm_client::Plan { +impl From for cloud_llm_client::Plan { fn from(value: SubscriptionKind) -> Self { match value { SubscriptionKind::ZedPro => Self::ZedPro, diff --git a/crates/collab/src/llm/db.rs b/crates/collab/src/llm/db.rs index 6a6efca0de..18ad624dab 100644 --- a/crates/collab/src/llm/db.rs +++ b/crates/collab/src/llm/db.rs @@ -6,11 +6,11 @@ mod tables; #[cfg(test)] mod tests; +use cloud_llm_client::LanguageModelProvider; use collections::HashMap; pub use ids::*; pub use seed::*; pub use tables::*; -use zed_llm_client::LanguageModelProvider; #[cfg(test)] pub use tests::TestLlmDb; diff --git a/crates/collab/src/llm/db/tests/provider_tests.rs b/crates/collab/src/llm/db/tests/provider_tests.rs index 7d52964b93..f4e1de40ec 100644 --- a/crates/collab/src/llm/db/tests/provider_tests.rs +++ b/crates/collab/src/llm/db/tests/provider_tests.rs @@ -1,5 +1,5 @@ +use cloud_llm_client::LanguageModelProvider; use pretty_assertions::assert_eq; -use zed_llm_client::LanguageModelProvider; use crate::llm::db::LlmDatabase; use crate::test_llm_db; diff --git a/crates/collab/src/llm/token.rs b/crates/collab/src/llm/token.rs index d4566ffcb4..da01c7f3be 100644 --- a/crates/collab/src/llm/token.rs +++ b/crates/collab/src/llm/token.rs @@ -4,12 +4,12 @@ use crate::llm::{AGENT_EXTENDED_TRIAL_FEATURE_FLAG, BYPASS_ACCOUNT_AGE_CHECK_FEA use crate::{Config, db::billing_preference}; use anyhow::{Context as _, Result}; use chrono::{NaiveDateTime, Utc}; +use cloud_llm_client::Plan; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use std::time::Duration; use thiserror::Error; use uuid::Uuid; -use zed_llm_client::Plan; #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 515647f97d..5c35394e1d 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -23,6 +23,7 @@ use anyhow::{Context as _, anyhow, bail}; use async_tungstenite::tungstenite::{ Message as TungsteniteMessage, protocol::CloseFrame as TungsteniteCloseFrame, }; +use axum::headers::UserAgent; use axum::{ Extension, Router, TypedHeader, body::Body, @@ -750,6 +751,7 @@ impl Server { address: String, principal: Principal, zed_version: ZedVersion, + user_agent: Option, geoip_country_code: Option, system_id: Option, send_connection_id: Option>, @@ -762,9 +764,14 @@ impl Server { user_id=field::Empty, login=field::Empty, impersonator=field::Empty, + user_agent=field::Empty, geoip_country_code=field::Empty ); principal.update_span(&span); + if let Some(user_agent) = user_agent { + span.record("user_agent", user_agent); + } + if let Some(country_code) = geoip_country_code.as_ref() { span.record("geoip_country_code", country_code); } @@ -1172,6 +1179,7 @@ pub async fn handle_websocket_request( ConnectInfo(socket_address): ConnectInfo, Extension(server): Extension>, Extension(principal): Extension, + user_agent: Option>, country_code_header: Option>, system_id_header: Option>, ws: WebSocketUpgrade, @@ -1227,6 +1235,7 @@ pub async fn handle_websocket_request( socket_address, principal, version, + user_agent.map(|header| header.to_string()), country_code_header.map(|header| header.to_string()), system_id_header.map(|header| header.to_string()), None, @@ -2859,12 +2868,12 @@ async fn make_update_user_plan_message( } fn model_requests_limit( - plan: zed_llm_client::Plan, + plan: cloud_llm_client::Plan, feature_flags: &Vec, -) -> zed_llm_client::UsageLimit { +) -> cloud_llm_client::UsageLimit { match plan.model_requests_limit() { - zed_llm_client::UsageLimit::Limited(limit) => { - let limit = if plan == zed_llm_client::Plan::ZedProTrial + cloud_llm_client::UsageLimit::Limited(limit) => { + let limit = if plan == cloud_llm_client::Plan::ZedProTrial && feature_flags .iter() .any(|flag| flag == AGENT_EXTENDED_TRIAL_FEATURE_FLAG) @@ -2874,9 +2883,9 @@ fn model_requests_limit( limit }; - zed_llm_client::UsageLimit::Limited(limit) + cloud_llm_client::UsageLimit::Limited(limit) } - zed_llm_client::UsageLimit::Unlimited => zed_llm_client::UsageLimit::Unlimited, + cloud_llm_client::UsageLimit::Unlimited => cloud_llm_client::UsageLimit::Unlimited, } } @@ -2886,21 +2895,21 @@ fn subscription_usage_to_proto( feature_flags: &Vec, ) -> proto::SubscriptionUsage { let plan = match plan { - proto::Plan::Free => zed_llm_client::Plan::ZedFree, - proto::Plan::ZedPro => zed_llm_client::Plan::ZedPro, - proto::Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, + proto::Plan::Free => cloud_llm_client::Plan::ZedFree, + proto::Plan::ZedPro => cloud_llm_client::Plan::ZedPro, + proto::Plan::ZedProTrial => cloud_llm_client::Plan::ZedProTrial, }; proto::SubscriptionUsage { model_requests_usage_amount: usage.model_requests as u32, model_requests_usage_limit: Some(proto::UsageLimit { variant: Some(match model_requests_limit(plan, feature_flags) { - zed_llm_client::UsageLimit::Limited(limit) => { + cloud_llm_client::UsageLimit::Limited(limit) => { proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { limit: limit as u32, }) } - zed_llm_client::UsageLimit::Unlimited => { + cloud_llm_client::UsageLimit::Unlimited => { proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) } }), @@ -2908,12 +2917,12 @@ fn subscription_usage_to_proto( edit_predictions_usage_amount: usage.edit_predictions as u32, edit_predictions_usage_limit: Some(proto::UsageLimit { variant: Some(match plan.edit_predictions_limit() { - zed_llm_client::UsageLimit::Limited(limit) => { + cloud_llm_client::UsageLimit::Limited(limit) => { proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { limit: limit as u32, }) } - zed_llm_client::UsageLimit::Unlimited => { + cloud_llm_client::UsageLimit::Unlimited => { proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) } }), @@ -2926,21 +2935,21 @@ fn make_default_subscription_usage( feature_flags: &Vec, ) -> proto::SubscriptionUsage { let plan = match plan { - proto::Plan::Free => zed_llm_client::Plan::ZedFree, - proto::Plan::ZedPro => zed_llm_client::Plan::ZedPro, - proto::Plan::ZedProTrial => zed_llm_client::Plan::ZedProTrial, + proto::Plan::Free => cloud_llm_client::Plan::ZedFree, + proto::Plan::ZedPro => cloud_llm_client::Plan::ZedPro, + proto::Plan::ZedProTrial => cloud_llm_client::Plan::ZedProTrial, }; proto::SubscriptionUsage { model_requests_usage_amount: 0, model_requests_usage_limit: Some(proto::UsageLimit { variant: Some(match model_requests_limit(plan, feature_flags) { - zed_llm_client::UsageLimit::Limited(limit) => { + cloud_llm_client::UsageLimit::Limited(limit) => { proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { limit: limit as u32, }) } - zed_llm_client::UsageLimit::Unlimited => { + cloud_llm_client::UsageLimit::Unlimited => { proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) } }), @@ -2948,12 +2957,12 @@ fn make_default_subscription_usage( edit_predictions_usage_amount: 0, edit_predictions_usage_limit: Some(proto::UsageLimit { variant: Some(match plan.edit_predictions_limit() { - zed_llm_client::UsageLimit::Limited(limit) => { + cloud_llm_client::UsageLimit::Limited(limit) => { proto::usage_limit::Variant::Limited(proto::usage_limit::Limited { limit: limit as u32, }) } - zed_llm_client::UsageLimit::Unlimited => { + cloud_llm_client::UsageLimit::Unlimited => { proto::usage_limit::Variant::Unlimited(proto::usage_limit::Unlimited {}) } }), diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index ab84e02b19..5192db16a7 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -256,6 +256,7 @@ impl TestServer { ZedVersion(SemanticVersion::new(1, 0, 0)), None, None, + None, Some(connection_id_tx), Executor::Deterministic(cx.background_executor().clone()), None, diff --git a/crates/context_server/src/client.rs b/crates/context_server/src/client.rs index ff4d79c07d..1eb29bbbf9 100644 --- a/crates/context_server/src/client.rs +++ b/crates/context_server/src/client.rs @@ -158,6 +158,7 @@ impl Client { pub fn stdio( server_id: ContextServerId, binary: ModelContextServerBinary, + working_directory: &Option, cx: AsyncApp, ) -> Result { log::info!( @@ -172,7 +173,7 @@ impl Client { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(String::new); - let transport = Arc::new(StdioTransport::new(binary, &cx)?); + let transport = Arc::new(StdioTransport::new(binary, working_directory, &cx)?); Self::new(server_id, server_name.into(), transport, cx) } diff --git a/crates/context_server/src/context_server.rs b/crates/context_server/src/context_server.rs index f2517feb27..e76e7972f7 100644 --- a/crates/context_server/src/context_server.rs +++ b/crates/context_server/src/context_server.rs @@ -53,7 +53,7 @@ impl std::fmt::Debug for ContextServerCommand { } enum ContextServerTransport { - Stdio(ContextServerCommand), + Stdio(ContextServerCommand, Option), Custom(Arc), } @@ -64,11 +64,18 @@ pub struct ContextServer { } impl ContextServer { - pub fn stdio(id: ContextServerId, command: ContextServerCommand) -> Self { + pub fn stdio( + id: ContextServerId, + command: ContextServerCommand, + working_directory: Option>, + ) -> Self { Self { id, client: RwLock::new(None), - configuration: ContextServerTransport::Stdio(command), + configuration: ContextServerTransport::Stdio( + command, + working_directory.map(|directory| directory.to_path_buf()), + ), } } @@ -90,13 +97,14 @@ impl ContextServer { pub async fn start(self: Arc, cx: &AsyncApp) -> Result<()> { let client = match &self.configuration { - ContextServerTransport::Stdio(command) => Client::stdio( + ContextServerTransport::Stdio(command, working_directory) => Client::stdio( client::ContextServerId(self.id.0.clone()), client::ModelContextServerBinary { executable: Path::new(&command.path).to_path_buf(), args: command.args.clone(), env: command.env.clone(), }, + working_directory, cx.clone(), )?, ContextServerTransport::Custom(transport) => Client::new( diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 56d0240fa5..443b8c16f1 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::pin::Pin; use anyhow::{Context as _, Result}; @@ -22,7 +23,11 @@ pub struct StdioTransport { } impl StdioTransport { - pub fn new(binary: ModelContextServerBinary, cx: &AsyncApp) -> Result { + pub fn new( + binary: ModelContextServerBinary, + working_directory: &Option, + cx: &AsyncApp, + ) -> Result { let mut command = util::command::new_smol_command(&binary.executable); command .args(&binary.args) @@ -32,6 +37,10 @@ impl StdioTransport { .stderr(std::process::Stdio::piped()) .kill_on_drop(true); + if let Some(working_directory) = working_directory { + command.current_dir(working_directory); + } + let mut server = command.spawn().with_context(|| { format!( "failed to spawn command. (path={:?}, args={:?})", diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index a0df669abe..a9eff17fa1 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -7,17 +7,17 @@ license = "GPL-3.0-or-later" [dependencies] anyhow.workspace = true -clap.workspace = true +command_palette.workspace = true +gpui.workspace = true mdbook = "0.4.40" +regex.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -regex.workspace = true util.workspace = true workspace-hack.workspace = true zed.workspace = true -gpui.workspace = true -command_palette.workspace = true +zlog.workspace = true [lints] workspace = true diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 8eeeb6f0c5..1448f4cb52 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -1,14 +1,15 @@ -use anyhow::Result; -use clap::{Arg, ArgMatches, Command}; +use anyhow::{Context, Result}; use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; use regex::Regex; use settings::KeymapFile; -use std::collections::HashSet; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; use std::io::{self, Read}; use std::process; use std::sync::LazyLock; +use util::paths::PathExt; static KEYMAP_MACOS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap") @@ -20,60 +21,68 @@ static KEYMAP_LINUX: LazyLock = LazyLock::new(|| { static ALL_ACTIONS: LazyLock> = LazyLock::new(dump_all_gpui_actions); -pub fn make_app() -> Command { - Command::new("zed-docs-preprocessor") - .about("Preprocesses Zed Docs content to provide rich action & keybinding support and more") - .subcommand( - Command::new("supports") - .arg(Arg::new("renderer").required(true)) - .about("Check whether a renderer is supported by this preprocessor"), - ) -} +const FRONT_MATTER_COMMENT: &'static str = ""; fn main() -> Result<()> { - let matches = make_app().get_matches(); + zlog::init(); + zlog::init_output_stderr(); // call a zed:: function so everything in `zed` crate is linked and // all actions in the actual app are registered zed::stdout_is_a_pty(); + let args = std::env::args().skip(1).collect::>(); - if let Some(sub_args) = matches.subcommand_matches("supports") { - handle_supports(sub_args); - } else { - handle_preprocessing()?; + match args.get(0).map(String::as_str) { + Some("supports") => { + let renderer = args.get(1).expect("Required argument"); + let supported = renderer != "not-supported"; + if supported { + process::exit(0); + } else { + process::exit(1); + } + } + Some("postprocess") => handle_postprocessing()?, + _ => handle_preprocessing()?, } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum Error { +enum PreprocessorError { ActionNotFound { action_name: String }, DeprecatedActionUsed { used: String, should_be: String }, + InvalidFrontmatterLine(String), } -impl Error { +impl PreprocessorError { fn new_for_not_found_action(action_name: String) -> Self { for action in &*ALL_ACTIONS { for alias in action.deprecated_aliases { if alias == &action_name { - return Error::DeprecatedActionUsed { + return PreprocessorError::DeprecatedActionUsed { used: action_name.clone(), should_be: action.name.to_string(), }; } } } - Error::ActionNotFound { + PreprocessorError::ActionNotFound { action_name: action_name.to_string(), } } } -impl std::fmt::Display for Error { +impl std::fmt::Display for PreprocessorError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Error::ActionNotFound { action_name } => write!(f, "Action not found: {}", action_name), - Error::DeprecatedActionUsed { used, should_be } => write!( + PreprocessorError::InvalidFrontmatterLine(line) => { + write!(f, "Invalid frontmatter line: {}", line) + } + PreprocessorError::ActionNotFound { action_name } => { + write!(f, "Action not found: {}", action_name) + } + PreprocessorError::DeprecatedActionUsed { used, should_be } => write!( f, "Deprecated action used: {} should be {}", used, should_be @@ -89,8 +98,9 @@ fn handle_preprocessing() -> Result<()> { let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?; - let mut errors = HashSet::::new(); + let mut errors = HashSet::::new(); + handle_frontmatter(&mut book, &mut errors); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); @@ -108,19 +118,41 @@ fn handle_preprocessing() -> Result<()> { Ok(()) } -fn handle_supports(sub_args: &ArgMatches) -> ! { - let renderer = sub_args - .get_one::("renderer") - .expect("Required argument"); - let supported = renderer != "not-supported"; - if supported { - process::exit(0); - } else { - process::exit(1); - } +fn handle_frontmatter(book: &mut Book, errors: &mut HashSet) { + let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap(); + for_each_chapter_mut(book, |chapter| { + let new_content = frontmatter_regex.replace(&chapter.content, |caps: ®ex::Captures| { + let frontmatter = caps[1].trim(); + let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']); + let mut metadata = HashMap::::default(); + for line in frontmatter.lines() { + let Some((name, value)) = line.split_once(':') else { + errors.insert(PreprocessorError::InvalidFrontmatterLine(format!( + "{}: {}", + chapter_breadcrumbs(&chapter), + line + ))); + continue; + }; + let name = name.trim(); + let value = value.trim(); + metadata.insert(name.to_string(), value.to_string()); + } + FRONT_MATTER_COMMENT.replace( + "{}", + &serde_json::to_string(&metadata).expect("Failed to serialize metadata"), + ) + }); + match new_content { + Cow::Owned(content) => { + chapter.content = content; + } + Cow::Borrowed(_) => {} + } + }); } -fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { +fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { let regex = Regex::new(r"\{#kb (.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { @@ -128,7 +160,9 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { +fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet) { let regex = Regex::new(r"\{#action (.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { @@ -152,7 +186,9 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet) { .replace_all(&chapter.content, |caps: ®ex::Captures| { let name = caps[1].trim(); let Some(action) = find_action_by_name(name) else { - errors.insert(Error::new_for_not_found_action(name.to_string())); + errors.insert(PreprocessorError::new_for_not_found_action( + name.to_string(), + )); return String::new(); }; format!("{}", &action.human_name) @@ -217,6 +253,13 @@ fn name_for_action(action_as_str: String) -> String { .unwrap_or(action_as_str) } +fn chapter_breadcrumbs(chapter: &Chapter) -> String { + let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1); + breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str)); + breadcrumbs.push(chapter.name.as_str()); + format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > ")) +} + fn load_keymap(asset_path: &str) -> Result { let content = util::asset_str::(asset_path); KeymapFile::parse(content.as_ref()) @@ -254,3 +297,126 @@ fn dump_all_gpui_actions() -> Vec { return actions; } + +fn handle_postprocessing() -> Result<()> { + let logger = zlog::scoped!("render"); + let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?; + let output = ctx + .config + .get_mut("output") + .expect("has output") + .as_table_mut() + .expect("output is table"); + let zed_html = output.remove("zed-html").expect("zed-html output defined"); + let default_description = zed_html + .get("default-description") + .expect("Default description not found") + .as_str() + .expect("Default description not a string") + .to_string(); + let default_title = zed_html + .get("default-title") + .expect("Default title not found") + .as_str() + .expect("Default title not a string") + .to_string(); + + output.insert("html".to_string(), zed_html); + mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?; + let ignore_list = ["toc.html"]; + + let root_dir = ctx.destination.clone(); + let mut files = Vec::with_capacity(128); + let mut queue = Vec::with_capacity(64); + queue.push(root_dir.clone()); + while let Some(dir) = queue.pop() { + for entry in std::fs::read_dir(&dir).context(dir.to_sanitized_string())? { + let Ok(entry) = entry else { + continue; + }; + let file_type = entry.file_type().context("Failed to determine file type")?; + if file_type.is_dir() { + queue.push(entry.path()); + } + if file_type.is_file() + && matches!( + entry.path().extension().and_then(std::ffi::OsStr::to_str), + Some("html") + ) + { + if ignore_list.contains(&&*entry.file_name().to_string_lossy()) { + zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy()); + } else { + files.push(entry.path()); + } + } + } + } + + zlog::info!(logger => "Processing {} `.html` files", files.len()); + let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap(); + for file in files { + let contents = std::fs::read_to_string(&file)?; + let mut meta_description = None; + let mut meta_title = None; + let contents = meta_regex.replace(&contents, |caps: ®ex::Captures| { + let metadata: HashMap = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata"); + for (kind, content) in metadata { + match kind.as_str() { + "description" => { + meta_description = Some(content); + } + "title" => { + meta_title = Some(content); + } + _ => { + zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir)); + } + } + } + String::new() + }); + let meta_description = meta_description.as_ref().unwrap_or_else(|| { + zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir)); + &default_description + }); + let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir)); + let meta_title = meta_title.as_ref().unwrap_or_else(|| { + zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir)); + &default_title + }); + let meta_title = format!("{} | {}", page_title, meta_title); + zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir)); + let contents = contents.replace("#description#", meta_description); + let contents = TITLE_REGEX + .replace(&contents, |_: ®ex::Captures| { + format!("{}", meta_title) + }) + .to_string(); + // let contents = contents.replace("#title#", &meta_title); + std::fs::write(file, contents)?; + } + return Ok(()); + + fn pretty_path<'a>( + path: &'a std::path::PathBuf, + root: &'a std::path::PathBuf, + ) -> &'a std::path::Path { + &path.strip_prefix(&root).unwrap_or(&path) + } + const TITLE_REGEX: std::cell::LazyCell = + std::cell::LazyCell::new(|| Regex::new(r"\s*(.*?)\s*").unwrap()); + fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String { + let title_tag_contents = &TITLE_REGEX + .captures(&contents) + .with_context(|| format!("Failed to find title in {:?}", pretty_path)) + .expect("Page has element")[1]; + let title = title_tag_contents + .trim() + .strip_suffix("- Zed") + .unwrap_or(title_tag_contents) + .trim() + .to_string(); + title + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3c877873a0..a2f2310144 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -65,7 +65,7 @@ use display_map::*; pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPlaceholder}; pub use editor_settings::{ CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode, - ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowScrollbar, + ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap, ShowScrollbar, }; use editor_settings::{GoToDefinitionFallback, Minimap as MinimapSettings}; pub use editor_settings_controls::*; diff --git a/crates/eval/Cargo.toml b/crates/eval/Cargo.toml index d5db7f71a4..a0214c76a1 100644 --- a/crates/eval/Cargo.toml +++ b/crates/eval/Cargo.toml @@ -19,8 +19,8 @@ path = "src/explorer.rs" [dependencies] agent.workspace = true -agent_ui.workspace = true agent_settings.workspace = true +agent_ui.workspace = true anyhow.workspace = true assistant_tool.workspace = true assistant_tools.workspace = true @@ -29,6 +29,7 @@ buffer_diff.workspace = true chrono.workspace = true clap.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true debug_adapter_extension.workspace = true dirs.workspace = true @@ -68,4 +69,3 @@ util.workspace = true uuid.workspace = true watch.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 7ce3b1fdf1..23c8814916 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -15,11 +15,11 @@ use agent_settings::AgentProfileId; use anyhow::{Result, anyhow}; use async_trait::async_trait; use buffer_diff::DiffHunkStatus; +use cloud_llm_client::CompletionIntent; use collections::HashMap; use futures::{FutureExt as _, StreamExt, channel::mpsc, select_biased}; use gpui::{App, AppContext, AsyncApp, Entity}; use language_model::{LanguageModel, Role, StopReason}; -use zed_llm_client::CompletionIntent; pub const THREAD_EVENT_TIMEOUT: Duration = Duration::from_secs(60 * 2); diff --git a/crates/extension_host/src/wasm_host.rs b/crates/extension_host/src/wasm_host.rs index 1f6f5035e3..d990b670f4 100644 --- a/crates/extension_host/src/wasm_host.rs +++ b/crates/extension_host/src/wasm_host.rs @@ -106,7 +106,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_initialization_options( @@ -131,7 +131,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_workspace_configuration( @@ -154,7 +154,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_additional_initialization_options( @@ -179,7 +179,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn language_server_additional_workspace_configuration( @@ -204,7 +204,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn labels_for_completions( @@ -230,7 +230,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn labels_for_symbols( @@ -256,7 +256,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn complete_slash_command_argument( @@ -275,7 +275,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn run_slash_command( @@ -301,7 +301,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn context_server_command( @@ -320,7 +320,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn context_server_configuration( @@ -347,7 +347,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn suggest_docs_packages(&self, provider: Arc<str>) -> Result<Vec<String>> { @@ -362,7 +362,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn index_docs( @@ -388,7 +388,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn get_dap_binary( @@ -410,7 +410,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn dap_request_kind( &self, @@ -427,7 +427,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn dap_config_to_scenario(&self, config: ZedDebugConfig) -> Result<DebugScenario> { @@ -441,7 +441,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn dap_locator_create_scenario( @@ -465,7 +465,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } async fn run_dap_locator( &self, @@ -481,7 +481,7 @@ impl extension::Extension for WasmExtension { } .boxed() }) - .await + .await? } } @@ -761,7 +761,7 @@ impl WasmExtension { .with_context(|| format!("failed to load wasm extension {}", manifest.id)) } - pub async fn call<T, Fn>(&self, f: Fn) -> T + pub async fn call<T, Fn>(&self, f: Fn) -> Result<T> where T: 'static + Send, Fn: 'static @@ -777,8 +777,19 @@ impl WasmExtension { } .boxed() })) - .expect("wasm extension channel should not be closed yet"); - return_rx.await.expect("wasm extension channel") + .map_err(|_| { + anyhow!( + "wasm extension channel should not be closed yet, extension {} (id {})", + self.manifest.name, + self.manifest.id, + ) + })?; + return_rx.await.with_context(|| { + format!( + "wasm extension channel, extension {} (id {})", + self.manifest.name, self.manifest.id, + ) + }) } } @@ -799,8 +810,19 @@ impl WasmState { } .boxed_local() })) - .expect("main thread message channel should not be closed yet"); - async move { return_rx.await.expect("main thread message channel") } + .unwrap_or_else(|_| { + panic!( + "main thread message channel should not be closed yet, extension {} (id {})", + self.manifest.name, self.manifest.id, + ) + }); + let name = self.manifest.name.clone(); + let id = self.manifest.id.clone(); + async move { + return_rx.await.unwrap_or_else(|_| { + panic!("main thread message channel, extension {name} (id {id})") + }) + } } fn work_dir(&self) -> PathBuf { diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index a4d61dd56f..e5ac70bb58 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1404,14 +1404,21 @@ impl PickerDelegate for FileFinderDelegate { } else { let path_position = PathWithPosition::parse_str(&raw_query); + #[cfg(windows)] + let raw_query = raw_query.trim().to_owned().replace("/", "\\"); + #[cfg(not(windows))] + let raw_query = raw_query.trim().to_owned(); + + let file_query_end = if path_position.path.to_str().unwrap_or(&raw_query) == raw_query { + None + } else { + // Safe to unwrap as we won't get here when the unwrap in if fails + Some(path_position.path.to_str().unwrap().len()) + }; + let query = FileSearchQuery { - raw_query: raw_query.trim().to_owned(), - file_query_end: if path_position.path.to_str().unwrap_or(raw_query) == raw_query { - None - } else { - // Safe to unwrap as we won't get here when the unwrap in if fails - Some(path_position.path.to_str().unwrap().len()) - }, + raw_query, + file_query_end, path_position, }; diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 2fb80b7e73..4c919249ee 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -24,6 +24,7 @@ buffer_diff.workspace = true call.workspace = true chrono.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true command_palette_hooks.workspace = true component.workspace = true @@ -62,7 +63,6 @@ watch.workspace = true workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true -zed_llm_client.workspace = true [target.'cfg(windows)'.dependencies] windows.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index f7efada469..e196a5b139 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -71,12 +71,12 @@ use ui::{ use util::{ResultExt, TryFutureExt, maybe}; use workspace::SERIALIZATION_THROTTLE_TIME; +use cloud_llm_client::CompletionIntent; use workspace::{ Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId}, }; -use zed_llm_client::CompletionIntent; actions!( git_panel, diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 680111a6ce..4023ddf2dc 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -47,6 +47,7 @@ wayland = [ "wayland-cursor", "wayland-protocols", "wayland-protocols-plasma", + "wayland-protocols-wlr", "filedescriptor", "xkbcommon", "open", @@ -193,6 +194,9 @@ wayland-protocols = { version = "0.31.2", features = [ wayland-protocols-plasma = { version = "0.2.0", features = [ "client", ], optional = true } +wayland-protocols-wlr = { version = "0.3.8", features = [ + "client" +], optional = true} # X11 as-raw-xcb-connection = { version = "1", optional = true } diff --git a/crates/gpui/examples/tab_stop.rs b/crates/gpui/examples/tab_stop.rs index 9c58b52a5e..1f6500f3e6 100644 --- a/crates/gpui/examples/tab_stop.rs +++ b/crates/gpui/examples/tab_stop.rs @@ -6,6 +6,7 @@ use gpui::{ actions!(example, [Tab, TabPrev]); struct Example { + focus_handle: FocusHandle, items: Vec<FocusHandle>, message: SharedString, } @@ -20,8 +21,11 @@ impl Example { cx.focus_handle().tab_index(2).tab_stop(true), ]; - window.focus(items.first().unwrap()); + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle); + Self { + focus_handle, items, message: SharedString::from("Press `Tab`, `Shift-Tab` to switch focus."), } @@ -40,6 +44,10 @@ impl Example { impl Render for Example { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + fn tab_stop_style<T: Styled>(this: T) -> T { + this.border_3().border_color(gpui::blue()) + } + fn button(id: impl Into<ElementId>) -> Stateful<Div> { div() .id(id) @@ -52,12 +60,13 @@ impl Render for Example { .border_color(gpui::black()) .bg(gpui::black()) .text_color(gpui::white()) - .focus(|this| this.border_color(gpui::blue())) + .focus(tab_stop_style) .shadow_sm() } div() .id("app") + .track_focus(&self.focus_handle) .on_action(cx.listener(Self::on_tab)) .on_action(cx.listener(Self::on_tab_prev)) .size_full() @@ -86,7 +95,7 @@ impl Render for Example { .border_color(gpui::black()) .when( item_handle.tab_stop && item_handle.is_focused(window), - |this| this.border_color(gpui::blue()), + tab_stop_style, ) .map(|this| match item_handle.tab_stop { true => this diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 759d33563e..ded7bae316 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -2023,6 +2023,10 @@ impl HttpClient for NullHttpClient { .boxed() } + fn user_agent(&self) -> Option<&http_client::http::HeaderValue> { + None + } + fn proxy(&self) -> Option<&Url> { None } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 1e72d23868..febf294e48 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -1216,6 +1216,10 @@ pub enum WindowKind { /// A window that appears above all other windows, usually used for alerts or popups /// use sparingly! PopUp, + /// An overlay such as a notification window, a launcher, ... + /// + /// Only supported on wayland + Overlay, } /// The appearance of the window, as defined by the operating system. diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index 72e4477ecf..33b22e7ce5 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -61,6 +61,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{ }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; use wayland_protocols_plasma::blur::client::{org_kde_kwin_blur, org_kde_kwin_blur_manager}; +use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; use xkbcommon::xkb::{self, KEYMAP_COMPILE_NO_FLAGS, Keycode}; @@ -114,6 +115,7 @@ pub struct Globals { pub fractional_scale_manager: Option<wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1>, pub decoration_manager: Option<zxdg_decoration_manager_v1::ZxdgDecorationManagerV1>, + pub layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>, pub blur_manager: Option<org_kde_kwin_blur_manager::OrgKdeKwinBlurManager>, pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>, pub executor: ForegroundExecutor, @@ -151,6 +153,7 @@ impl Globals { viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), + layer_shell: globals.bind(&qh, 1..=1, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), executor, @@ -929,6 +932,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_buffer::WlBuffer); delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); +delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); @@ -1074,6 +1078,31 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ObjectId> for WaylandClientStatePtr { } } +impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ObjectId> for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + event: <zwlr_layer_surface_v1::ZwlrLayerSurfaceV1 as Proxy>::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle<Self>, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + drop(state); + + let should_close = window.handle_layersurface_event(event); + + if should_close { + // The close logic will be handled in drop_window() + window.close(); + } + } +} + impl Dispatch<xdg_wm_base::XdgWmBase, ()> for WaylandClientStatePtr { fn event( _: &mut Self, diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 2b2207e22c..33c908d1b2 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -1,3 +1,6 @@ +use blade_graphics as gpu; +use collections::HashMap; +use futures::channel::oneshot::Receiver; use std::{ cell::{Ref, RefCell, RefMut}, ffi::c_void, @@ -6,9 +9,14 @@ use std::{ sync::Arc, }; -use blade_graphics as gpu; -use collections::HashMap; -use futures::channel::oneshot::Receiver; +use crate::{ + Capslock, + platform::{ + PlatformAtlas, PlatformInputHandler, PlatformWindow, + blade::{BladeContext, BladeRenderer, BladeSurfaceConfig}, + linux::wayland::{display::WaylandDisplay, serial::SerialKind}, + }, +}; use raw_window_handle as rwh; use wayland_backend::client::ObjectId; @@ -20,6 +28,8 @@ use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1 use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::Layer; +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; use crate::scene::Scene; use crate::{ @@ -27,15 +37,7 @@ use crate::{ PlatformDisplay, PlatformInput, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Size, Tiling, WaylandClientStatePtr, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, - WindowParams, px, size, -}; -use crate::{ - Capslock, - platform::{ - PlatformAtlas, PlatformInputHandler, PlatformWindow, - blade::{BladeContext, BladeRenderer, BladeSurfaceConfig}, - linux::wayland::{display::WaylandDisplay, serial::SerialKind}, - }, + WindowKind, WindowParams, px, size, }; #[derive(Default)] @@ -81,14 +83,12 @@ struct InProgressConfigure { } pub struct WaylandWindowState { - xdg_surface: xdg_surface::XdgSurface, + surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, pub surface: wl_surface::WlSurface, - decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>, app_id: Option<String>, appearance: WindowAppearance, blur: Option<org_kde_kwin_blur::OrgKdeKwinBlur>, - toplevel: xdg_toplevel::XdgToplevel, viewport: Option<wp_viewport::WpViewport>, outputs: HashMap<ObjectId, Output>, display: Option<(ObjectId, Output)>, @@ -114,6 +114,78 @@ pub struct WaylandWindowState { client_inset: Option<Pixels>, } +pub enum WaylandSurfaceState { + Xdg(WaylandXdgSurfaceState), + LayerShell(WaylandLayerSurfaceState), +} + +pub struct WaylandXdgSurfaceState { + xdg_surface: xdg_surface::XdgSurface, + toplevel: xdg_toplevel::XdgToplevel, + decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>, +} + +pub struct WaylandLayerSurfaceState { + layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, +} + +impl WaylandSurfaceState { + fn ack_configure(&self, serial: u32) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + xdg_surface.ack_configure(serial); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { + layer_surface.ack_configure(serial); + } + } + } + + fn decoration(&self) -> Option<&zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1> { + if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { decoration, .. }) = self { + decoration.as_ref() + } else { + None + } + } + + fn toplevel(&self) -> Option<&xdg_toplevel::XdgToplevel> { + if let WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { toplevel, .. }) = self { + Some(toplevel) + } else { + None + } + } + + fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { + xdg_surface.set_window_geometry(x, y, width, height); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { + // cannot set window position of a layer surface + layer_surface.set_size(width as u32, height as u32); + } + } + } + + fn destroy(&mut self) { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { + xdg_surface, + toplevel, + decoration: _decoration, + }) => { + toplevel.destroy(); + xdg_surface.destroy(); + } + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { + layer_surface.destroy(); + } + } + } +} + #[derive(Clone)] pub struct WaylandWindowStatePtr { state: Rc<RefCell<WaylandWindowState>>, @@ -124,9 +196,7 @@ impl WaylandWindowState { pub(crate) fn new( handle: AnyWindowHandle, surface: wl_surface::WlSurface, - xdg_surface: xdg_surface::XdgSurface, - toplevel: xdg_toplevel::XdgToplevel, - decoration: Option<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1>, + surface_state: WaylandSurfaceState, appearance: WindowAppearance, viewport: Option<wp_viewport::WpViewport>, client: WaylandClientStatePtr, @@ -156,13 +226,11 @@ impl WaylandWindowState { }; Ok(Self { - xdg_surface, + surface_state, acknowledged_first_configure: false, surface, - decoration, app_id: None, blur: None, - toplevel, viewport, globals, outputs: HashMap::default(), @@ -235,17 +303,16 @@ impl Drop for WaylandWindow { let client = state.client.clone(); state.renderer.destroy(); - if let Some(decoration) = &state.decoration { + if let Some(decoration) = &state.surface_state.decoration() { decoration.destroy(); } if let Some(blur) = &state.blur { blur.release(); } - state.toplevel.destroy(); + state.surface_state.destroy(); if let Some(viewport) = &state.viewport { viewport.destroy(); } - state.xdg_surface.destroy(); state.surface.destroy(); let state_ptr = self.0.clone(); @@ -279,27 +346,65 @@ impl WaylandWindow { appearance: WindowAppearance, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let xdg_surface = globals - .wm_base - .get_xdg_surface(&surface, &globals.qh, surface.id()); - let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); - if let Some(size) = params.window_min_size { - toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); - } + let surface_state = match (params.kind, globals.layer_shell.as_ref()) { + // Matching on layer_shell here means that if kind is Overlay, but the compositor doesn't support layer_shell, + // we end up defaulting to xdg_surface anyway + (WindowKind::Overlay, Some(layer_shell)) => { + let layer_surface = layer_shell.get_layer_surface( + &surface, + None, + Layer::Overlay, + "".to_string(), + &globals.qh, + surface.id(), + ); + + let width = params.bounds.size.width.0; + let height = params.bounds.size.height.0; + layer_surface.set_size(width as u32, height as u32); + layer_surface.set_keyboard_interactivity( + zwlr_layer_surface_v1::KeyboardInteractivity::OnDemand, + ); + + WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) + } + _ => { + let xdg_surface = + globals + .wm_base + .get_xdg_surface(&surface, &globals.qh, surface.id()); + + let toplevel = xdg_surface.get_toplevel(&globals.qh, surface.id()); + + if let Some(size) = params.window_min_size { + toplevel.set_min_size(size.width.0 as i32, size.height.0 as i32); + } + + // Attempt to set up window decorations based on the requested configuration + let decoration = globals + .decoration_manager + .as_ref() + .map(|decoration_manager| { + decoration_manager.get_toplevel_decoration( + &toplevel, + &globals.qh, + surface.id(), + ) + }); + + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { + xdg_surface, + toplevel, + decoration, + }) + } + }; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); } - // Attempt to set up window decorations based on the requested configuration - let decoration = globals - .decoration_manager - .as_ref() - .map(|decoration_manager| { - decoration_manager.get_toplevel_decoration(&toplevel, &globals.qh, surface.id()) - }); - let viewport = globals .viewporter .as_ref() @@ -309,9 +414,7 @@ impl WaylandWindow { state: Rc::new(RefCell::new(WaylandWindowState::new( handle, surface.clone(), - xdg_surface, - toplevel, - decoration, + surface_state, appearance, viewport, client, @@ -403,7 +506,7 @@ impl WaylandWindowStatePtr { } } let mut state = self.state.borrow_mut(); - state.xdg_surface.ack_configure(serial); + state.surface_state.ack_configure(serial); let window_geometry = inset_by_tiling( state.bounds.map_origin(|_| px(0.0)), @@ -413,7 +516,7 @@ impl WaylandWindowStatePtr { .map(|v| v.0 as i32) .map_size(|v| if v <= 0 { 1 } else { v }); - state.xdg_surface.set_window_geometry( + state.surface_state.set_geometry( window_geometry.origin.x, window_geometry.origin.y, window_geometry.size.width, @@ -578,6 +681,42 @@ impl WaylandWindowStatePtr { } } + pub fn handle_layersurface_event(&self, event: zwlr_layer_surface_v1::Event) -> bool { + match event { + zwlr_layer_surface_v1::Event::Configure { + width, + height, + serial, + } => { + let mut size = if width == 0 || height == 0 { + None + } else { + Some(size(px(width as f32), px(height as f32))) + }; + + let mut state = self.state.borrow_mut(); + state.in_progress_configure = Some(InProgressConfigure { + size, + fullscreen: false, + maximized: false, + resizing: false, + tiling: Tiling::default(), + }); + drop(state); + + // just do the same thing we'd do as an xdg_surface + self.handle_xdg_surface_event(xdg_surface::Event::Configure { serial }); + + false + } + zwlr_layer_surface_v1::Event::Closed => { + // unlike xdg, we don't have a choice here: the surface is closing. + true + } + _ => false, + } + } + #[allow(clippy::mutable_key_type)] pub fn handle_surface_event( &self, @@ -840,7 +979,7 @@ impl PlatformWindow for WaylandWindow { let state_ptr = self.0.clone(); let dp_size = size.to_device_pixels(self.scale_factor()); - state.xdg_surface.set_window_geometry( + state.surface_state.set_geometry( state.bounds.origin.x.0 as i32, state.bounds.origin.y.0 as i32, dp_size.width.0, @@ -934,12 +1073,16 @@ impl PlatformWindow for WaylandWindow { } fn set_title(&mut self, title: &str) { - self.borrow().toplevel.set_title(title.to_string()); + if let Some(toplevel) = self.borrow().surface_state.toplevel() { + toplevel.set_title(title.to_string()); + } } fn set_app_id(&mut self, app_id: &str) { let mut state = self.borrow_mut(); - state.toplevel.set_app_id(app_id.to_owned()); + if let Some(toplevel) = self.borrow().surface_state.toplevel() { + toplevel.set_app_id(app_id.to_owned()); + } state.app_id = Some(app_id.to_owned()); } @@ -950,24 +1093,30 @@ impl PlatformWindow for WaylandWindow { } fn minimize(&self) { - self.borrow().toplevel.set_minimized(); + if let Some(toplevel) = self.borrow().surface_state.toplevel() { + toplevel.set_minimized(); + } } fn zoom(&self) { let state = self.borrow(); - if !state.maximized { - state.toplevel.set_maximized(); - } else { - state.toplevel.unset_maximized(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.maximized { + toplevel.set_maximized(); + } else { + toplevel.unset_maximized(); + } } } fn toggle_fullscreen(&self) { - let mut state = self.borrow_mut(); - if !state.fullscreen { - state.toplevel.set_fullscreen(None); - } else { - state.toplevel.unset_fullscreen(); + let mut state = self.borrow(); + if let Some(toplevel) = state.surface_state.toplevel() { + if !state.fullscreen { + toplevel.set_fullscreen(None); + } else { + toplevel.unset_fullscreen(); + } } } @@ -1032,27 +1181,33 @@ impl PlatformWindow for WaylandWindow { fn show_window_menu(&self, position: Point<Pixels>) { let state = self.borrow(); let serial = state.client.get_serial(SerialKind::MousePress); - state.toplevel.show_window_menu( - &state.globals.seat, - serial, - position.x.0 as i32, - position.y.0 as i32, - ); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.show_window_menu( + &state.globals.seat, + serial, + position.x.0 as i32, + position.y.0 as i32, + ); + } } fn start_window_move(&self) { let state = self.borrow(); let serial = state.client.get_serial(SerialKind::MousePress); - state.toplevel._move(&state.globals.seat, serial); + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel._move(&state.globals.seat, serial); + } } fn start_window_resize(&self, edge: crate::ResizeEdge) { let state = self.borrow(); - state.toplevel.resize( - &state.globals.seat, - state.client.get_serial(SerialKind::MousePress), - edge.to_xdg(), - ) + if let Some(toplevel) = state.surface_state.toplevel() { + toplevel.resize( + &state.globals.seat, + state.client.get_serial(SerialKind::MousePress), + edge.to_xdg(), + ) + } } fn window_decorations(&self) -> Decorations { @@ -1068,7 +1223,7 @@ impl PlatformWindow for WaylandWindow { fn request_decorations(&self, decorations: WindowDecorations) { let mut state = self.borrow_mut(); state.decorations = decorations; - if let Some(decoration) = state.decoration.as_ref() { + if let Some(decoration) = state.surface_state.decoration() { decoration.set_mode(decorations.to_xdg()); update_window(state); } diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index aedf131909..f01d33147b 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -559,7 +559,7 @@ impl MacWindow { } let native_window: id = match kind { - WindowKind::Normal => msg_send![WINDOW_CLASS, alloc], + WindowKind::Normal | WindowKind::Overlay => msg_send![WINDOW_CLASS, alloc], WindowKind::PopUp => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] @@ -711,7 +711,7 @@ impl MacWindow { native_window.makeFirstResponder_(native_view); match kind { - WindowKind::Normal => { + WindowKind::Normal | WindowKind::Overlay => { native_window.setLevel_(NSNormalWindowLevel); native_window.setAcceptsMouseMovedEvents_(YES); } diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs index 1aa4cd6d9f..7dde42efed 100644 --- a/crates/gpui/src/tab_stop.rs +++ b/crates/gpui/src/tab_stop.rs @@ -32,20 +32,18 @@ impl TabHandles { self.handles.clear(); } - fn current_index(&self, focused_id: Option<&FocusId>) -> usize { - self.handles - .iter() - .position(|h| Some(&h.id) == focused_id) - .unwrap_or_default() + fn current_index(&self, focused_id: Option<&FocusId>) -> Option<usize> { + self.handles.iter().position(|h| Some(&h.id) == focused_id) } pub(crate) fn next(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> { - let ix = self.current_index(focused_id); - - let mut next_ix = ix + 1; - if next_ix + 1 > self.handles.len() { - next_ix = 0; - } + let next_ix = self + .current_index(focused_id) + .and_then(|ix| { + let next_ix = ix + 1; + (next_ix < self.handles.len()).then_some(next_ix) + }) + .unwrap_or_default(); if let Some(next_handle) = self.handles.get(next_ix) { Some(next_handle.clone()) @@ -55,7 +53,7 @@ impl TabHandles { } pub(crate) fn prev(&self, focused_id: Option<&FocusId>) -> Option<FocusHandle> { - let ix = self.current_index(focused_id); + let ix = self.current_index(focused_id).unwrap_or_default(); let prev_ix; if ix == 0 { prev_ix = self.handles.len().saturating_sub(1); @@ -108,8 +106,14 @@ mod tests { ] ); - // next - assert_eq!(tab.next(None), Some(tab.handles[1].clone())); + // Select first tab index if no handle is currently focused. + assert_eq!(tab.next(None), Some(tab.handles[0].clone())); + // Select last tab index if no handle is currently focused. + assert_eq!( + tab.prev(None), + Some(tab.handles[tab.handles.len() - 1].clone()) + ); + assert_eq!( tab.next(Some(&tab.handles[0].id)), Some(tab.handles[1].clone()) diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index eebab86e21..434bd74fc8 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -4,6 +4,7 @@ pub mod github; pub use anyhow::{Result, anyhow}; pub use async_body::{AsyncBody, Inner}; use derive_more::Deref; +use http::HeaderValue; pub use http::{self, Method, Request, Response, StatusCode, Uri}; use futures::future::BoxFuture; @@ -39,6 +40,8 @@ impl HttpRequestExt for http::request::Builder { pub trait HttpClient: 'static + Send + Sync { fn type_name(&self) -> &'static str; + fn user_agent(&self) -> Option<&HeaderValue>; + fn send( &self, req: http::Request<AsyncBody>, @@ -118,6 +121,10 @@ impl HttpClient for HttpClientWithProxy { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.proxy.as_ref() } @@ -135,6 +142,10 @@ impl HttpClient for Arc<HttpClientWithProxy> { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.proxy.as_ref() } @@ -250,6 +261,10 @@ impl HttpClient for Arc<HttpClientWithUrl> { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.client.proxy.as_ref() } @@ -267,6 +282,10 @@ impl HttpClient for HttpClientWithUrl { self.client.send(req) } + fn user_agent(&self) -> Option<&HeaderValue> { + self.client.user_agent() + } + fn proxy(&self) -> Option<&Url> { self.client.proxy.as_ref() } @@ -314,6 +333,10 @@ impl HttpClient for BlockedHttpClient { }) } + fn user_agent(&self) -> Option<&HeaderValue> { + None + } + fn proxy(&self) -> Option<&Url> { None } @@ -334,6 +357,7 @@ type FakeHttpHandler = Box< #[cfg(feature = "test-support")] pub struct FakeHttpClient { handler: FakeHttpHandler, + user_agent: HeaderValue, } #[cfg(feature = "test-support")] @@ -348,6 +372,7 @@ impl FakeHttpClient { client: HttpClientWithProxy { client: Arc::new(Self { handler: Box::new(move |req| Box::pin(handler(req))), + user_agent: HeaderValue::from_static(type_name::<Self>()), }), proxy: None, }, @@ -390,6 +415,10 @@ impl HttpClient for FakeHttpClient { future } + fn user_agent(&self) -> Option<&HeaderValue> { + Some(&self.user_agent) + } + fn proxy(&self) -> Option<&Url> { None } diff --git a/crates/inline_completion_button/Cargo.toml b/crates/inline_completion_button/Cargo.toml index c2a619d500..b34e59336b 100644 --- a/crates/inline_completion_button/Cargo.toml +++ b/crates/inline_completion_button/Cargo.toml @@ -15,6 +15,7 @@ doctest = false [dependencies] anyhow.workspace = true client.workspace = true +cloud_llm_client.workspace = true copilot.workspace = true editor.workspace = true feature_flags.workspace = true @@ -32,7 +33,6 @@ ui.workspace = true workspace-hack.workspace = true workspace.workspace = true zed_actions.workspace = true -zed_llm_client.workspace = true zeta.workspace = true [dev-dependencies] diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index 2615a8beef..81d9181cfc 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -1,5 +1,6 @@ use anyhow::Result; use client::{DisableAiSettings, UserStore, zed_urls}; +use cloud_llm_client::UsageLimit; use copilot::{Copilot, Status}; use editor::{ Editor, SelectionEffects, @@ -34,7 +35,6 @@ use workspace::{ notifications::NotificationId, }; use zed_actions::OpenBrowser; -use zed_llm_client::UsageLimit; use zeta::RateCompletions; actions!( diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 7cda2b4b5a..549afc931c 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -166,7 +166,6 @@ pub struct CachedLspAdapter { pub reinstall_attempt_count: AtomicU64, cached_binary: futures::lock::Mutex<Option<LanguageServerBinary>>, manifest_name: OnceLock<Option<ManifestName>>, - attach_kind: OnceLock<Attach>, } impl Debug for CachedLspAdapter { @@ -202,7 +201,6 @@ impl CachedLspAdapter { adapter, cached_binary: Default::default(), reinstall_attempt_count: AtomicU64::new(0), - attach_kind: Default::default(), manifest_name: Default::default(), }) } @@ -288,29 +286,6 @@ impl CachedLspAdapter { .get_or_init(|| self.adapter.manifest_name()) .clone() } - pub fn attach_kind(&self) -> Attach { - *self.attach_kind.get_or_init(|| self.adapter.attach_kind()) - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Attach { - /// Create a single language server instance per subproject root. - InstancePerRoot, - /// Use one shared language server instance for all subprojects within a project. - Shared, -} - -impl Attach { - pub fn root_path( - &self, - root_subproject_path: (WorktreeId, Arc<Path>), - ) -> (WorktreeId, Arc<Path>) { - match self { - Attach::InstancePerRoot => root_subproject_path, - Attach::Shared => (root_subproject_path.0, Arc::from(Path::new(""))), - } - } } /// Determines what gets sent out as a workspace folders content @@ -611,10 +586,6 @@ pub trait LspAdapter: 'static + Send + Sync { Ok(original) } - fn attach_kind(&self) -> Attach { - Attach::Shared - } - /// Determines whether a language server supports workspace folders. /// /// And does not trip over itself in the process. diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index b718c530f5..841be60b0e 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -20,6 +20,7 @@ anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true base64.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true @@ -37,7 +38,6 @@ telemetry_events.workspace = true thiserror.workspace = true util.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 54640419b6..1637d2de8a 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -11,6 +11,7 @@ pub mod fake_provider; use anthropic::{AnthropicError, parse_prompt_too_long}; use anyhow::{Result, anyhow}; use client::Client; +use cloud_llm_client::{CompletionMode, CompletionRequestStatus}; use futures::FutureExt; use futures::{StreamExt, future::BoxFuture, stream::BoxStream}; use gpui::{AnyElement, AnyView, App, AsyncApp, SharedString, Task, Window}; @@ -26,7 +27,6 @@ use std::time::Duration; use std::{fmt, io}; use thiserror::Error; use util::serde::is_default; -use zed_llm_client::{CompletionMode, CompletionRequestStatus}; pub use crate::model::*; pub use crate::rate_limiter::*; diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index 6f3d420ad5..dc485e9937 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -1,10 +1,9 @@ use std::io::{Cursor, Write}; use std::sync::Arc; -use crate::role::Role; -use crate::{LanguageModelToolUse, LanguageModelToolUseId}; use anyhow::Result; use base64::write::EncoderWriter; +use cloud_llm_client::{CompletionIntent, CompletionMode}; use gpui::{ App, AppContext as _, DevicePixels, Image, ImageFormat, ObjectFit, SharedString, Size, Task, point, px, size, @@ -12,7 +11,9 @@ use gpui::{ use image::codecs::png::PngEncoder; use serde::{Deserialize, Serialize}; use util::ResultExt; -use zed_llm_client::{CompletionIntent, CompletionMode}; + +use crate::role::Role; +use crate::{LanguageModelToolUse, LanguageModelToolUseId}; #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] pub struct LanguageModelImage { diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 574579aaa7..208b0d99c9 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -16,18 +16,17 @@ ai_onboarding.workspace = true anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true aws-config = { workspace = true, features = ["behavior-version-latest"] } -aws-credential-types = { workspace = true, features = [ - "hardcoded-credentials", -] } +aws-credential-types = { workspace = true, features = ["hardcoded-credentials"] } aws_http_client.workspace = true bedrock.workspace = true chrono.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true component.workspace = true -credentials_provider.workspace = true convert_case.workspace = true copilot.workspace = true +credentials_provider.workspace = true deepseek = { workspace = true, features = ["schemars"] } editor.workspace = true futures.workspace = true @@ -35,6 +34,7 @@ google_ai = { workspace = true, features = ["schemars"] } gpui.workspace = true gpui_tokio.workspace = true http_client.workspace = true +language.workspace = true language_model.workspace = true lmstudio = { workspace = true, features = ["schemars"] } log.workspace = true @@ -43,8 +43,6 @@ mistral = { workspace = true, features = ["schemars"] } ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } -vercel = { workspace = true, features = ["schemars"] } -x_ai = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true proto.workspace = true release_channel.workspace = true @@ -61,9 +59,9 @@ tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } ui.workspace = true ui_input.workspace = true util.workspace = true +vercel = { workspace = true, features = ["schemars"] } workspace-hack.workspace = true -zed_llm_client.workspace = true -language.workspace = true +x_ai = { workspace = true, features = ["schemars"] } [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 09a2ac6e0a..3de135c5a2 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -3,6 +3,13 @@ use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; use client::{Client, ModelRequestUsage, UserStore, zed_urls}; +use cloud_llm_client::{ + CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CURRENT_PLAN_HEADER_NAME, CompletionBody, + CompletionEvent, CompletionRequestStatus, CountTokensBody, CountTokensResponse, + EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE, + SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME, + TOOL_USE_LIMIT_REACHED_HEADER_NAME, ZED_VERSION_HEADER_NAME, +}; use futures::{ AsyncBufReadExt, FutureExt, Stream, StreamExt, future::BoxFuture, stream::BoxStream, }; @@ -33,13 +40,6 @@ use std::time::Duration; use thiserror::Error; use ui::{TintColor, prelude::*}; use util::{ResultExt as _, maybe}; -use zed_llm_client::{ - CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CURRENT_PLAN_HEADER_NAME, CompletionBody, - CompletionRequestStatus, CountTokensBody, CountTokensResponse, EXPIRED_LLM_TOKEN_HEADER_NAME, - ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE, - SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME, - TOOL_USE_LIMIT_REACHED_HEADER_NAME, ZED_VERSION_HEADER_NAME, -}; use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, into_anthropic}; use crate::provider::google::{GoogleEventMapper, into_google}; @@ -120,10 +120,10 @@ pub struct State { user_store: Entity<UserStore>, status: client::Status, accept_terms_of_service_task: Option<Task<Result<()>>>, - models: Vec<Arc<zed_llm_client::LanguageModel>>, - default_model: Option<Arc<zed_llm_client::LanguageModel>>, - default_fast_model: Option<Arc<zed_llm_client::LanguageModel>>, - recommended_models: Vec<Arc<zed_llm_client::LanguageModel>>, + models: Vec<Arc<cloud_llm_client::LanguageModel>>, + default_model: Option<Arc<cloud_llm_client::LanguageModel>>, + default_fast_model: Option<Arc<cloud_llm_client::LanguageModel>>, + recommended_models: Vec<Arc<cloud_llm_client::LanguageModel>>, _fetch_models_task: Task<()>, _settings_subscription: Subscription, _llm_token_subscription: Subscription, @@ -238,8 +238,8 @@ impl State { // Right now we represent thinking variants of models as separate models on the client, // so we need to insert variants for any model that supports thinking. if model.supports_thinking { - models.push(Arc::new(zed_llm_client::LanguageModel { - id: zed_llm_client::LanguageModelId(format!("{}-thinking", model.id).into()), + models.push(Arc::new(cloud_llm_client::LanguageModel { + id: cloud_llm_client::LanguageModelId(format!("{}-thinking", model.id).into()), display_name: format!("{} Thinking", model.display_name), ..model })); @@ -328,7 +328,7 @@ impl CloudLanguageModelProvider { fn create_language_model( &self, - model: Arc<zed_llm_client::LanguageModel>, + model: Arc<cloud_llm_client::LanguageModel>, llm_api_token: LlmApiToken, ) -> Arc<dyn LanguageModel> { Arc::new(CloudLanguageModel { @@ -518,7 +518,7 @@ fn render_accept_terms( pub struct CloudLanguageModel { id: LanguageModelId, - model: Arc<zed_llm_client::LanguageModel>, + model: Arc<cloud_llm_client::LanguageModel>, llm_api_token: LlmApiToken, client: Arc<Client>, request_limiter: RateLimiter, @@ -611,12 +611,12 @@ impl CloudLanguageModel { .headers() .get(CURRENT_PLAN_HEADER_NAME) .and_then(|plan| plan.to_str().ok()) - .and_then(|plan| zed_llm_client::Plan::from_str(plan).ok()) + .and_then(|plan| cloud_llm_client::Plan::from_str(plan).ok()) { let plan = match plan { - zed_llm_client::Plan::ZedFree => Plan::Free, - zed_llm_client::Plan::ZedPro => Plan::ZedPro, - zed_llm_client::Plan::ZedProTrial => Plan::ZedProTrial, + cloud_llm_client::Plan::ZedFree => Plan::Free, + cloud_llm_client::Plan::ZedPro => Plan::ZedPro, + cloud_llm_client::Plan::ZedProTrial => Plan::ZedProTrial, }; return Err(anyhow!(ModelRequestLimitReachedError { plan })); } @@ -729,7 +729,7 @@ impl LanguageModel for CloudLanguageModel { } fn upstream_provider_id(&self) -> LanguageModelProviderId { - use zed_llm_client::LanguageModelProvider::*; + use cloud_llm_client::LanguageModelProvider::*; match self.model.provider { Anthropic => language_model::ANTHROPIC_PROVIDER_ID, OpenAi => language_model::OPEN_AI_PROVIDER_ID, @@ -738,7 +738,7 @@ impl LanguageModel for CloudLanguageModel { } fn upstream_provider_name(&self) -> LanguageModelProviderName { - use zed_llm_client::LanguageModelProvider::*; + use cloud_llm_client::LanguageModelProvider::*; match self.model.provider { Anthropic => language_model::ANTHROPIC_PROVIDER_NAME, OpenAi => language_model::OPEN_AI_PROVIDER_NAME, @@ -772,11 +772,11 @@ impl LanguageModel for CloudLanguageModel { fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { match self.model.provider { - zed_llm_client::LanguageModelProvider::Anthropic - | zed_llm_client::LanguageModelProvider::OpenAi => { + cloud_llm_client::LanguageModelProvider::Anthropic + | cloud_llm_client::LanguageModelProvider::OpenAi => { LanguageModelToolSchemaFormat::JsonSchema } - zed_llm_client::LanguageModelProvider::Google => { + cloud_llm_client::LanguageModelProvider::Google => { LanguageModelToolSchemaFormat::JsonSchemaSubset } } @@ -795,15 +795,15 @@ impl LanguageModel for CloudLanguageModel { fn cache_configuration(&self) -> Option<LanguageModelCacheConfiguration> { match &self.model.provider { - zed_llm_client::LanguageModelProvider::Anthropic => { + cloud_llm_client::LanguageModelProvider::Anthropic => { Some(LanguageModelCacheConfiguration { min_total_token: 2_048, should_speculate: true, max_cache_anchors: 4, }) } - zed_llm_client::LanguageModelProvider::OpenAi - | zed_llm_client::LanguageModelProvider::Google => None, + cloud_llm_client::LanguageModelProvider::OpenAi + | cloud_llm_client::LanguageModelProvider::Google => None, } } @@ -813,15 +813,17 @@ impl LanguageModel for CloudLanguageModel { cx: &App, ) -> BoxFuture<'static, Result<u64>> { match self.model.provider { - zed_llm_client::LanguageModelProvider::Anthropic => count_anthropic_tokens(request, cx), - zed_llm_client::LanguageModelProvider::OpenAi => { + cloud_llm_client::LanguageModelProvider::Anthropic => { + count_anthropic_tokens(request, cx) + } + cloud_llm_client::LanguageModelProvider::OpenAi => { let model = match open_ai::Model::from_id(&self.model.id.0) { Ok(model) => model, Err(err) => return async move { Err(anyhow!(err)) }.boxed(), }; count_open_ai_tokens(request, model, cx) } - zed_llm_client::LanguageModelProvider::Google => { + cloud_llm_client::LanguageModelProvider::Google => { let client = self.client.clone(); let llm_api_token = self.llm_api_token.clone(); let model_id = self.model.id.to_string(); @@ -832,7 +834,7 @@ impl LanguageModel for CloudLanguageModel { let token = llm_api_token.acquire(&client).await?; let request_body = CountTokensBody { - provider: zed_llm_client::LanguageModelProvider::Google, + provider: cloud_llm_client::LanguageModelProvider::Google, model: model_id, provider_request: serde_json::to_value(&google_ai::CountTokensRequest { generate_content_request, @@ -893,7 +895,7 @@ impl LanguageModel for CloudLanguageModel { let app_version = cx.update(|cx| AppVersion::global(cx)).ok(); let thinking_allowed = request.thinking_allowed; match self.model.provider { - zed_llm_client::LanguageModelProvider::Anthropic => { + cloud_llm_client::LanguageModelProvider::Anthropic => { let request = into_anthropic( request, self.model.id.to_string(), @@ -924,7 +926,7 @@ impl LanguageModel for CloudLanguageModel { prompt_id, intent, mode, - provider: zed_llm_client::LanguageModelProvider::Anthropic, + provider: cloud_llm_client::LanguageModelProvider::Anthropic, model: request.model.clone(), provider_request: serde_json::to_value(&request) .map_err(|e| anyhow!(e))?, @@ -948,7 +950,7 @@ impl LanguageModel for CloudLanguageModel { }); async move { Ok(future.await?.boxed()) }.boxed() } - zed_llm_client::LanguageModelProvider::OpenAi => { + cloud_llm_client::LanguageModelProvider::OpenAi => { let client = self.client.clone(); let model = match open_ai::Model::from_id(&self.model.id.0) { Ok(model) => model, @@ -976,7 +978,7 @@ impl LanguageModel for CloudLanguageModel { prompt_id, intent, mode, - provider: zed_llm_client::LanguageModelProvider::OpenAi, + provider: cloud_llm_client::LanguageModelProvider::OpenAi, model: request.model.clone(), provider_request: serde_json::to_value(&request) .map_err(|e| anyhow!(e))?, @@ -996,7 +998,7 @@ impl LanguageModel for CloudLanguageModel { }); async move { Ok(future.await?.boxed()) }.boxed() } - zed_llm_client::LanguageModelProvider::Google => { + cloud_llm_client::LanguageModelProvider::Google => { let client = self.client.clone(); let request = into_google(request, self.model.id.to_string(), GoogleModelMode::Default); @@ -1016,7 +1018,7 @@ impl LanguageModel for CloudLanguageModel { prompt_id, intent, mode, - provider: zed_llm_client::LanguageModelProvider::Google, + provider: cloud_llm_client::LanguageModelProvider::Google, model: request.model.model_id.clone(), provider_request: serde_json::to_value(&request) .map_err(|e| anyhow!(e))?, @@ -1040,15 +1042,8 @@ impl LanguageModel for CloudLanguageModel { } } -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CloudCompletionEvent<T> { - Status(CompletionRequestStatus), - Event(T), -} - fn map_cloud_completion_events<T, F>( - stream: Pin<Box<dyn Stream<Item = Result<CloudCompletionEvent<T>>> + Send>>, + stream: Pin<Box<dyn Stream<Item = Result<CompletionEvent<T>>> + Send>>, mut map_callback: F, ) -> BoxStream<'static, Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> where @@ -1063,10 +1058,10 @@ where Err(error) => { vec![Err(LanguageModelCompletionError::from(error))] } - Ok(CloudCompletionEvent::Status(event)) => { + Ok(CompletionEvent::Status(event)) => { vec![Ok(LanguageModelCompletionEvent::StatusUpdate(event))] } - Ok(CloudCompletionEvent::Event(event)) => map_callback(event), + Ok(CompletionEvent::Event(event)) => map_callback(event), }) }) .boxed() @@ -1074,9 +1069,9 @@ where fn usage_updated_event<T>( usage: Option<ModelRequestUsage>, -) -> impl Stream<Item = Result<CloudCompletionEvent<T>>> { +) -> impl Stream<Item = Result<CompletionEvent<T>>> { futures::stream::iter(usage.map(|usage| { - Ok(CloudCompletionEvent::Status( + Ok(CompletionEvent::Status( CompletionRequestStatus::UsageUpdated { amount: usage.amount as usize, limit: usage.limit, @@ -1087,9 +1082,9 @@ fn usage_updated_event<T>( fn tool_use_limit_reached_event<T>( tool_use_limit_reached: bool, -) -> impl Stream<Item = Result<CloudCompletionEvent<T>>> { +) -> impl Stream<Item = Result<CompletionEvent<T>>> { futures::stream::iter(tool_use_limit_reached.then(|| { - Ok(CloudCompletionEvent::Status( + Ok(CompletionEvent::Status( CompletionRequestStatus::ToolUseLimitReached, )) })) @@ -1098,7 +1093,7 @@ fn tool_use_limit_reached_event<T>( fn response_lines<T: DeserializeOwned>( response: Response<AsyncBody>, includes_status_messages: bool, -) -> impl Stream<Item = Result<CloudCompletionEvent<T>>> { +) -> impl Stream<Item = Result<CompletionEvent<T>>> { futures::stream::try_unfold( (String::new(), BufReader::new(response.into_body())), move |(mut line, mut body)| async move { @@ -1106,9 +1101,9 @@ fn response_lines<T: DeserializeOwned>( Ok(0) => Ok(None), Ok(_) => { let event = if includes_status_messages { - serde_json::from_str::<CloudCompletionEvent<T>>(&line)? + serde_json::from_str::<CompletionEvent<T>>(&line)? } else { - CloudCompletionEvent::Event(serde_json::from_str::<T>(&line)?) + CompletionEvent::Event(serde_json::from_str::<T>(&line)?) }; line.clear(); diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index d9a84f1eb7..3cdc2e5401 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -3,6 +3,7 @@ use std::str::FromStr as _; use std::sync::Arc; use anyhow::{Result, anyhow}; +use cloud_llm_client::CompletionIntent; use collections::HashMap; use copilot::copilot_chat::{ ChatMessage, ChatMessageContent, ChatMessagePart, CopilotChat, ImageUrl, @@ -30,7 +31,6 @@ use settings::SettingsStore; use std::time::Duration; use ui::prelude::*; use util::debug_panic; -use zed_llm_client::CompletionIntent; use super::anthropic::count_anthropic_tokens; use super::google::count_google_tokens; diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 4a0cc7078b..0524c02fd5 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -1625,6 +1625,10 @@ impl LspAdapter for BasedPyrightLspAdapter { fn manifest_name(&self) -> Option<ManifestName> { Some(SharedString::new_static("pyproject.toml").into()) } + + fn workspace_folders_content(&self) -> WorkspaceFoldersContent { + WorkspaceFoldersContent::WorktreeRoot + } } #[cfg(test)] diff --git a/crates/onboarding/Cargo.toml b/crates/onboarding/Cargo.toml index 6ec8f8b162..da009b4e4e 100644 --- a/crates/onboarding/Cargo.toml +++ b/crates/onboarding/Cargo.toml @@ -18,12 +18,15 @@ default = [] anyhow.workspace = true command_palette_hooks.workspace = true db.workspace = true +editor.workspace = true feature_flags.workspace = true fs.workspace = true gpui.workspace = true +language.workspace = true +project.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true -workspace.workspace = true workspace-hack.workspace = true +workspace.workspace = true zed_actions.workspace = true diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs new file mode 100644 index 0000000000..c07d8fef4d --- /dev/null +++ b/crates/onboarding/src/editing_page.rs @@ -0,0 +1,287 @@ +use editor::{EditorSettings, ShowMinimap}; +use fs::Fs; +use gpui::{App, IntoElement, Pixels, Window}; +use language::language_settings::AllLanguageSettings; +use project::project_settings::ProjectSettings; +use settings::{Settings as _, update_settings_file}; +use theme::{FontFamilyCache, FontFamilyName, ThemeSettings}; +use ui::{ + ContextMenu, DropdownMenu, IconButton, Label, LabelCommon, LabelSize, NumericStepper, + ParentElement, SharedString, Styled, SwitchColor, SwitchField, ToggleButtonGroup, + ToggleButtonGroupStyle, ToggleButtonSimple, ToggleState, div, h_flex, px, v_flex, +}; + +fn read_show_mini_map(cx: &App) -> ShowMinimap { + editor::EditorSettings::get_global(cx).minimap.show +} + +fn write_show_mini_map(show: ShowMinimap, cx: &mut App) { + let fs = <dyn Fs>::global(cx); + + update_settings_file::<EditorSettings>(fs, cx, move |editor_settings, _| { + editor_settings.minimap.get_or_insert_default().show = Some(show); + }); +} + +fn read_inlay_hints(cx: &App) -> bool { + AllLanguageSettings::get_global(cx) + .defaults + .inlay_hints + .enabled +} + +fn write_inlay_hints(enabled: bool, cx: &mut App) { + let fs = <dyn Fs>::global(cx); + + update_settings_file::<AllLanguageSettings>(fs, cx, move |all_language_settings, cx| { + all_language_settings + .defaults + .inlay_hints + .get_or_insert_with(|| { + AllLanguageSettings::get_global(cx) + .clone() + .defaults + .inlay_hints + }) + .enabled = enabled; + }); +} + +fn read_git_blame(cx: &App) -> bool { + ProjectSettings::get_global(cx).git.inline_blame_enabled() +} + +fn set_git_blame(enabled: bool, cx: &mut App) { + let fs = <dyn Fs>::global(cx); + + update_settings_file::<ProjectSettings>(fs, cx, move |project_settings, _| { + project_settings + .git + .inline_blame + .get_or_insert_default() + .enabled = enabled; + }); +} + +fn write_ui_font_family(font: SharedString, cx: &mut App) { + let fs = <dyn Fs>::global(cx); + + update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| { + theme_settings.ui_font_family = Some(FontFamilyName(font.into())); + }); +} + +fn write_ui_font_size(size: Pixels, cx: &mut App) { + let fs = <dyn Fs>::global(cx); + + update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| { + theme_settings.ui_font_size = Some(size.into()); + }); +} + +fn write_buffer_font_size(size: Pixels, cx: &mut App) { + let fs = <dyn Fs>::global(cx); + + update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| { + theme_settings.buffer_font_size = Some(size.into()); + }); +} + +fn write_buffer_font_family(font_family: SharedString, cx: &mut App) { + let fs = <dyn Fs>::global(cx); + + update_settings_file::<ThemeSettings>(fs, cx, move |theme_settings, _| { + theme_settings.buffer_font_family = Some(FontFamilyName(font_family.into())); + }); +} + +pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme_settings = ThemeSettings::get_global(cx); + let ui_font_size = theme_settings.ui_font_size(cx); + let font_family = theme_settings.buffer_font.family.clone(); + let buffer_font_size = theme_settings.buffer_font_size(cx); + + v_flex() + .gap_4() + .child(Label::new("Import Settings").size(LabelSize::Large)) + .child( + Label::new("Automatically pull your settings from other editors.") + .size(LabelSize::Small), + ) + .child( + h_flex() + .child(IconButton::new( + "import-vs-code-settings", + ui::IconName::Code, + )) + .child(IconButton::new( + "import-cursor-settings", + ui::IconName::CursorIBeam, + )), + ) + .child(Label::new("Popular Settings").size(LabelSize::Large)) + .child( + h_flex() + .gap_4() + .justify_between() + .child( + v_flex() + .justify_between() + .gap_1() + .child(Label::new("UI Font")) + .child( + h_flex() + .justify_between() + .gap_2() + .child(div().min_w(px(120.)).child(DropdownMenu::new( + "ui-font-family", + theme_settings.ui_font.family.clone(), + ContextMenu::build(window, cx, |mut menu, _, cx| { + let font_family_cache = FontFamilyCache::global(cx); + + for font_name in font_family_cache.list_font_families(cx) { + menu = menu.custom_entry( + { + let font_name = font_name.clone(); + move |_window, _cx| { + Label::new(font_name.clone()) + .into_any_element() + } + }, + { + let font_name = font_name.clone(); + move |_window, cx| { + write_ui_font_family(font_name.clone(), cx); + } + }, + ) + } + + menu + }), + ))) + .child(NumericStepper::new( + "ui-font-size", + ui_font_size.to_string(), + move |_, _, cx| { + write_ui_font_size(ui_font_size - px(1.), cx); + }, + move |_, _, cx| { + write_ui_font_size(ui_font_size + px(1.), cx); + }, + )), + ), + ) + .child( + v_flex() + .justify_between() + .gap_1() + .child(Label::new("Editor Font")) + .child( + h_flex() + .justify_between() + .gap_2() + .child(DropdownMenu::new( + "buffer-font-family", + font_family, + ContextMenu::build(window, cx, |mut menu, _, cx| { + let font_family_cache = FontFamilyCache::global(cx); + + for font_name in font_family_cache.list_font_families(cx) { + menu = menu.custom_entry( + { + let font_name = font_name.clone(); + move |_window, _cx| { + Label::new(font_name.clone()) + .into_any_element() + } + }, + { + let font_name = font_name.clone(); + move |_window, cx| { + write_buffer_font_family( + font_name.clone(), + cx, + ); + } + }, + ) + } + + menu + }), + )) + .child(NumericStepper::new( + "buffer-font-size", + buffer_font_size.to_string(), + move |_, _, cx| { + write_buffer_font_size(buffer_font_size - px(1.), cx); + }, + move |_, _, cx| { + write_buffer_font_size(buffer_font_size + px(1.), cx); + }, + )), + ), + ), + ) + .child( + h_flex() + .justify_between() + .child(Label::new("Mini Map")) + .child( + ToggleButtonGroup::single_row( + "onboarding-show-mini-map", + [ + ToggleButtonSimple::new("Auto", |_, _, cx| { + write_show_mini_map(ShowMinimap::Auto, cx); + }), + ToggleButtonSimple::new("Always", |_, _, cx| { + write_show_mini_map(ShowMinimap::Always, cx); + }), + ToggleButtonSimple::new("Never", |_, _, cx| { + write_show_mini_map(ShowMinimap::Never, cx); + }), + ], + ) + .selected_index(match read_show_mini_map(cx) { + ShowMinimap::Auto => 0, + ShowMinimap::Always => 1, + ShowMinimap::Never => 2, + }) + .style(ToggleButtonGroupStyle::Outlined) + .button_width(ui::rems_from_px(64.)), + ), + ) + .child( + SwitchField::new( + "onboarding-enable-inlay-hints", + "Inlay Hints", + "See parameter names for function and method calls inline.", + if read_inlay_hints(cx) { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + |toggle_state, _, cx| { + write_inlay_hints(toggle_state == &ToggleState::Selected, cx); + }, + ) + .color(SwitchColor::Accent), + ) + .child( + SwitchField::new( + "onboarding-git-blame-switch", + "Git Blame", + "See who committed each line on a given file.", + if read_git_blame(cx) { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + |toggle_state, _, cx| { + set_git_blame(toggle_state == &ToggleState::Selected, cx); + }, + ) + .color(SwitchColor::Accent), + ) +} diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index b675ed2dd7..cc0c47ca71 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -21,6 +21,7 @@ use workspace::{ open_new, with_active_or_new_workspace, }; +mod editing_page; mod welcome; pub struct OnBoardingFeatureFlag {} @@ -246,7 +247,9 @@ impl Onboarding { fn render_page(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement { match self.selected_page { SelectedPage::Basics => self.render_basics_page(window, cx).into_any_element(), - SelectedPage::Editing => self.render_editing_page(window, cx).into_any_element(), + SelectedPage::Editing => { + crate::editing_page::render_editing_page(window, cx).into_any_element() + } SelectedPage::AiSetup => self.render_ai_setup_page(window, cx).into_any_element(), } } @@ -281,11 +284,6 @@ impl Onboarding { ) } - fn render_editing_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { - // div().child("editing page") - "Right" - } - fn render_ai_setup_page(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { div().child("ai setup page") } diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 2f3b188980..47a0f12c06 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -35,6 +35,7 @@ pub fn remote_server_dir_relative() -> &'static Path { /// Sets a custom directory for all user data, overriding the default data directory. /// This function must be called before any other path operations that depend on the data directory. +/// The directory's path will be canonicalized to an absolute path by a blocking FS operation. /// The directory will be created if it doesn't exist. /// /// # Arguments @@ -50,13 +51,20 @@ pub fn remote_server_dir_relative() -> &'static Path { /// /// Panics if: /// * Called after the data directory has been initialized (e.g., via `data_dir` or `config_dir`) +/// * The directory's path cannot be canonicalized to an absolute path /// * The directory cannot be created pub fn set_custom_data_dir(dir: &str) -> &'static PathBuf { if CURRENT_DATA_DIR.get().is_some() || CONFIG_DIR.get().is_some() { panic!("set_custom_data_dir called after data_dir or config_dir was initialized"); } CUSTOM_DATA_DIR.get_or_init(|| { - let path = PathBuf::from(dir); + let mut path = PathBuf::from(dir); + if path.is_relative() { + let abs_path = path + .canonicalize() + .expect("failed to canonicalize custom data directory's path to an absolute path"); + path = PathBuf::from(util::paths::SanitizedPath::from(abs_path)) + } std::fs::create_dir_all(&path).expect("failed to create custom data directory"); path }) diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index ceec0c0a52..c96ab4e8f3 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -13,6 +13,7 @@ use settings::{Settings as _, SettingsStore}; use util::ResultExt as _; use crate::{ + Project, project_settings::{ContextServerSettings, ProjectSettings}, worktree_store::WorktreeStore, }; @@ -144,6 +145,7 @@ pub struct ContextServerStore { context_server_settings: HashMap<Arc<str>, ContextServerSettings>, servers: HashMap<ContextServerId, ContextServerState>, worktree_store: Entity<WorktreeStore>, + project: WeakEntity<Project>, registry: Entity<ContextServerDescriptorRegistry>, update_servers_task: Option<Task<Result<()>>>, context_server_factory: Option<ContextServerFactory>, @@ -161,12 +163,17 @@ pub enum Event { impl EventEmitter<Event> for ContextServerStore {} impl ContextServerStore { - pub fn new(worktree_store: Entity<WorktreeStore>, cx: &mut Context<Self>) -> Self { + pub fn new( + worktree_store: Entity<WorktreeStore>, + weak_project: WeakEntity<Project>, + cx: &mut Context<Self>, + ) -> Self { Self::new_internal( true, None, ContextServerDescriptorRegistry::default_global(cx), worktree_store, + weak_project, cx, ) } @@ -184,9 +191,10 @@ impl ContextServerStore { pub fn test( registry: Entity<ContextServerDescriptorRegistry>, worktree_store: Entity<WorktreeStore>, + weak_project: WeakEntity<Project>, cx: &mut Context<Self>, ) -> Self { - Self::new_internal(false, None, registry, worktree_store, cx) + Self::new_internal(false, None, registry, worktree_store, weak_project, cx) } #[cfg(any(test, feature = "test-support"))] @@ -194,6 +202,7 @@ impl ContextServerStore { context_server_factory: ContextServerFactory, registry: Entity<ContextServerDescriptorRegistry>, worktree_store: Entity<WorktreeStore>, + weak_project: WeakEntity<Project>, cx: &mut Context<Self>, ) -> Self { Self::new_internal( @@ -201,6 +210,7 @@ impl ContextServerStore { Some(context_server_factory), registry, worktree_store, + weak_project, cx, ) } @@ -210,6 +220,7 @@ impl ContextServerStore { context_server_factory: Option<ContextServerFactory>, registry: Entity<ContextServerDescriptorRegistry>, worktree_store: Entity<WorktreeStore>, + weak_project: WeakEntity<Project>, cx: &mut Context<Self>, ) -> Self { let subscriptions = if maintain_server_loop { @@ -235,6 +246,7 @@ impl ContextServerStore { context_server_settings: Self::resolve_context_server_settings(&worktree_store, cx) .clone(), worktree_store, + project: weak_project, registry, needs_server_update: false, servers: HashMap::default(), @@ -360,7 +372,7 @@ impl ContextServerStore { let configuration = state.configuration(); self.stop_server(&state.server().id(), cx)?; - let new_server = self.create_context_server(id.clone(), configuration.clone())?; + let new_server = self.create_context_server(id.clone(), configuration.clone(), cx); self.run_server(new_server, configuration, cx); } Ok(()) @@ -449,14 +461,33 @@ impl ContextServerStore { &self, id: ContextServerId, configuration: Arc<ContextServerConfiguration>, - ) -> Result<Arc<ContextServer>> { + cx: &mut Context<Self>, + ) -> Arc<ContextServer> { + let root_path = self + .project + .read_with(cx, |project, cx| project.active_project_directory(cx)) + .ok() + .flatten() + .or_else(|| { + self.worktree_store.read_with(cx, |store, cx| { + store.visible_worktrees(cx).fold(None, |acc, item| { + if acc.is_none() { + item.read(cx).root_dir() + } else { + acc + } + }) + }) + }); + if let Some(factory) = self.context_server_factory.as_ref() { - Ok(factory(id, configuration)) + factory(id, configuration) } else { - Ok(Arc::new(ContextServer::stdio( + Arc::new(ContextServer::stdio( id, configuration.command().clone(), - ))) + root_path, + )) } } @@ -553,7 +584,7 @@ impl ContextServerStore { let mut servers_to_remove = HashSet::default(); let mut servers_to_stop = HashSet::default(); - this.update(cx, |this, _cx| { + this.update(cx, |this, cx| { for server_id in this.servers.keys() { // All servers that are not in desired_servers should be removed from the store. // This can happen if the user removed a server from the context server settings. @@ -572,14 +603,10 @@ impl ContextServerStore { let existing_config = state.as_ref().map(|state| state.configuration()); if existing_config.as_deref() != Some(&config) || is_stopped { let config = Arc::new(config); - if let Some(server) = this - .create_context_server(id.clone(), config.clone()) - .log_err() - { - servers_to_start.push((server, config)); - if this.servers.contains_key(&id) { - servers_to_stop.insert(id); - } + let server = this.create_context_server(id.clone(), config.clone(), cx); + servers_to_start.push((server, config)); + if this.servers.contains_key(&id) { + servers_to_stop.insert(id); } } } @@ -630,7 +657,12 @@ mod tests { let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let store = cx.new(|cx| { - ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx) + ContextServerStore::test( + registry.clone(), + project.read(cx).worktree_store(), + project.downgrade(), + cx, + ) }); let server_1_id = ContextServerId(SERVER_1_ID.into()); @@ -705,7 +737,12 @@ mod tests { let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let store = cx.new(|cx| { - ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx) + ContextServerStore::test( + registry.clone(), + project.read(cx).worktree_store(), + project.downgrade(), + cx, + ) }); let server_1_id = ContextServerId(SERVER_1_ID.into()); @@ -758,7 +795,12 @@ mod tests { let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let store = cx.new(|cx| { - ContextServerStore::test(registry.clone(), project.read(cx).worktree_store(), cx) + ContextServerStore::test( + registry.clone(), + project.read(cx).worktree_store(), + project.downgrade(), + cx, + ) }); let server_id = ContextServerId(SERVER_1_ID.into()); @@ -842,6 +884,7 @@ mod tests { }), registry.clone(), project.read(cx).worktree_store(), + project.downgrade(), cx, ) }); @@ -1074,6 +1117,7 @@ mod tests { }), registry.clone(), project.read(cx).worktree_store(), + project.downgrade(), cx, ) }); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index defe056dd8..5a8cc05d7d 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -2425,36 +2425,12 @@ impl LocalLspStore { let server_id = server_node.server_id_or_init( |LaunchDisposition { server_name, - attach, + path, settings, }| { - let server_id = match attach { - language::Attach::InstancePerRoot => { - // todo: handle instance per root proper. - if let Some(server_ids) = self - .language_server_ids - .get(&(worktree_id, server_name.clone())) - { - server_ids.iter().cloned().next().unwrap() - } else { - let language_name = language.name(); - let adapter = self.languages - .lsp_adapters(&language_name) - .into_iter() - .find(|adapter| &adapter.name() == server_name) - .expect("To find LSP adapter"); - let server_id = self.start_language_server( - &worktree, - delegate.clone(), - adapter, - settings, - cx, - ); - server_id - } - } - language::Attach::Shared => { + let server_id = + { let uri = Url::from_file_path( worktree.read(cx).abs_path().join(&path.path), ); @@ -2489,7 +2465,7 @@ impl LocalLspStore { } else { unreachable!("Language server ID should be available, as it's registered on demand") } - } + }; let lsp_store = self.weak.clone(); let server_name = server_node.name(); @@ -4705,35 +4681,11 @@ impl LspStore { let server_id = node.server_id_or_init( |LaunchDisposition { server_name, - attach, + path, settings, - }| match attach { - language::Attach::InstancePerRoot => { - // todo: handle instance per root proper. - if let Some(server_ids) = local - .language_server_ids - .get(&(worktree_id, server_name.clone())) - { - server_ids.iter().cloned().next().unwrap() - } else { - let adapter = local - .languages - .lsp_adapters(&language) - .into_iter() - .find(|adapter| &adapter.name() == server_name) - .expect("To find LSP adapter"); - let server_id = local.start_language_server( - &worktree, - delegate.clone(), - adapter, - settings, - cx, - ); - server_id - } - } - language::Attach::Shared => { + }| + { let uri = Url::from_file_path( worktree.read(cx).abs_path().join(&path.path), ); @@ -4762,7 +4714,6 @@ impl LspStore { } server_id } - }, ); if let Some(language_server_id) = server_id { @@ -7442,21 +7393,23 @@ impl LspStore { } pub(crate) async fn refresh_workspace_configurations( - this: &WeakEntity<Self>, + lsp_store: &WeakEntity<Self>, fs: Arc<dyn Fs>, cx: &mut AsyncApp, ) { maybe!(async move { - let servers = this - .update(cx, |this, cx| { - let Some(local) = this.as_local() else { + let mut refreshed_servers = HashSet::default(); + let servers = lsp_store + .update(cx, |lsp_store, cx| { + let toolchain_store = lsp_store.toolchain_store(cx); + let Some(local) = lsp_store.as_local() else { return Vec::default(); }; local .language_server_ids .iter() .flat_map(|((worktree_id, _), server_ids)| { - let worktree = this + let worktree = lsp_store .worktree_store .read(cx) .worktree_for_id(*worktree_id, cx); @@ -7472,43 +7425,54 @@ impl LspStore { ) }); - server_ids.iter().filter_map(move |server_id| { + let fs = fs.clone(); + let toolchain_store = toolchain_store.clone(); + server_ids.iter().filter_map(|server_id| { + let delegate = delegate.clone()? as Arc<dyn LspAdapterDelegate>; let states = local.language_servers.get(server_id)?; match states { LanguageServerState::Starting { .. } => None, LanguageServerState::Running { adapter, server, .. - } => Some(( - adapter.adapter.clone(), - server.clone(), - delegate.clone()? as Arc<dyn LspAdapterDelegate>, - )), + } => { + let fs = fs.clone(); + let toolchain_store = toolchain_store.clone(); + let adapter = adapter.clone(); + let server = server.clone(); + refreshed_servers.insert(server.name()); + Some(cx.spawn(async move |_, cx| { + let settings = + LocalLspStore::workspace_configuration_for_adapter( + adapter.adapter.clone(), + fs.as_ref(), + &delegate, + toolchain_store, + cx, + ) + .await + .ok()?; + server + .notify::<lsp::notification::DidChangeConfiguration>( + &lsp::DidChangeConfigurationParams { settings }, + ) + .ok()?; + Some(()) + })) + } } - }) + }).collect::<Vec<_>>() }) .collect::<Vec<_>>() }) .ok()?; - let toolchain_store = this.update(cx, |this, cx| this.toolchain_store(cx)).ok()?; - for (adapter, server, delegate) in servers { - let settings = LocalLspStore::workspace_configuration_for_adapter( - adapter, - fs.as_ref(), - &delegate, - toolchain_store.clone(), - cx, - ) - .await - .ok()?; - - server - .notify::<lsp::notification::DidChangeConfiguration>( - &lsp::DidChangeConfigurationParams { settings }, - ) - .ok(); - } + log::info!("Refreshing workspace configurations for servers {refreshed_servers:?}"); + // TODO this asynchronous job runs concurrently with extension (de)registration and may take enough time for a certain extension + // to stop and unregister its language server wrapper. + // This is racy : an extension might have already removed all `local.language_servers` state, but here we `.clone()` and hold onto it anyway. + // This now causes errors in the logs, we should find a way to remove such servers from the processing everywhere. + let _: Vec<Option<()>> = join_all(servers).await; Some(()) }) .await; diff --git a/crates/project/src/manifest_tree/server_tree.rs b/crates/project/src/manifest_tree/server_tree.rs index 0283f06eec..81cb1c450c 100644 --- a/crates/project/src/manifest_tree/server_tree.rs +++ b/crates/project/src/manifest_tree/server_tree.rs @@ -13,10 +13,10 @@ use std::{ sync::{Arc, Weak}, }; -use collections::{HashMap, IndexMap}; +use collections::IndexMap; use gpui::{App, AppContext as _, Entity, Subscription}; use language::{ - Attach, CachedLspAdapter, LanguageName, LanguageRegistry, ManifestDelegate, + CachedLspAdapter, LanguageName, LanguageRegistry, ManifestDelegate, language_settings::AllLanguageSettings, }; use lsp::LanguageServerName; @@ -38,7 +38,6 @@ pub(crate) struct ServersForWorktree { pub struct LanguageServerTree { manifest_tree: Entity<ManifestTree>, pub(crate) instances: BTreeMap<WorktreeId, ServersForWorktree>, - attach_kind_cache: HashMap<LanguageServerName, Attach>, languages: Arc<LanguageRegistry>, _subscriptions: Subscription, } @@ -53,7 +52,6 @@ pub struct LanguageServerTreeNode(Weak<InnerTreeNode>); #[derive(Debug)] pub(crate) struct LaunchDisposition<'a> { pub(crate) server_name: &'a LanguageServerName, - pub(crate) attach: Attach, pub(crate) path: ProjectPath, pub(crate) settings: Arc<LspSettings>, } @@ -62,7 +60,6 @@ impl<'a> From<&'a InnerTreeNode> for LaunchDisposition<'a> { fn from(value: &'a InnerTreeNode) -> Self { LaunchDisposition { server_name: &value.name, - attach: value.attach, path: value.path.clone(), settings: value.settings.clone(), } @@ -105,7 +102,6 @@ impl From<Weak<InnerTreeNode>> for LanguageServerTreeNode { pub struct InnerTreeNode { id: OnceLock<LanguageServerId>, name: LanguageServerName, - attach: Attach, path: ProjectPath, settings: Arc<LspSettings>, } @@ -113,14 +109,12 @@ pub struct InnerTreeNode { impl InnerTreeNode { fn new( name: LanguageServerName, - attach: Attach, path: ProjectPath, settings: impl Into<Arc<LspSettings>>, ) -> Self { InnerTreeNode { id: Default::default(), name, - attach, path, settings: settings.into(), } @@ -130,8 +124,11 @@ impl InnerTreeNode { /// Determines how the list of adapters to query should be constructed. pub(crate) enum AdapterQuery<'a> { /// Search for roots of all adapters associated with a given language name. + /// Layman: Look for all project roots along the queried path that have any + /// language server associated with this language running. Language(&'a LanguageName), /// Search for roots of adapter with a given name. + /// Layman: Look for all project roots along the queried path that have this server running. Adapter(&'a LanguageServerName), } @@ -147,7 +144,7 @@ impl LanguageServerTree { }), manifest_tree, instances: Default::default(), - attach_kind_cache: Default::default(), + languages, }) } @@ -223,7 +220,6 @@ impl LanguageServerTree { .and_then(|name| roots.get(&name)) .cloned() .unwrap_or_else(|| root_path.clone()); - let attach = adapter.attach_kind(); let inner_node = self .instances @@ -237,7 +233,6 @@ impl LanguageServerTree { ( Arc::new(InnerTreeNode::new( adapter.name(), - attach, root_path.clone(), settings.clone(), )), @@ -379,7 +374,6 @@ pub(crate) struct ServerTreeRebase<'a> { impl<'tree> ServerTreeRebase<'tree> { fn new(new_tree: &'tree mut LanguageServerTree) -> Self { let old_contents = std::mem::take(&mut new_tree.instances); - new_tree.attach_kind_cache.clear(); let all_server_ids = old_contents .values() .flat_map(|nodes| { @@ -446,10 +440,7 @@ impl<'tree> ServerTreeRebase<'tree> { .get(&disposition.path.worktree_id) .and_then(|worktree_nodes| worktree_nodes.roots.get(&disposition.path.path)) .and_then(|roots| roots.get(&disposition.name)) - .filter(|(old_node, _)| { - disposition.attach == old_node.attach - && disposition.settings == old_node.settings - }) + .filter(|(old_node, _)| disposition.settings == old_node.settings) else { return Some(node); }; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index a4e76ed475..6b943216b3 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -998,8 +998,9 @@ impl Project { cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); + let weak_self = cx.weak_entity(); let context_server_store = - cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx)); + cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx)); let environment = cx.new(|_| ProjectEnvironment::new(env)); let manifest_tree = ManifestTree::new(worktree_store.clone(), cx); @@ -1167,8 +1168,9 @@ impl Project { cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); + let weak_self = cx.weak_entity(); let context_server_store = - cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx)); + cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx)); let buffer_store = cx.new(|cx| { BufferStore::remote( @@ -1428,8 +1430,6 @@ impl Project { let image_store = cx.new(|cx| { ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) })?; - let context_server_store = - cx.new(|cx| ContextServerStore::new(worktree_store.clone(), cx))?; let environment = cx.new(|_| ProjectEnvironment::new(None))?; @@ -1496,6 +1496,10 @@ impl Project { let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([]), cx); + let weak_self = cx.weak_entity(); + let context_server_store = + cx.new(|cx| ContextServerStore::new(worktree_store.clone(), weak_self, cx)); + let mut worktrees = Vec::new(); for worktree in response.payload.worktrees { let worktree = diff --git a/crates/reqwest_client/src/reqwest_client.rs b/crates/reqwest_client/src/reqwest_client.rs index daff20ac4a..e02768876d 100644 --- a/crates/reqwest_client/src/reqwest_client.rs +++ b/crates/reqwest_client/src/reqwest_client.rs @@ -20,6 +20,7 @@ static REDACT_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"key=[^&]+") pub struct ReqwestClient { client: reqwest::Client, proxy: Option<Url>, + user_agent: Option<HeaderValue>, handle: tokio::runtime::Handle, } @@ -44,9 +45,11 @@ impl ReqwestClient { Ok(client.into()) } - pub fn proxy_and_user_agent(proxy: Option<Url>, agent: &str) -> anyhow::Result<Self> { + pub fn proxy_and_user_agent(proxy: Option<Url>, user_agent: &str) -> anyhow::Result<Self> { + let user_agent = HeaderValue::from_str(user_agent)?; + let mut map = HeaderMap::new(); - map.insert(http::header::USER_AGENT, HeaderValue::from_str(agent)?); + map.insert(http::header::USER_AGENT, user_agent.clone()); let mut client = Self::builder().default_headers(map); let client_has_proxy; @@ -73,6 +76,7 @@ impl ReqwestClient { .build()?; let mut client: ReqwestClient = client.into(); client.proxy = client_has_proxy.then_some(proxy).flatten(); + client.user_agent = Some(user_agent); Ok(client) } } @@ -96,6 +100,7 @@ impl From<reqwest::Client> for ReqwestClient { client, handle, proxy: None, + user_agent: None, } } } @@ -216,6 +221,10 @@ impl http_client::HttpClient for ReqwestClient { type_name::<Self>() } + fn user_agent(&self) -> Option<&HeaderValue> { + self.user_agent.as_ref() + } + fn send( &self, req: http::Request<http_client::AsyncBody>, diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 25f033469d..e8434c1a32 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -48,3 +48,7 @@ workspace.workspace = true [dev-dependencies] db = {"workspace"= true, "features" = ["test-support"]} +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 5ff91246f4..70afe1729c 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -11,11 +11,10 @@ use editor::{CompletionProvider, Editor, EditorEvent}; use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context, - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero, - KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, - ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, - actions, anchored, deferred, div, + Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity, + EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyContext, Keystroke, MouseButton, + Point, ScrollStrategy, ScrollWheelEvent, Stateful, StyledText, Subscription, Task, + TextStyleRefinement, WeakEntity, actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; @@ -35,7 +34,10 @@ use workspace::{ use crate::{ keybindings::persistence::KEYBINDING_EDITORS, - ui_components::table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState}, + ui_components::{ + keystroke_input::{ClearKeystrokes, KeystrokeInput, StartRecording, StopRecording}, + table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState}, + }, }; const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static("<no arguments>"); @@ -72,18 +74,6 @@ actions!( ] ); -actions!( - keystroke_input, - [ - /// Starts recording keystrokes - StartRecording, - /// Stops recording keystrokes - StopRecording, - /// Clears the recorded keystrokes - ClearKeystrokes, - ] -); - pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); @@ -393,7 +383,7 @@ impl KeymapEditor { let keystroke_editor = cx.new(|cx| { let mut keystroke_editor = KeystrokeInput::new(None, window, cx); - keystroke_editor.search = true; + keystroke_editor.set_search(true); keystroke_editor }); @@ -2979,524 +2969,6 @@ async fn remove_keybinding( Ok(()) } -#[derive(PartialEq, Eq, Debug, Copy, Clone)] -enum CloseKeystrokeResult { - Partial, - Close, - None, -} - -struct KeystrokeInput { - keystrokes: Vec<Keystroke>, - placeholder_keystrokes: Option<Vec<Keystroke>>, - outer_focus_handle: FocusHandle, - inner_focus_handle: FocusHandle, - intercept_subscription: Option<Subscription>, - _focus_subscriptions: [Subscription; 2], - search: bool, - /// Handles tripe escape to stop recording - close_keystrokes: Option<Vec<Keystroke>>, - close_keystrokes_start: Option<usize>, - previous_modifiers: Modifiers, -} - -impl KeystrokeInput { - const KEYSTROKE_COUNT_MAX: usize = 3; - - fn new( - placeholder_keystrokes: Option<Vec<Keystroke>>, - window: &mut Window, - cx: &mut Context<Self>, - ) -> Self { - let outer_focus_handle = cx.focus_handle(); - let inner_focus_handle = cx.focus_handle(); - let _focus_subscriptions = [ - cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in), - cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out), - ]; - Self { - keystrokes: Vec::new(), - placeholder_keystrokes, - inner_focus_handle, - outer_focus_handle, - intercept_subscription: None, - _focus_subscriptions, - search: false, - close_keystrokes: None, - close_keystrokes_start: None, - previous_modifiers: Modifiers::default(), - } - } - - fn set_keystrokes(&mut self, keystrokes: Vec<Keystroke>, cx: &mut Context<Self>) { - self.keystrokes = keystrokes; - self.keystrokes_changed(cx); - } - - fn dummy(modifiers: Modifiers) -> Keystroke { - return Keystroke { - modifiers, - key: "".to_string(), - key_char: None, - }; - } - - fn keystrokes_changed(&self, cx: &mut Context<Self>) { - cx.emit(()); - cx.notify(); - } - - fn key_context() -> KeyContext { - let mut key_context = KeyContext::default(); - key_context.add("KeystrokeInput"); - key_context - } - - fn handle_possible_close_keystroke( - &mut self, - keystroke: &Keystroke, - window: &mut Window, - cx: &mut Context<Self>, - ) -> CloseKeystrokeResult { - let Some(keybind_for_close_action) = window - .highest_precedence_binding_for_action_in_context(&StopRecording, Self::key_context()) - else { - log::trace!("No keybinding to stop recording keystrokes in keystroke input"); - self.close_keystrokes.take(); - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - }; - let action_keystrokes = keybind_for_close_action.keystrokes(); - - if let Some(mut close_keystrokes) = self.close_keystrokes.take() { - let mut index = 0; - - while index < action_keystrokes.len() && index < close_keystrokes.len() { - if !close_keystrokes[index].should_match(&action_keystrokes[index]) { - break; - } - index += 1; - } - if index == close_keystrokes.len() { - if index >= action_keystrokes.len() { - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - } - if keystroke.should_match(&action_keystrokes[index]) { - if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 { - self.stop_recording(&StopRecording, window, cx); - return CloseKeystrokeResult::Close; - } else { - close_keystrokes.push(keystroke.clone()); - self.close_keystrokes = Some(close_keystrokes); - return CloseKeystrokeResult::Partial; - } - } else { - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - } - } - } else if let Some(first_action_keystroke) = action_keystrokes.first() - && keystroke.should_match(first_action_keystroke) - { - self.close_keystrokes = Some(vec![keystroke.clone()]); - return CloseKeystrokeResult::Partial; - } - self.close_keystrokes_start.take(); - return CloseKeystrokeResult::None; - } - - fn on_modifiers_changed( - &mut self, - event: &ModifiersChangedEvent, - _window: &mut Window, - cx: &mut Context<Self>, - ) { - let keystrokes_len = self.keystrokes.len(); - - if self.previous_modifiers.modified() - && event.modifiers.is_subset_of(&self.previous_modifiers) - { - self.previous_modifiers &= event.modifiers; - cx.stop_propagation(); - return; - } - - if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() - && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX - { - if self.search { - if self.previous_modifiers.modified() { - last.modifiers |= event.modifiers; - self.previous_modifiers |= event.modifiers; - } else { - self.keystrokes.push(Self::dummy(event.modifiers)); - self.previous_modifiers |= event.modifiers; - } - } else if !event.modifiers.modified() { - self.keystrokes.pop(); - } else { - last.modifiers = event.modifiers; - } - - self.keystrokes_changed(cx); - } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { - self.keystrokes.push(Self::dummy(event.modifiers)); - if self.search { - self.previous_modifiers |= event.modifiers; - } - self.keystrokes_changed(cx); - } - cx.stop_propagation(); - } - - fn handle_keystroke( - &mut self, - keystroke: &Keystroke, - window: &mut Window, - cx: &mut Context<Self>, - ) { - let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx); - if close_keystroke_result != CloseKeystrokeResult::Close { - let key_len = self.keystrokes.len(); - if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() - && key_len <= Self::KEYSTROKE_COUNT_MAX - { - if self.search { - last.key = keystroke.key.clone(); - if close_keystroke_result == CloseKeystrokeResult::Partial - && self.close_keystrokes_start.is_none() - { - self.close_keystrokes_start = Some(self.keystrokes.len() - 1); - } - if self.search { - self.previous_modifiers = keystroke.modifiers; - } - self.keystrokes_changed(cx); - cx.stop_propagation(); - return; - } else { - self.keystrokes.pop(); - } - } - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { - if close_keystroke_result == CloseKeystrokeResult::Partial - && self.close_keystrokes_start.is_none() - { - self.close_keystrokes_start = Some(self.keystrokes.len()); - } - self.keystrokes.push(keystroke.clone()); - if self.search { - self.previous_modifiers = keystroke.modifiers; - } else if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { - self.keystrokes.push(Self::dummy(keystroke.modifiers)); - } - } else if close_keystroke_result != CloseKeystrokeResult::Partial { - self.clear_keystrokes(&ClearKeystrokes, window, cx); - } - } - self.keystrokes_changed(cx); - cx.stop_propagation(); - } - - fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) { - if self.intercept_subscription.is_none() { - let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| { - this.handle_keystroke(&event.keystroke, window, cx); - }); - self.intercept_subscription = Some(cx.intercept_keystrokes(listener)) - } - } - - fn on_inner_focus_out( - &mut self, - _event: gpui::FocusOutEvent, - _window: &mut Window, - cx: &mut Context<Self>, - ) { - self.intercept_subscription.take(); - cx.notify(); - } - - fn keystrokes(&self) -> &[Keystroke] { - if let Some(placeholders) = self.placeholder_keystrokes.as_ref() - && self.keystrokes.is_empty() - { - return placeholders; - } - if !self.search - && self - .keystrokes - .last() - .map_or(false, |last| last.key.is_empty()) - { - return &self.keystrokes[..self.keystrokes.len() - 1]; - } - return &self.keystrokes; - } - - fn render_keystrokes(&self, is_recording: bool) -> impl Iterator<Item = Div> { - let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref() - && self.keystrokes.is_empty() - { - if is_recording { - &[] - } else { - placeholders.as_slice() - } - } else { - &self.keystrokes - }; - keystrokes.iter().map(move |keystroke| { - h_flex().children(ui::render_keystroke( - keystroke, - Some(Color::Default), - Some(rems(0.875).into()), - ui::PlatformStyle::platform(), - false, - )) - }) - } - - fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context<Self>) { - window.focus(&self.inner_focus_handle); - self.clear_keystrokes(&ClearKeystrokes, window, cx); - self.previous_modifiers = window.modifiers(); - cx.stop_propagation(); - } - - fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context<Self>) { - if !self.inner_focus_handle.is_focused(window) { - return; - } - window.focus(&self.outer_focus_handle); - if let Some(close_keystrokes_start) = self.close_keystrokes_start.take() - && close_keystrokes_start < self.keystrokes.len() - { - self.keystrokes.drain(close_keystrokes_start..); - } - self.close_keystrokes.take(); - cx.notify(); - } - - fn clear_keystrokes( - &mut self, - _: &ClearKeystrokes, - _window: &mut Window, - cx: &mut Context<Self>, - ) { - self.keystrokes.clear(); - self.keystrokes_changed(cx); - } -} - -impl EventEmitter<()> for KeystrokeInput {} - -impl Focusable for KeystrokeInput { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.outer_focus_handle.clone() - } -} - -impl Render for KeystrokeInput { - fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { - let colors = cx.theme().colors(); - let is_focused = self.outer_focus_handle.contains_focused(window, cx); - let is_recording = self.inner_focus_handle.is_focused(window); - - let horizontal_padding = rems_from_px(64.); - - let recording_bg_color = colors - .editor_background - .blend(colors.text_accent.opacity(0.1)); - - let recording_pulse = |color: Color| { - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(Color::Error) - .with_animation( - "recording-pulse", - Animation::new(std::time::Duration::from_secs(2)) - .repeat() - .with_easing(gpui::pulsating_between(0.4, 0.8)), - { - let color = color.color(cx); - move |this, delta| this.color(Color::Custom(color.opacity(delta))) - }, - ) - }; - - let recording_indicator = h_flex() - .h_4() - .pr_1() - .gap_0p5() - .border_1() - .border_color(colors.border) - .bg(colors - .editor_background - .blend(colors.text_accent.opacity(0.1))) - .rounded_sm() - .child(recording_pulse(Color::Error)) - .child( - Label::new("REC") - .size(LabelSize::XSmall) - .weight(FontWeight::SEMIBOLD) - .color(Color::Error), - ); - - let search_indicator = h_flex() - .h_4() - .pr_1() - .gap_0p5() - .border_1() - .border_color(colors.border) - .bg(colors - .editor_background - .blend(colors.text_accent.opacity(0.1))) - .rounded_sm() - .child(recording_pulse(Color::Accent)) - .child( - Label::new("SEARCH") - .size(LabelSize::XSmall) - .weight(FontWeight::SEMIBOLD) - .color(Color::Accent), - ); - - let record_icon = if self.search { - IconName::MagnifyingGlass - } else { - IconName::PlayFilled - }; - - h_flex() - .id("keystroke-input") - .track_focus(&self.outer_focus_handle) - .py_2() - .px_3() - .gap_2() - .min_h_10() - .w_full() - .flex_1() - .justify_between() - .rounded_lg() - .overflow_hidden() - .map(|this| { - if is_recording { - this.bg(recording_bg_color) - } else { - this.bg(colors.editor_background) - } - }) - .border_1() - .border_color(colors.border_variant) - .when(is_focused, |parent| { - parent.border_color(colors.border_focused) - }) - .key_context(Self::key_context()) - .on_action(cx.listener(Self::start_recording)) - .on_action(cx.listener(Self::clear_keystrokes)) - .child( - h_flex() - .w(horizontal_padding) - .gap_0p5() - .justify_start() - .flex_none() - .when(is_recording, |this| { - this.map(|this| { - if self.search { - this.child(search_indicator) - } else { - this.child(recording_indicator) - } - }) - }), - ) - .child( - h_flex() - .id("keystroke-input-inner") - .track_focus(&self.inner_focus_handle) - .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) - .size_full() - .when(!self.search, |this| { - this.focus(|mut style| { - style.border_color = Some(colors.border_focused); - style - }) - }) - .w_full() - .min_w_0() - .justify_center() - .flex_wrap() - .gap(ui::DynamicSpacing::Base04.rems(cx)) - .children(self.render_keystrokes(is_recording)), - ) - .child( - h_flex() - .w(horizontal_padding) - .gap_0p5() - .justify_end() - .flex_none() - .map(|this| { - if is_recording { - this.child( - IconButton::new("stop-record-btn", IconName::StopFilled) - .shape(ui::IconButtonShape::Square) - .map(|this| { - this.tooltip(Tooltip::for_action_title( - if self.search { - "Stop Searching" - } else { - "Stop Recording" - }, - &StopRecording, - )) - }) - .icon_color(Color::Error) - .on_click(cx.listener(|this, _event, window, cx| { - this.stop_recording(&StopRecording, window, cx); - })), - ) - } else { - this.child( - IconButton::new("record-btn", record_icon) - .shape(ui::IconButtonShape::Square) - .map(|this| { - this.tooltip(Tooltip::for_action_title( - if self.search { - "Start Searching" - } else { - "Start Recording" - }, - &StartRecording, - )) - }) - .when(!is_focused, |this| this.icon_color(Color::Muted)) - .on_click(cx.listener(|this, _event, window, cx| { - this.start_recording(&StartRecording, window, cx); - })), - ) - } - }) - .child( - IconButton::new("clear-btn", IconName::Delete) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::for_action_title( - "Clear Keystrokes", - &ClearKeystrokes, - )) - .when(!is_recording || !is_focused, |this| { - this.icon_color(Color::Muted) - }) - .on_click(cx.listener(|this, _event, window, cx| { - this.clear_keystrokes(&ClearKeystrokes, window, cx); - })), - ), - ) - } -} - fn collect_contexts_from_assets() -> Vec<SharedString> { let mut keymap_assets = vec![ util::asset_str::<SettingsAssets>(settings::DEFAULT_KEYMAP_PATH), diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/settings_ui/src/ui_components/keystroke_input.rs new file mode 100644 index 0000000000..03d27d0ab9 --- /dev/null +++ b/crates/settings_ui/src/ui_components/keystroke_input.rs @@ -0,0 +1,1388 @@ +use gpui::{ + Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, + Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions, +}; +use ui::{ + ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, + ParentElement as _, Render, Styled as _, Tooltip, Window, prelude::*, +}; + +actions!( + keystroke_input, + [ + /// Starts recording keystrokes + StartRecording, + /// Stops recording keystrokes + StopRecording, + /// Clears the recorded keystrokes + ClearKeystrokes, + ] +); + +const KEY_CONTEXT_VALUE: &'static str = "KeystrokeInput"; + +const CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(300); + +enum CloseKeystrokeResult { + Partial, + Close, + None, +} + +impl PartialEq for CloseKeystrokeResult { + fn eq(&self, other: &Self) -> bool { + matches!( + (self, other), + (CloseKeystrokeResult::Partial, CloseKeystrokeResult::Partial) + | (CloseKeystrokeResult::Close, CloseKeystrokeResult::Close) + | (CloseKeystrokeResult::None, CloseKeystrokeResult::None) + ) + } +} + +pub struct KeystrokeInput { + keystrokes: Vec<Keystroke>, + placeholder_keystrokes: Option<Vec<Keystroke>>, + outer_focus_handle: FocusHandle, + inner_focus_handle: FocusHandle, + intercept_subscription: Option<Subscription>, + _focus_subscriptions: [Subscription; 2], + search: bool, + /// The sequence of close keystrokes being typed + close_keystrokes: Option<Vec<Keystroke>>, + close_keystrokes_start: Option<usize>, + previous_modifiers: Modifiers, + /// In order to support inputting keystrokes that end with a prefix of the + /// close keybind keystrokes, we clear the close keystroke capture info + /// on a timeout after a close keystroke is pressed + /// + /// e.g. if close binding is `esc esc esc` and user wants to search for + /// `ctrl-g esc`, after entering the `ctrl-g esc`, hitting `esc` twice would + /// stop recording because of the sequence of three escapes making it + /// impossible to search for anything ending in `esc` + clear_close_keystrokes_timer: Option<Task<()>>, + #[cfg(test)] + recording: bool, +} + +impl KeystrokeInput { + const KEYSTROKE_COUNT_MAX: usize = 3; + + pub fn new( + placeholder_keystrokes: Option<Vec<Keystroke>>, + window: &mut Window, + cx: &mut Context<Self>, + ) -> Self { + let outer_focus_handle = cx.focus_handle(); + let inner_focus_handle = cx.focus_handle(); + let _focus_subscriptions = [ + cx.on_focus_in(&inner_focus_handle, window, Self::on_inner_focus_in), + cx.on_focus_out(&inner_focus_handle, window, Self::on_inner_focus_out), + ]; + Self { + keystrokes: Vec::new(), + placeholder_keystrokes, + inner_focus_handle, + outer_focus_handle, + intercept_subscription: None, + _focus_subscriptions, + search: false, + close_keystrokes: None, + close_keystrokes_start: None, + previous_modifiers: Modifiers::default(), + clear_close_keystrokes_timer: None, + #[cfg(test)] + recording: false, + } + } + + pub fn set_keystrokes(&mut self, keystrokes: Vec<Keystroke>, cx: &mut Context<Self>) { + self.keystrokes = keystrokes; + self.keystrokes_changed(cx); + } + + pub fn set_search(&mut self, search: bool) { + self.search = search; + } + + pub fn keystrokes(&self) -> &[Keystroke] { + if let Some(placeholders) = self.placeholder_keystrokes.as_ref() + && self.keystrokes.is_empty() + { + return placeholders; + } + if !self.search + && self + .keystrokes + .last() + .map_or(false, |last| last.key.is_empty()) + { + return &self.keystrokes[..self.keystrokes.len() - 1]; + } + return &self.keystrokes; + } + + fn dummy(modifiers: Modifiers) -> Keystroke { + return Keystroke { + modifiers, + key: "".to_string(), + key_char: None, + }; + } + + fn keystrokes_changed(&self, cx: &mut Context<Self>) { + cx.emit(()); + cx.notify(); + } + + fn key_context() -> KeyContext { + let mut key_context = KeyContext::default(); + key_context.add(KEY_CONTEXT_VALUE); + key_context + } + + fn determine_stop_recording_binding(window: &mut Window) -> Option<gpui::KeyBinding> { + if cfg!(test) { + Some(gpui::KeyBinding::new( + "escape escape escape", + StopRecording, + Some(KEY_CONTEXT_VALUE), + )) + } else { + window.highest_precedence_binding_for_action_in_context( + &StopRecording, + Self::key_context(), + ) + } + } + + fn upsert_close_keystrokes_start(&mut self, start: usize, cx: &mut Context<Self>) { + if self.close_keystrokes_start.is_some() { + return; + } + self.close_keystrokes_start = Some(start); + self.update_clear_close_keystrokes_timer(cx); + } + + fn update_clear_close_keystrokes_timer(&mut self, cx: &mut Context<Self>) { + self.clear_close_keystrokes_timer = Some(cx.spawn(async |this, cx| { + cx.background_executor() + .timer(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT) + .await; + this.update(cx, |this, _cx| { + this.end_close_keystrokes_capture(); + }) + .ok(); + })); + } + + /// Interrupt the capture of close keystrokes, but do not clear the close keystrokes + /// from the input + fn end_close_keystrokes_capture(&mut self) -> Option<usize> { + self.close_keystrokes.take(); + self.clear_close_keystrokes_timer.take(); + return self.close_keystrokes_start.take(); + } + + fn handle_possible_close_keystroke( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context<Self>, + ) -> CloseKeystrokeResult { + let Some(keybind_for_close_action) = Self::determine_stop_recording_binding(window) else { + log::trace!("No keybinding to stop recording keystrokes in keystroke input"); + self.end_close_keystrokes_capture(); + return CloseKeystrokeResult::None; + }; + let action_keystrokes = keybind_for_close_action.keystrokes(); + + if let Some(mut close_keystrokes) = self.close_keystrokes.take() { + let mut index = 0; + + while index < action_keystrokes.len() && index < close_keystrokes.len() { + if !close_keystrokes[index].should_match(&action_keystrokes[index]) { + break; + } + index += 1; + } + if index == close_keystrokes.len() { + if index >= action_keystrokes.len() { + self.end_close_keystrokes_capture(); + return CloseKeystrokeResult::None; + } + if keystroke.should_match(&action_keystrokes[index]) { + close_keystrokes.push(keystroke.clone()); + if close_keystrokes.len() == action_keystrokes.len() { + return CloseKeystrokeResult::Close; + } else { + self.close_keystrokes = Some(close_keystrokes); + self.update_clear_close_keystrokes_timer(cx); + return CloseKeystrokeResult::Partial; + } + } else { + self.end_close_keystrokes_capture(); + return CloseKeystrokeResult::None; + } + } + } else if let Some(first_action_keystroke) = action_keystrokes.first() + && keystroke.should_match(first_action_keystroke) + { + self.close_keystrokes = Some(vec![keystroke.clone()]); + return CloseKeystrokeResult::Partial; + } + self.end_close_keystrokes_capture(); + return CloseKeystrokeResult::None; + } + + fn on_modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + window: &mut Window, + cx: &mut Context<Self>, + ) { + cx.stop_propagation(); + let keystrokes_len = self.keystrokes.len(); + + if self.previous_modifiers.modified() + && event.modifiers.is_subset_of(&self.previous_modifiers) + { + self.previous_modifiers &= event.modifiers; + return; + } + self.keystrokes_changed(cx); + + if let Some(last) = self.keystrokes.last_mut() + && last.key.is_empty() + && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX + { + if !self.search && !event.modifiers.modified() { + self.keystrokes.pop(); + return; + } + if self.search { + if self.previous_modifiers.modified() { + last.modifiers |= event.modifiers; + } else { + self.keystrokes.push(Self::dummy(event.modifiers)); + } + self.previous_modifiers |= event.modifiers; + } else { + last.modifiers = event.modifiers; + return; + } + } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { + self.keystrokes.push(Self::dummy(event.modifiers)); + if self.search { + self.previous_modifiers |= event.modifiers; + } + } + if keystrokes_len >= Self::KEYSTROKE_COUNT_MAX { + self.clear_keystrokes(&ClearKeystrokes, window, cx); + } + } + + fn handle_keystroke( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context<Self>, + ) { + cx.stop_propagation(); + + let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx); + if close_keystroke_result == CloseKeystrokeResult::Close { + self.stop_recording(&StopRecording, window, cx); + return; + } + + let mut keystroke = keystroke.clone(); + if let Some(last) = self.keystrokes.last() + && last.key.is_empty() + && (!self.search || self.previous_modifiers.modified()) + { + let key = keystroke.key.clone(); + keystroke = last.clone(); + keystroke.key = key; + self.keystrokes.pop(); + } + + if close_keystroke_result == CloseKeystrokeResult::Partial { + self.upsert_close_keystrokes_start(self.keystrokes.len(), cx); + if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX { + return; + } + } + + if self.keystrokes.len() >= Self::KEYSTROKE_COUNT_MAX { + self.clear_keystrokes(&ClearKeystrokes, window, cx); + return; + } + + self.keystrokes.push(keystroke.clone()); + self.keystrokes_changed(cx); + + if self.search { + self.previous_modifiers = keystroke.modifiers; + return; + } + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && keystroke.modifiers.modified() { + self.keystrokes.push(Self::dummy(keystroke.modifiers)); + } + } + + fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context<Self>) { + if self.intercept_subscription.is_none() { + let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| { + this.handle_keystroke(&event.keystroke, window, cx); + }); + self.intercept_subscription = Some(cx.intercept_keystrokes(listener)) + } + } + + fn on_inner_focus_out( + &mut self, + _event: gpui::FocusOutEvent, + _window: &mut Window, + cx: &mut Context<Self>, + ) { + self.intercept_subscription.take(); + cx.notify(); + } + + fn render_keystrokes(&self, is_recording: bool) -> impl Iterator<Item = Div> { + let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref() + && self.keystrokes.is_empty() + { + if is_recording { + &[] + } else { + placeholders.as_slice() + } + } else { + &self.keystrokes + }; + keystrokes.iter().map(move |keystroke| { + h_flex().children(ui::render_keystroke( + keystroke, + Some(Color::Default), + Some(rems(0.875).into()), + ui::PlatformStyle::platform(), + false, + )) + }) + } + + pub fn start_recording( + &mut self, + _: &StartRecording, + window: &mut Window, + cx: &mut Context<Self>, + ) { + window.focus(&self.inner_focus_handle); + self.clear_keystrokes(&ClearKeystrokes, window, cx); + self.previous_modifiers = window.modifiers(); + #[cfg(test)] + { + self.recording = true; + } + cx.stop_propagation(); + } + + pub fn stop_recording( + &mut self, + _: &StopRecording, + window: &mut Window, + cx: &mut Context<Self>, + ) { + if !self.is_recording(window) { + return; + } + window.focus(&self.outer_focus_handle); + if let Some(close_keystrokes_start) = self.close_keystrokes_start.take() + && close_keystrokes_start < self.keystrokes.len() + { + self.keystrokes.drain(close_keystrokes_start..); + self.keystrokes_changed(cx); + } + self.end_close_keystrokes_capture(); + #[cfg(test)] + { + self.recording = false; + } + cx.notify(); + } + + pub fn clear_keystrokes( + &mut self, + _: &ClearKeystrokes, + _window: &mut Window, + cx: &mut Context<Self>, + ) { + self.keystrokes.clear(); + self.keystrokes_changed(cx); + self.end_close_keystrokes_capture(); + } + + fn is_recording(&self, window: &Window) -> bool { + #[cfg(test)] + { + if true { + // in tests, we just need a simple bool that is toggled on start and stop recording + return self.recording; + } + } + // however, in the real world, checking if the inner focus handle is focused + // is a much more reliable check, as the intercept keystroke handlers are installed + // on focus of the inner focus handle, thereby ensuring our recording state does + // not get de-synced + return self.inner_focus_handle.is_focused(window); + } +} + +impl EventEmitter<()> for KeystrokeInput {} + +impl Focusable for KeystrokeInput { + fn focus_handle(&self, _cx: &gpui::App) -> FocusHandle { + self.outer_focus_handle.clone() + } +} + +impl Render for KeystrokeInput { + fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { + let colors = cx.theme().colors(); + let is_focused = self.outer_focus_handle.contains_focused(window, cx); + let is_recording = self.is_recording(window); + + let horizontal_padding = rems_from_px(64.); + + let recording_bg_color = colors + .editor_background + .blend(colors.text_accent.opacity(0.1)); + + let recording_pulse = |color: Color| { + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Error) + .with_animation( + "recording-pulse", + Animation::new(std::time::Duration::from_secs(2)) + .repeat() + .with_easing(gpui::pulsating_between(0.4, 0.8)), + { + let color = color.color(cx); + move |this, delta| this.color(Color::Custom(color.opacity(delta))) + }, + ) + }; + + let recording_indicator = h_flex() + .h_4() + .pr_1() + .gap_0p5() + .border_1() + .border_color(colors.border) + .bg(colors + .editor_background + .blend(colors.text_accent.opacity(0.1))) + .rounded_sm() + .child(recording_pulse(Color::Error)) + .child( + Label::new("REC") + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(Color::Error), + ); + + let search_indicator = h_flex() + .h_4() + .pr_1() + .gap_0p5() + .border_1() + .border_color(colors.border) + .bg(colors + .editor_background + .blend(colors.text_accent.opacity(0.1))) + .rounded_sm() + .child(recording_pulse(Color::Accent)) + .child( + Label::new("SEARCH") + .size(LabelSize::XSmall) + .weight(FontWeight::SEMIBOLD) + .color(Color::Accent), + ); + + let record_icon = if self.search { + IconName::MagnifyingGlass + } else { + IconName::PlayFilled + }; + + h_flex() + .id("keystroke-input") + .track_focus(&self.outer_focus_handle) + .py_2() + .px_3() + .gap_2() + .min_h_10() + .w_full() + .flex_1() + .justify_between() + .rounded_lg() + .overflow_hidden() + .map(|this| { + if is_recording { + this.bg(recording_bg_color) + } else { + this.bg(colors.editor_background) + } + }) + .border_1() + .border_color(colors.border_variant) + .when(is_focused, |parent| { + parent.border_color(colors.border_focused) + }) + .key_context(Self::key_context()) + .on_action(cx.listener(Self::start_recording)) + .on_action(cx.listener(Self::clear_keystrokes)) + .child( + h_flex() + .w(horizontal_padding) + .gap_0p5() + .justify_start() + .flex_none() + .when(is_recording, |this| { + this.map(|this| { + if self.search { + this.child(search_indicator) + } else { + this.child(recording_indicator) + } + }) + }), + ) + .child( + h_flex() + .id("keystroke-input-inner") + .track_focus(&self.inner_focus_handle) + .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) + .size_full() + .when(!self.search, |this| { + this.focus(|mut style| { + style.border_color = Some(colors.border_focused); + style + }) + }) + .w_full() + .min_w_0() + .justify_center() + .flex_wrap() + .gap(ui::DynamicSpacing::Base04.rems(cx)) + .children(self.render_keystrokes(is_recording)), + ) + .child( + h_flex() + .w(horizontal_padding) + .gap_0p5() + .justify_end() + .flex_none() + .map(|this| { + if is_recording { + this.child( + IconButton::new("stop-record-btn", IconName::StopFilled) + .shape(IconButtonShape::Square) + .map(|this| { + this.tooltip(Tooltip::for_action_title( + if self.search { + "Stop Searching" + } else { + "Stop Recording" + }, + &StopRecording, + )) + }) + .icon_color(Color::Error) + .on_click(cx.listener(|this, _event, window, cx| { + this.stop_recording(&StopRecording, window, cx); + })), + ) + } else { + this.child( + IconButton::new("record-btn", record_icon) + .shape(IconButtonShape::Square) + .map(|this| { + this.tooltip(Tooltip::for_action_title( + if self.search { + "Start Searching" + } else { + "Start Recording" + }, + &StartRecording, + )) + }) + .when(!is_focused, |this| this.icon_color(Color::Muted)) + .on_click(cx.listener(|this, _event, window, cx| { + this.start_recording(&StartRecording, window, cx); + })), + ) + } + }) + .child( + IconButton::new("clear-btn", IconName::Delete) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::for_action_title( + "Clear Keystrokes", + &ClearKeystrokes, + )) + .when(!is_recording || !is_focused, |this| { + this.icon_color(Color::Muted) + }) + .on_click(cx.listener(|this, _event, window, cx| { + this.clear_keystrokes(&ClearKeystrokes, window, cx); + })), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fs::FakeFs; + use gpui::{Entity, TestAppContext, VisualTestContext}; + use itertools::Itertools as _; + use project::Project; + use settings::SettingsStore; + use workspace::Workspace; + + pub struct KeystrokeInputTestHelper { + input: Entity<KeystrokeInput>, + current_modifiers: Modifiers, + cx: VisualTestContext, + } + + impl KeystrokeInputTestHelper { + /// Creates a new test helper with default settings + pub fn new(mut cx: VisualTestContext) -> Self { + let input = cx.new_window_entity(|window, cx| KeystrokeInput::new(None, window, cx)); + + let mut helper = Self { + input, + current_modifiers: Modifiers::default(), + cx, + }; + + helper.start_recording(); + helper + } + + /// Sets search mode on the input + pub fn with_search_mode(&mut self, search: bool) -> &mut Self { + self.input.update(&mut self.cx, |input, _| { + input.set_search(search); + }); + self + } + + /// Sends a keystroke event based on string description + /// Examples: "a", "ctrl-a", "cmd-shift-z", "escape" + #[track_caller] + pub fn send_keystroke(&mut self, keystroke_input: &str) -> &mut Self { + self.expect_is_recording(true); + let keystroke_str = if keystroke_input.ends_with('-') { + format!("{}_", keystroke_input) + } else { + keystroke_input.to_string() + }; + + let mut keystroke = Keystroke::parse(&keystroke_str) + .unwrap_or_else(|_| panic!("Invalid keystroke: {}", keystroke_input)); + + // Remove the dummy key if we added it for modifier-only keystrokes + if keystroke_input.ends_with('-') && keystroke_str.ends_with("_") { + keystroke.key = "".to_string(); + } + + // Combine current modifiers with keystroke modifiers + keystroke.modifiers |= self.current_modifiers; + + self.update_input(|input, window, cx| { + input.handle_keystroke(&keystroke, window, cx); + }); + + // Don't update current_modifiers for keystrokes with actual keys + if keystroke.key.is_empty() { + self.current_modifiers = keystroke.modifiers; + } + self + } + + /// Sends a modifier change event based on string description + /// Examples: "+ctrl", "-ctrl", "+cmd+shift", "-all" + #[track_caller] + pub fn send_modifiers(&mut self, modifiers: &str) -> &mut Self { + self.expect_is_recording(true); + let new_modifiers = if modifiers == "-all" { + Modifiers::default() + } else { + self.parse_modifier_change(modifiers) + }; + + let event = ModifiersChangedEvent { + modifiers: new_modifiers, + capslock: gpui::Capslock::default(), + }; + + self.update_input(|input, window, cx| { + input.on_modifiers_changed(&event, window, cx); + }); + + self.current_modifiers = new_modifiers; + self + } + + /// Sends multiple events in sequence + /// Each event string is either a keystroke or modifier change + #[track_caller] + pub fn send_events(&mut self, events: &[&str]) -> &mut Self { + self.expect_is_recording(true); + for event in events { + if event.starts_with('+') || event.starts_with('-') { + self.send_modifiers(event); + } else { + self.send_keystroke(event); + } + } + self + } + + #[track_caller] + fn expect_keystrokes_equal(actual: &[Keystroke], expected: &[&str]) { + let expected_keystrokes: Result<Vec<Keystroke>, _> = expected + .iter() + .map(|s| { + let keystroke_str = if s.ends_with('-') { + format!("{}_", s) + } else { + s.to_string() + }; + + let mut keystroke = Keystroke::parse(&keystroke_str)?; + + // Remove the dummy key if we added it for modifier-only keystrokes + if s.ends_with('-') && keystroke_str.ends_with("_") { + keystroke.key = "".to_string(); + } + + Ok(keystroke) + }) + .collect(); + + let expected_keystrokes = expected_keystrokes + .unwrap_or_else(|e: anyhow::Error| panic!("Invalid expected keystroke: {}", e)); + + assert_eq!( + actual.len(), + expected_keystrokes.len(), + "Keystroke count mismatch. Expected: {:?}, Actual: {:?}", + expected_keystrokes + .iter() + .map(|k| k.unparse()) + .collect::<Vec<_>>(), + actual.iter().map(|k| k.unparse()).collect::<Vec<_>>() + ); + + for (i, (actual, expected)) in actual.iter().zip(expected_keystrokes.iter()).enumerate() + { + assert_eq!( + actual.unparse(), + expected.unparse(), + "Keystroke {} mismatch. Expected: '{}', Actual: '{}'", + i, + expected.unparse(), + actual.unparse() + ); + } + } + + /// Verifies that the keystrokes match the expected strings + #[track_caller] + pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self { + let actual = self + .input + .read_with(&mut self.cx, |input, _| input.keystrokes.clone()); + Self::expect_keystrokes_equal(&actual, expected); + self + } + + #[track_caller] + pub fn expect_close_keystrokes(&mut self, expected: &[&str]) -> &mut Self { + let actual = self + .input + .read_with(&mut self.cx, |input, _| input.close_keystrokes.clone()) + .unwrap_or_default(); + Self::expect_keystrokes_equal(&actual, expected); + self + } + + /// Verifies that there are no keystrokes + #[track_caller] + pub fn expect_empty(&mut self) -> &mut Self { + self.expect_keystrokes(&[]) + } + + /// Starts recording keystrokes + #[track_caller] + pub fn start_recording(&mut self) -> &mut Self { + self.expect_is_recording(false); + self.input.update_in(&mut self.cx, |input, window, cx| { + input.start_recording(&StartRecording, window, cx); + }); + self + } + + /// Stops recording keystrokes + pub fn stop_recording(&mut self) -> &mut Self { + self.expect_is_recording(true); + self.input.update_in(&mut self.cx, |input, window, cx| { + input.stop_recording(&StopRecording, window, cx); + }); + self + } + + /// Clears all keystrokes + #[track_caller] + pub fn clear_keystrokes(&mut self) -> &mut Self { + let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx); + self.input.update_in(&mut self.cx, |input, window, cx| { + input.clear_keystrokes(&ClearKeystrokes, window, cx); + }); + KeystrokeUpdateTracker::finish(change_tracker, &self.cx); + self.current_modifiers = Default::default(); + self + } + + /// Verifies the recording state + #[track_caller] + pub fn expect_is_recording(&mut self, expected: bool) -> &mut Self { + let actual = self + .input + .update_in(&mut self.cx, |input, window, _| input.is_recording(window)); + assert_eq!( + actual, expected, + "Recording state mismatch. Expected: {}, Actual: {}", + expected, actual + ); + self + } + + pub async fn wait_for_close_keystroke_capture_end(&mut self) -> &mut Self { + let task = self.input.update_in(&mut self.cx, |input, _, _| { + input.clear_close_keystrokes_timer.take() + }); + let task = task.expect("No close keystroke capture end timer task"); + self.cx + .executor() + .advance_clock(CLOSE_KEYSTROKE_CAPTURE_END_TIMEOUT); + task.await; + self + } + + /// Parses modifier change strings like "+ctrl", "-shift", "+cmd+alt" + #[track_caller] + fn parse_modifier_change(&self, modifiers_str: &str) -> Modifiers { + let mut modifiers = self.current_modifiers; + + assert!(!modifiers_str.is_empty(), "Empty modifier string"); + + let value; + let split_char; + let remaining; + if let Some(to_add) = modifiers_str.strip_prefix('+') { + value = true; + split_char = '+'; + remaining = to_add; + } else { + let to_remove = modifiers_str + .strip_prefix('-') + .expect("Modifier string must start with '+' or '-'"); + value = false; + split_char = '-'; + remaining = to_remove; + } + + for modifier in remaining.split(split_char) { + match modifier { + "ctrl" | "control" => modifiers.control = value, + "alt" | "option" => modifiers.alt = value, + "shift" => modifiers.shift = value, + "cmd" | "command" | "platform" => modifiers.platform = value, + "fn" | "function" => modifiers.function = value, + _ => panic!("Unknown modifier: {}", modifier), + } + } + + modifiers + } + + #[track_caller] + fn update_input<R>( + &mut self, + cb: impl FnOnce(&mut KeystrokeInput, &mut Window, &mut Context<KeystrokeInput>) -> R, + ) -> R { + let change_tracker = KeystrokeUpdateTracker::new(self.input.clone(), &mut self.cx); + let result = self.input.update_in(&mut self.cx, cb); + KeystrokeUpdateTracker::finish(change_tracker, &self.cx); + return result; + } + } + + struct KeystrokeUpdateTracker { + initial_keystrokes: Vec<Keystroke>, + _subscription: Subscription, + input: Entity<KeystrokeInput>, + received_keystrokes_updated: bool, + } + + impl KeystrokeUpdateTracker { + fn new(input: Entity<KeystrokeInput>, cx: &mut VisualTestContext) -> Entity<Self> { + cx.new(|cx| Self { + initial_keystrokes: input.read_with(cx, |input, _| input.keystrokes.clone()), + _subscription: cx.subscribe(&input, |this: &mut Self, _, _, _| { + this.received_keystrokes_updated = true; + }), + input, + received_keystrokes_updated: false, + }) + } + #[track_caller] + fn finish(this: Entity<Self>, cx: &VisualTestContext) { + let (received_keystrokes_updated, initial_keystrokes_str, updated_keystrokes_str) = + this.read_with(cx, |this, cx| { + let updated_keystrokes = this + .input + .read_with(cx, |input, _| input.keystrokes.clone()); + let initial_keystrokes_str = keystrokes_str(&this.initial_keystrokes); + let updated_keystrokes_str = keystrokes_str(&updated_keystrokes); + ( + this.received_keystrokes_updated, + initial_keystrokes_str, + updated_keystrokes_str, + ) + }); + if received_keystrokes_updated { + assert_ne!( + initial_keystrokes_str, updated_keystrokes_str, + "Received keystrokes_updated event, expected different keystrokes" + ); + } else { + assert_eq!( + initial_keystrokes_str, updated_keystrokes_str, + "Received no keystrokes_updated event, expected same keystrokes" + ); + } + + fn keystrokes_str(ks: &[Keystroke]) -> String { + ks.iter().map(|ks| ks.unparse()).join(" ") + } + } + } + + async fn init_test(cx: &mut TestAppContext) -> KeystrokeInputTestHelper { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + project::Project::init_settings(cx); + workspace::init_settings(cx); + }); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = VisualTestContext::from_window(*workspace, cx); + KeystrokeInputTestHelper::new(cx) + } + + #[gpui::test] + async fn test_basic_keystroke_input(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_keystroke("a") + .clear_keystrokes() + .expect_empty(); + } + + #[gpui::test] + async fn test_modifier_handling(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "a", "-ctrl"]) + .expect_keystrokes(&["ctrl-a"]); + } + + #[gpui::test] + async fn test_multiple_modifiers(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_keystroke("cmd-shift-z") + .expect_keystrokes(&["cmd-shift-z", "cmd-shift-"]); + } + + #[gpui::test] + async fn test_search_mode_behavior(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+cmd", "shift-f", "-cmd"]) + // In search mode, when completing a modifier-only keystroke with a key, + // only the original modifiers are preserved, not the keystroke's modifiers + .expect_keystrokes(&["cmd-f"]); + } + + #[gpui::test] + async fn test_keystroke_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_keystroke("a") + .send_keystroke("b") + .send_keystroke("c") + .expect_keystrokes(&["a", "b", "c"]) // At max limit + .send_keystroke("d") + .expect_empty(); // Should clear when exceeding limit + } + + #[gpui::test] + async fn test_modifier_release_all(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+shift", "a", "-all"]) + .expect_keystrokes(&["ctrl-shift-a"]); + } + + #[gpui::test] + async fn test_search_new_modifiers_not_added_until_all_released(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+shift", "a", "-ctrl"]) + .expect_keystrokes(&["ctrl-shift-a"]) + .send_events(&["+ctrl"]) + .expect_keystrokes(&["ctrl-shift-a", "ctrl-shift-"]); + } + + #[gpui::test] + async fn test_previous_modifiers_no_effect_when_not_search(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl+shift", "a", "-all"]) + .expect_keystrokes(&["ctrl-shift-a"]); + } + + #[gpui::test] + async fn test_keystroke_limit_overflow_non_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["a", "b", "c", "d"]) // 4 keystrokes, exceeds limit of 3 + .expect_empty(); // Should clear when exceeding limit + } + + #[gpui::test] + async fn test_complex_modifier_sequences(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "+alt", "a", "-ctrl", "-shift", "-alt"]) + .expect_keystrokes(&["ctrl-shift-alt-a"]); + } + + #[gpui::test] + async fn test_modifier_only_keystrokes_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"]) + .expect_keystrokes(&["ctrl-shift-"]); // Modifier-only sequences create modifier-only keystrokes + } + + #[gpui::test] + async fn test_modifier_only_keystrokes_non_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl", "+shift", "-ctrl", "-shift"]) + .expect_empty(); // Modifier-only sequences get filtered in non-search mode + } + + #[gpui::test] + async fn test_rapid_modifier_changes(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "-ctrl", "+shift", "-shift", "+alt", "a", "-alt"]) + .expect_keystrokes(&["ctrl-", "shift-", "alt-a"]); + } + + #[gpui::test] + async fn test_clear_keystrokes_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "a", "-ctrl", "b"]) + .expect_keystrokes(&["ctrl-a", "b"]) + .clear_keystrokes() + .expect_empty(); + } + + #[gpui::test] + async fn test_non_search_mode_modifier_key_sequence(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl", "a"]) + .expect_keystrokes(&["ctrl-a", "ctrl-"]) + .send_events(&["-ctrl"]) + .expect_keystrokes(&["ctrl-a"]); // Non-search mode filters trailing empty keystrokes + } + + #[gpui::test] + async fn test_all_modifiers_at_once(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+shift+alt+cmd", "a", "-all"]) + .expect_keystrokes(&["ctrl-shift-alt-cmd-a"]); + } + + #[gpui::test] + async fn test_keystrokes_at_exact_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "b", "c"]) // exactly 3 keystrokes (at limit) + .expect_keystrokes(&["a", "b", "c"]) + .send_events(&["d"]) // should clear when exceeding + .expect_empty(); + } + + #[gpui::test] + async fn test_function_modifier_key(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+fn", "f1", "-fn"]) + .expect_keystrokes(&["fn-f1"]); + } + + #[gpui::test] + async fn test_start_stop_recording(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_events(&["a", "b"]) + .expect_keystrokes(&["a", "b"]) // start_recording clears existing keystrokes + .stop_recording() + .expect_is_recording(false) + .start_recording() + .send_events(&["c"]) + .expect_keystrokes(&["c"]); + } + + #[gpui::test] + async fn test_modifier_sequence_with_interruption(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "a", "-shift", "b", "-ctrl"]) + .expect_keystrokes(&["ctrl-shift-a", "ctrl-b"]); + } + + #[gpui::test] + async fn test_empty_key_sequence_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&[]) // No events at all + .expect_empty(); + } + + #[gpui::test] + async fn test_modifier_sequence_completion_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "+shift", "-shift", "a", "-ctrl"]) + .expect_keystrokes(&["ctrl-shift-a"]); + } + + #[gpui::test] + async fn test_triple_escape_stops_recording_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "escape", "escape", "escape"]) + .expect_keystrokes(&["a"]) // Triple escape removes final escape, stops recording + .expect_is_recording(false); + } + + #[gpui::test] + async fn test_triple_escape_stops_recording_non_search_mode(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["a", "escape", "escape", "escape"]) + .expect_keystrokes(&["a"]); // Triple escape stops recording but only removes final escape + } + + #[gpui::test] + async fn test_triple_escape_at_keystroke_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "b", "c", "escape", "escape", "escape"]) // 6 keystrokes total, exceeds limit + .expect_keystrokes(&["a", "b", "c"]); // Triple escape stops recording and removes escapes, leaves original keystrokes + } + + #[gpui::test] + async fn test_interrupted_escape_sequence(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape", "a", "escape"]) // Partial escape sequence interrupted by 'a' + .expect_keystrokes(&["escape", "escape", "a"]); // Escape sequence interrupted by 'a', no close triggered + } + + #[gpui::test] + async fn test_interrupted_escape_sequence_within_limit(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape", "a"]) // Partial escape sequence interrupted by 'a' (3 keystrokes, at limit) + .expect_keystrokes(&["escape", "escape", "a"]); // Should not trigger close, interruption resets escape detection + } + + #[gpui::test] + async fn test_partial_escape_sequence_no_close(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape"]) // Only 2 escapes, not enough to close + .expect_keystrokes(&["escape", "escape"]) + .expect_is_recording(true); // Should remain in keystrokes, no close triggered + } + + #[gpui::test] + async fn test_recording_state_after_triple_escape(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "escape", "escape", "escape"]) + .expect_keystrokes(&["a"]) // Triple escape stops recording, removes final escape + .expect_is_recording(false); + } + + #[gpui::test] + async fn test_triple_escape_mixed_with_other_keystrokes(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["a", "escape", "b", "escape", "escape"]) // Mixed sequence, should not trigger close + .expect_keystrokes(&["a", "escape", "b"]); // No complete triple escape sequence, stays at limit + } + + #[gpui::test] + async fn test_triple_escape_only(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["escape", "escape", "escape"]) // Pure triple escape sequence + .expect_empty(); + } + + #[gpui::test] + async fn test_end_close_keystroke_capture(cx: &mut TestAppContext) { + init_test(cx) + .await + .send_events(&["+ctrl", "g", "-ctrl", "escape"]) + .expect_keystrokes(&["ctrl-g", "escape"]) + .wait_for_close_keystroke_capture_end() + .await + .send_events(&["escape", "escape"]) + .expect_keystrokes(&["ctrl-g", "escape", "escape"]) + .expect_close_keystrokes(&["escape", "escape"]) + .send_keystroke("escape") + .expect_keystrokes(&["ctrl-g", "escape"]); + } + + #[gpui::test] + async fn test_search_previous_modifiers_are_sticky(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+alt", "-ctrl", "j"]) + .expect_keystrokes(&["ctrl-alt-j"]); + } + + #[gpui::test] + async fn test_previous_modifiers_can_be_entered_separately(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "-ctrl"]) + .expect_keystrokes(&["ctrl-"]) + .send_events(&["+alt", "-alt"]) + .expect_keystrokes(&["ctrl-", "alt-"]); + } + + #[gpui::test] + async fn test_previous_modifiers_reset_on_key(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+alt", "-ctrl", "+shift"]) + .expect_keystrokes(&["ctrl-shift-alt-"]) + .send_keystroke("j") + .expect_keystrokes(&["ctrl-shift-alt-j"]) + .send_keystroke("i") + .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i"]) + .send_events(&["-shift-alt", "+cmd"]) + .expect_keystrokes(&["ctrl-shift-alt-j", "shift-alt-i", "cmd-"]); + } + + #[gpui::test] + async fn test_previous_modifiers_reset_on_release_all(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl+alt", "-ctrl", "+shift"]) + .expect_keystrokes(&["ctrl-shift-alt-"]) + .send_events(&["-all", "j"]) + .expect_keystrokes(&["ctrl-shift-alt-", "j"]); + } + + #[gpui::test] + async fn test_search_repeat_modifiers(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(true) + .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"]) + .expect_keystrokes(&["ctrl-", "alt-", "shift-"]) + .send_events(&["+cmd"]) + .expect_empty(); + } + + #[gpui::test] + async fn test_not_search_repeat_modifiers(cx: &mut TestAppContext) { + init_test(cx) + .await + .with_search_mode(false) + .send_events(&["+ctrl", "-ctrl", "+alt", "-alt", "+shift", "-shift"]) + .expect_empty(); + } +} diff --git a/crates/settings_ui/src/ui_components/mod.rs b/crates/settings_ui/src/ui_components/mod.rs index 13971b0a5d..5d6463a61a 100644 --- a/crates/settings_ui/src/ui_components/mod.rs +++ b/crates/settings_ui/src/ui_components/mod.rs @@ -1 +1,2 @@ +pub mod keystroke_input; pub mod table; diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index c6cf7ac62c..30683e60f3 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -291,14 +291,18 @@ impl Component for ToggleButton { } } -mod private { - pub trait Sealed {} +pub struct ButtonConfiguration { + label: SharedString, + icon: Option<IconName>, + on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>, } -pub trait ButtonBuilder: 'static + private::Sealed { - fn label(&self) -> impl Into<SharedString>; - fn icon(&self) -> Option<IconName>; - fn on_click(self) -> Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>; +mod private { + pub trait ToggleButtonStyle {} +} + +pub trait ButtonBuilder: 'static + private::ToggleButtonStyle { + fn into_configuration(self) -> ButtonConfiguration; } pub struct ToggleButtonSimple { @@ -318,19 +322,15 @@ impl ToggleButtonSimple { } } -impl private::Sealed for ToggleButtonSimple {} +impl private::ToggleButtonStyle for ToggleButtonSimple {} impl ButtonBuilder for ToggleButtonSimple { - fn label(&self) -> impl Into<SharedString> { - self.label.clone() - } - - fn icon(&self) -> Option<IconName> { - None - } - - fn on_click(self) -> Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> { - self.on_click + fn into_configuration(self) -> ButtonConfiguration { + ButtonConfiguration { + label: self.label, + icon: None, + on_click: self.on_click, + } } } @@ -354,58 +354,14 @@ impl ToggleButtonWithIcon { } } -impl private::Sealed for ToggleButtonWithIcon {} +impl private::ToggleButtonStyle for ToggleButtonWithIcon {} impl ButtonBuilder for ToggleButtonWithIcon { - fn label(&self) -> impl Into<SharedString> { - self.label.clone() - } - - fn icon(&self) -> Option<IconName> { - Some(self.icon) - } - - fn on_click(self) -> Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static> { - self.on_click - } -} - -struct ToggleButtonRow<T: ButtonBuilder> { - items: Vec<T>, - index_offset: usize, - last_item_idx: usize, - is_last_row: bool, -} - -impl<T: ButtonBuilder> ToggleButtonRow<T> { - fn new(items: Vec<T>, index_offset: usize, is_last_row: bool) -> Self { - Self { - index_offset, - last_item_idx: index_offset + items.len() - 1, - is_last_row, - items, - } - } -} - -enum ToggleButtonGroupRows<T: ButtonBuilder> { - Single(Vec<T>), - Multiple(Vec<T>, Vec<T>), -} - -impl<T: ButtonBuilder> ToggleButtonGroupRows<T> { - fn items(self) -> impl IntoIterator<Item = ToggleButtonRow<T>> { - match self { - ToggleButtonGroupRows::Single(items) => { - vec![ToggleButtonRow::new(items, 0, true)] - } - ToggleButtonGroupRows::Multiple(first_row, second_row) => { - let row_len = first_row.len(); - vec![ - ToggleButtonRow::new(first_row, 0, false), - ToggleButtonRow::new(second_row, row_len, true), - ] - } + fn into_configuration(self) -> ButtonConfiguration { + ButtonConfiguration { + label: self.label, + icon: Some(self.icon), + on_click: self.on_click, } } } @@ -418,48 +374,42 @@ pub enum ToggleButtonGroupStyle { } #[derive(IntoElement)] -pub struct ToggleButtonGroup<T> +pub struct ToggleButtonGroup<T, const COLS: usize = 3, const ROWS: usize = 1> where T: ButtonBuilder, { - group_name: SharedString, - rows: ToggleButtonGroupRows<T>, + group_name: &'static str, + rows: [[T; COLS]; ROWS], style: ToggleButtonGroupStyle, button_width: Rems, selected_index: usize, } -impl<T: ButtonBuilder> ToggleButtonGroup<T> { - pub fn single_row( - group_name: impl Into<SharedString>, - buttons: impl IntoIterator<Item = T>, - ) -> Self { +impl<T: ButtonBuilder, const COLS: usize> ToggleButtonGroup<T, COLS> { + pub fn single_row(group_name: &'static str, buttons: [T; COLS]) -> Self { Self { - group_name: group_name.into(), - rows: ToggleButtonGroupRows::Single(Vec::from_iter(buttons)), + group_name, + rows: [buttons], style: ToggleButtonGroupStyle::Transparent, button_width: rems_from_px(100.), selected_index: 0, } } +} - pub fn multiple_rows<const ROWS: usize>( - group_name: impl Into<SharedString>, - first_row: [T; ROWS], - second_row: [T; ROWS], - ) -> Self { +impl<T: ButtonBuilder, const COLS: usize> ToggleButtonGroup<T, COLS, 2> { + pub fn two_rows(group_name: &'static str, first_row: [T; COLS], second_row: [T; COLS]) -> Self { Self { - group_name: group_name.into(), - rows: ToggleButtonGroupRows::Multiple( - Vec::from_iter(first_row), - Vec::from_iter(second_row), - ), + group_name, + rows: [first_row, second_row], style: ToggleButtonGroupStyle::Transparent, button_width: rems_from_px(100.), selected_index: 0, } } +} +impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> ToggleButtonGroup<T, COLS, ROWS> { pub fn style(mut self, style: ToggleButtonGroupStyle) -> Self { self.style = style; self @@ -476,60 +426,56 @@ impl<T: ButtonBuilder> ToggleButtonGroup<T> { } } -impl<T: ButtonBuilder> RenderOnce for ToggleButtonGroup<T> { +impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce + for ToggleButtonGroup<T, COLS, ROWS> +{ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let rows = self.rows.items().into_iter().map(|row| { - ( - row.items - .into_iter() - .enumerate() - .map(move |(index, item)| (index + row.index_offset, row.last_item_idx, item)) - .map(|(index, last_item_idx, item)| { - ( - ButtonLike::new((self.group_name.clone(), index)) - .when(index == self.selected_index, |this| { - this.toggle_state(true) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - }) - .rounding(None) - .when(self.style == ToggleButtonGroupStyle::Filled, |button| { - button.style(ButtonStyle::Filled) - }) - .child( - h_flex() - .min_w(self.button_width) - .gap_1p5() - .justify_center() - .when_some(item.icon(), |this, icon| { - this.child(Icon::new(icon).size(IconSize::XSmall).map( - |this| { - if index == self.selected_index { - this.color(Color::Accent) - } else { - this.color(Color::Muted) - } - }, - )) - }) - .child( - Label::new(item.label()) - .when(index == self.selected_index, |this| { - this.color(Color::Accent) - }), - ), - ) - .on_click(item.on_click()), - index == last_item_idx, - ) - }), - row.is_last_row, - ) + let entries = self.rows.into_iter().enumerate().map(|(row_index, row)| { + row.into_iter().enumerate().map(move |(index, button)| { + let ButtonConfiguration { + label, + icon, + on_click, + } = button.into_configuration(); + + ButtonLike::new((self.group_name, row_index * COLS + index)) + .when(index == self.selected_index, |this| { + this.toggle_state(true) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + }) + .rounding(None) + .when(self.style == ToggleButtonGroupStyle::Filled, |button| { + button.style(ButtonStyle::Filled) + }) + .child( + h_flex() + .min_w(self.button_width) + .gap_1p5() + .justify_center() + .when_some(icon, |this, icon| { + this.child(Icon::new(icon).size(IconSize::XSmall).map(|this| { + if index == self.selected_index { + this.color(Color::Accent) + } else { + this.color(Color::Muted) + } + })) + }) + .child( + Label::new(label).when(index == self.selected_index, |this| { + this.color(Color::Accent) + }), + ), + ) + .on_click(on_click) + .into_any_element() + }) }); + let border_color = cx.theme().colors().border.opacity(0.6); let is_outlined_or_filled = self.style == ToggleButtonGroupStyle::Outlined || self.style == ToggleButtonGroupStyle::Filled; let is_transparent = self.style == ToggleButtonGroupStyle::Transparent; - let border_color = cx.theme().colors().border.opacity(0.6); v_flex() .rounded_md() @@ -541,13 +487,15 @@ impl<T: ButtonBuilder> RenderOnce for ToggleButtonGroup<T> { this.border_1().border_color(border_color) } }) - .children(rows.map(|(items, last_row)| { + .children(entries.enumerate().map(|(row_index, row)| { + let last_row = row_index == ROWS - 1; h_flex() .when(!is_outlined_or_filled, |this| this.gap_px()) .when(is_outlined_or_filled && !last_row, |this| { this.border_b_1().border_color(border_color) }) - .children(items.map(|(item, last_item)| { + .children(row.enumerate().map(|(item_index, item)| { + let last_item = item_index == COLS - 1; div() .when(is_outlined_or_filled && !last_item, |this| { this.border_r_1().border_color(border_color) @@ -566,7 +514,9 @@ component::__private::inventory::submit! { component::ComponentFn::new(register_toggle_button_group) } -impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> { +impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> Component + for ToggleButtonGroup<T, COLS, ROWS> +{ fn name() -> &'static str { "ToggleButtonGroup" } @@ -628,7 +578,7 @@ impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> { ), single_example( "Multiple Row Group", - ToggleButtonGroup::multiple_rows( + ToggleButtonGroup::two_rows( "multiple_row_test", [ ToggleButtonSimple::new("First", |_, _, _| {}), @@ -647,7 +597,7 @@ impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> { ), single_example( "Multiple Row Group with Icons", - ToggleButtonGroup::multiple_rows( + ToggleButtonGroup::two_rows( "multiple_row_test_icons", [ ToggleButtonWithIcon::new( @@ -736,7 +686,7 @@ impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> { ), single_example( "Multiple Row Group", - ToggleButtonGroup::multiple_rows( + ToggleButtonGroup::two_rows( "multiple_row_test", [ ToggleButtonSimple::new("First", |_, _, _| {}), @@ -756,7 +706,7 @@ impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> { ), single_example( "Multiple Row Group with Icons", - ToggleButtonGroup::multiple_rows( + ToggleButtonGroup::two_rows( "multiple_row_test", [ ToggleButtonWithIcon::new( @@ -846,7 +796,7 @@ impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> { ), single_example( "Multiple Row Group", - ToggleButtonGroup::multiple_rows( + ToggleButtonGroup::two_rows( "multiple_row_test", [ ToggleButtonSimple::new("First", |_, _, _| {}), @@ -866,7 +816,7 @@ impl<T: ButtonBuilder> Component for ToggleButtonGroup<T> { ), single_example( "Multiple Row Group with Icons", - ToggleButtonGroup::multiple_rows( + ToggleButtonGroup::two_rows( "multiple_row_test", [ ToggleButtonWithIcon::new( diff --git a/crates/ui/src/components/numeric_stepper.rs b/crates/ui/src/components/numeric_stepper.rs index f9e6e88f01..05d368f427 100644 --- a/crates/ui/src/components/numeric_stepper.rs +++ b/crates/ui/src/components/numeric_stepper.rs @@ -2,7 +2,7 @@ use gpui::ClickEvent; use crate::{IconButtonShape, prelude::*}; -#[derive(IntoElement)] +#[derive(IntoElement, RegisterComponent)] pub struct NumericStepper { id: ElementId, value: SharedString, @@ -93,3 +93,34 @@ impl RenderOnce for NumericStepper { ) } } + +impl Component for NumericStepper { + fn scope() -> ComponentScope { + ComponentScope::Input + } + + fn name() -> &'static str { + "NumericStepper" + } + + fn sort_name() -> &'static str { + Self::name() + } + + fn description() -> Option<&'static str> { + Some("A button used to increment or decrement a numeric value. ") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> { + Some( + div() + .child(NumericStepper::new( + "numeric-stepper-component-preview", + "10", + move |_, _, _| {}, + move |_, _, _| {}, + )) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/styles/animation.rs b/crates/ui/src/styles/animation.rs index 50c4e0eb0d..0649bee1f8 100644 --- a/crates/ui/src/styles/animation.rs +++ b/crates/ui/src/styles/animation.rs @@ -109,7 +109,7 @@ impl Component for Animation { fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> { let container_size = 128.0; let element_size = 32.0; - let left_offset = element_size - container_size / 2.0; + let offset = container_size / 2.0 - element_size / 2.0; Some( v_flex() .gap_6() @@ -129,7 +129,7 @@ impl Component for Animation { .id("animate-in-from-bottom") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .left(px(offset)) .rounded_md() .bg(gpui::red()) .animate_in(AnimationDirection::FromBottom, false), @@ -148,7 +148,7 @@ impl Component for Animation { .id("animate-in-from-top") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .left(px(offset)) .rounded_md() .bg(gpui::blue()) .animate_in(AnimationDirection::FromTop, false), @@ -167,7 +167,7 @@ impl Component for Animation { .id("animate-in-from-left") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .top(px(offset)) .rounded_md() .bg(gpui::green()) .animate_in(AnimationDirection::FromLeft, false), @@ -186,7 +186,7 @@ impl Component for Animation { .id("animate-in-from-right") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .top(px(offset)) .rounded_md() .bg(gpui::yellow()) .animate_in(AnimationDirection::FromRight, false), @@ -211,7 +211,7 @@ impl Component for Animation { .id("fade-animate-in-from-bottom") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .left(px(offset)) .rounded_md() .bg(gpui::red()) .animate_in(AnimationDirection::FromBottom, true), @@ -230,7 +230,7 @@ impl Component for Animation { .id("fade-animate-in-from-top") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .left(px(offset)) .rounded_md() .bg(gpui::blue()) .animate_in(AnimationDirection::FromTop, true), @@ -249,7 +249,7 @@ impl Component for Animation { .id("fade-animate-in-from-left") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .top(px(offset)) .rounded_md() .bg(gpui::green()) .animate_in(AnimationDirection::FromLeft, true), @@ -268,7 +268,7 @@ impl Component for Animation { .id("fade-animate-in-from-right") .absolute() .size(px(element_size)) - .left(px(left_offset)) + .top(px(offset)) .rounded_md() .bg(gpui::yellow()) .animate_in(AnimationDirection::FromRight, true), diff --git a/crates/web_search/Cargo.toml b/crates/web_search/Cargo.toml index e5b8ca63b2..4ba46faec4 100644 --- a/crates/web_search/Cargo.toml +++ b/crates/web_search/Cargo.toml @@ -13,8 +13,8 @@ path = "src/web_search.rs" [dependencies] anyhow.workspace = true +cloud_llm_client.workspace = true collections.workspace = true gpui.workspace = true serde.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true diff --git a/crates/web_search/src/web_search.rs b/crates/web_search/src/web_search.rs index a131b0de71..8578cfe4aa 100644 --- a/crates/web_search/src/web_search.rs +++ b/crates/web_search/src/web_search.rs @@ -1,8 +1,9 @@ +use std::sync::Arc; + use anyhow::Result; +use cloud_llm_client::WebSearchResponse; use collections::HashMap; use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task}; -use std::sync::Arc; -use zed_llm_client::WebSearchResponse; pub fn init(cx: &mut App) { let registry = cx.new(|_cx| WebSearchRegistry::default()); diff --git a/crates/web_search_providers/Cargo.toml b/crates/web_search_providers/Cargo.toml index 2e052796c4..f7a248d106 100644 --- a/crates/web_search_providers/Cargo.toml +++ b/crates/web_search_providers/Cargo.toml @@ -14,6 +14,7 @@ path = "src/web_search_providers.rs" [dependencies] anyhow.workspace = true client.workspace = true +cloud_llm_client.workspace = true futures.workspace = true gpui.workspace = true http_client.workspace = true @@ -22,4 +23,3 @@ serde.workspace = true serde_json.workspace = true web_search.workspace = true workspace-hack.workspace = true -zed_llm_client.workspace = true diff --git a/crates/web_search_providers/src/cloud.rs b/crates/web_search_providers/src/cloud.rs index adf79b0ff6..52ee0da0d4 100644 --- a/crates/web_search_providers/src/cloud.rs +++ b/crates/web_search_providers/src/cloud.rs @@ -2,12 +2,12 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; use client::Client; +use cloud_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, WebSearchBody, WebSearchResponse}; use futures::AsyncReadExt as _; use gpui::{App, AppContext, Context, Entity, Subscription, Task}; use http_client::{HttpClient, Method}; use language_model::{LlmApiToken, RefreshLlmTokenListener}; use web_search::{WebSearchProvider, WebSearchProviderId}; -use zed_llm_client::{EXPIRED_LLM_TOKEN_HEADER_NAME, WebSearchBody, WebSearchResponse}; pub struct CloudWebSearchProvider { state: Entity<State>, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 77d76b44f5..e58014e7b8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1065,7 +1065,6 @@ pub struct Workspace { center: PaneGroup, left_dock: Entity<Dock>, bottom_dock: Entity<Dock>, - bottom_dock_layout: BottomDockLayout, right_dock: Entity<Dock>, panes: Vec<Entity<Pane>>, panes_by_item: HashMap<EntityId, WeakEntity<Pane>>, @@ -1307,7 +1306,6 @@ impl Workspace { ) .detach(); - let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout; let left_dock = Dock::new(DockPosition::Left, modal_layer.clone(), window, cx); let bottom_dock = Dock::new(DockPosition::Bottom, modal_layer.clone(), window, cx); let right_dock = Dock::new(DockPosition::Right, modal_layer.clone(), window, cx); @@ -1406,7 +1404,6 @@ impl Workspace { suppressed_notifications: HashSet::default(), left_dock, bottom_dock, - bottom_dock_layout, right_dock, project: project.clone(), follower_states: Default::default(), @@ -1633,10 +1630,6 @@ impl Workspace { &self.bottom_dock } - pub fn bottom_dock_layout(&self) -> BottomDockLayout { - self.bottom_dock_layout - } - pub fn set_bottom_dock_layout( &mut self, layout: BottomDockLayout, @@ -1648,7 +1641,6 @@ impl Workspace { content.bottom_dock_layout = Some(layout); }); - self.bottom_dock_layout = layout; cx.notify(); self.serialize_workspace(window, cx); } @@ -6246,6 +6238,7 @@ impl Render for Workspace { .iter() .map(|(_, notification)| notification.entity_id()) .collect::<Vec<_>>(); + let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout; client_side_decorations( self.actions(div(), window, cx) @@ -6369,7 +6362,7 @@ impl Render for Workspace { )) }) .child({ - match self.bottom_dock_layout { + match bottom_dock_layout { BottomDockLayout::Full => div() .flex() .flex_col() diff --git a/crates/zed/resources/windows/zed.iss b/crates/zed/resources/windows/zed.iss index 9d104d1f15..51c1dd096e 100644 --- a/crates/zed/resources/windows/zed.iss +++ b/crates/zed/resources/windows/zed.iss @@ -1245,16 +1245,6 @@ Root: HKCU; Subkey: "Software\Classes\zed\DefaultIcon"; ValueType: "string"; Val Root: HKCU; Subkey: "Software\Classes\zed\shell\open\command"; ValueType: "string"; ValueData: """{app}\Zed.exe"" ""%1""" [Code] -function InitializeSetup(): Boolean; -begin - Result := True; - - if not WizardSilent() and IsAdmin() then begin - MsgBox('This User Installer is not meant to be run as an Administrator.', mbError, MB_OK); - Result := False; - end; -end; - function WizardNotSilent(): Boolean; begin Result := not WizardSilent(); diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index ccbe57e7b3..d7f1473288 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -63,7 +63,7 @@ pub fn init_panic_hook( location.column(), match app_commit_sha.as_ref() { Some(commit_sha) => format!( - "https://github.com/zed-industries/zed/blob/{}/src/{}#L{} \ + "https://github.com/zed-industries/zed/blob/{}/{}#L{} \ (may not be uploaded, line may be incorrect if files modified)\n", commit_sha.full(), location.file(), diff --git a/crates/zed/src/zed/component_preview.rs b/crates/zed/src/zed/component_preview.rs index 670793cff3..2e57152c62 100644 --- a/crates/zed/src/zed/component_preview.rs +++ b/crates/zed/src/zed/component_preview.rs @@ -105,6 +105,7 @@ enum PreviewPage { struct ComponentPreview { active_page: PreviewPage, active_thread: Option<Entity<ActiveThread>>, + reset_key: usize, component_list: ListState, component_map: HashMap<ComponentId, ComponentMetadata>, components: Vec<ComponentMetadata>, @@ -188,6 +189,7 @@ impl ComponentPreview { let mut component_preview = Self { active_page, active_thread: None, + reset_key: 0, component_list, component_map: component_registry.component_map(), components: sorted_components, @@ -265,8 +267,13 @@ impl ComponentPreview { } fn set_active_page(&mut self, page: PreviewPage, cx: &mut Context<Self>) { - self.active_page = page; - cx.emit(ItemEvent::UpdateTab); + if self.active_page == page { + // Force the current preview page to render again + self.reset_key = self.reset_key.wrapping_add(1); + } else { + self.active_page = page; + cx.emit(ItemEvent::UpdateTab); + } cx.notify(); } @@ -690,6 +697,7 @@ impl ComponentPreview { component.clone(), self.workspace.clone(), self.active_thread.clone(), + self.reset_key, )) .into_any_element() } else { @@ -1041,6 +1049,7 @@ pub struct ComponentPreviewPage { component: ComponentMetadata, workspace: WeakEntity<Workspace>, active_thread: Option<Entity<ActiveThread>>, + reset_key: usize, } impl ComponentPreviewPage { @@ -1048,6 +1057,7 @@ impl ComponentPreviewPage { component: ComponentMetadata, workspace: WeakEntity<Workspace>, active_thread: Option<Entity<ActiveThread>>, + reset_key: usize, // languages: Arc<LanguageRegistry> ) -> Self { Self { @@ -1055,6 +1065,7 @@ impl ComponentPreviewPage { component, workspace, active_thread, + reset_key, } } @@ -1155,6 +1166,7 @@ impl ComponentPreviewPage { }; v_flex() + .id(("component-preview", self.reset_key)) .size_full() .flex_1() .px_12() diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml index c2b1de08ae..294d95aefd 100644 --- a/crates/zeta/Cargo.toml +++ b/crates/zeta/Cargo.toml @@ -21,6 +21,7 @@ ai_onboarding.workspace = true anyhow.workspace = true arrayvec.workspace = true client.workspace = true +cloud_llm_client.workspace = true collections.workspace = true command_palette_hooks.workspace = true copilot.workspace = true @@ -52,11 +53,10 @@ thiserror.workspace = true ui.workspace = true util.workspace = true uuid.workspace = true +workspace-hack.workspace = true workspace.workspace = true worktree.workspace = true zed_actions.workspace = true -zed_llm_client.workspace = true -workspace-hack.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index d6f033899d..d5c6be278b 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -17,6 +17,10 @@ pub use rate_completion_modal::*; use anyhow::{Context as _, Result, anyhow}; use arrayvec::ArrayVec; use client::{Client, EditPredictionUsage, UserStore}; +use cloud_llm_client::{ + AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, MINIMUM_REQUIRED_VERSION_HEADER_NAME, + PredictEditsBody, PredictEditsResponse, ZED_VERSION_HEADER_NAME, +}; use collections::{HashMap, HashSet, VecDeque}; use futures::AsyncReadExt; use gpui::{ @@ -53,10 +57,6 @@ use uuid::Uuid; use workspace::Workspace; use workspace::notifications::{ErrorMessagePrompt, NotificationId}; use worktree::Worktree; -use zed_llm_client::{ - AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, MINIMUM_REQUIRED_VERSION_HEADER_NAME, - PredictEditsBody, PredictEditsResponse, ZED_VERSION_HEADER_NAME, -}; const CURSOR_MARKER: &'static str = "<|user_cursor_is_here|>"; const START_OF_FILE_MARKER: &'static str = "<|start_of_file|>"; diff --git a/crates/zlog/src/sink.rs b/crates/zlog/src/sink.rs index acf0469c77..17aa08026e 100644 --- a/crates/zlog/src/sink.rs +++ b/crates/zlog/src/sink.rs @@ -21,6 +21,8 @@ const ANSI_MAGENTA: &str = "\x1b[35m"; /// Whether stdout output is enabled. static mut ENABLED_SINKS_STDOUT: bool = false; +/// Whether stderr output is enabled. +static mut ENABLED_SINKS_STDERR: bool = false; /// Is Some(file) if file output is enabled. static ENABLED_SINKS_FILE: Mutex<Option<std::fs::File>> = Mutex::new(None); @@ -45,6 +47,12 @@ pub fn init_output_stdout() { } } +pub fn init_output_stderr() { + unsafe { + ENABLED_SINKS_STDERR = true; + } +} + pub fn init_output_file( path: &'static PathBuf, path_rotate: Option<&'static PathBuf>, @@ -115,6 +123,21 @@ pub fn submit(record: Record) { }, record.message ); + } else if unsafe { ENABLED_SINKS_STDERR } { + let mut stdout = std::io::stderr().lock(); + _ = writeln!( + &mut stdout, + "{} {ANSI_BOLD}{}{}{ANSI_RESET} {} {}", + chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z"), + LEVEL_ANSI_COLORS[record.level as usize], + LEVEL_OUTPUT_STRINGS[record.level as usize], + SourceFmt { + scope: record.scope, + module_path: record.module_path, + ansi: true, + }, + record.message + ); } let mut file = ENABLED_SINKS_FILE.lock().unwrap_or_else(|handle| { ENABLED_SINKS_FILE.clear_poison(); diff --git a/crates/zlog/src/zlog.rs b/crates/zlog/src/zlog.rs index 570c82314c..5b40278f3f 100644 --- a/crates/zlog/src/zlog.rs +++ b/crates/zlog/src/zlog.rs @@ -5,7 +5,7 @@ mod env_config; pub mod filter; pub mod sink; -pub use sink::{flush, init_output_file, init_output_stdout}; +pub use sink::{flush, init_output_file, init_output_stderr, init_output_stdout}; pub const SCOPE_DEPTH_MAX: usize = 4; diff --git a/docs/README.md b/docs/README.md index 55993c9e36..a225903674 100644 --- a/docs/README.md +++ b/docs/README.md @@ -69,3 +69,64 @@ Templates are just functions that modify the source of the docs pages (usually w - Template Trait: crates/docs_preprocessor/src/templates.rs - Example template: crates/docs_preprocessor/src/templates/keybinding.rs - Client-side plugins: docs/theme/plugins.js + +## Postprocessor + +A postprocessor is implemented as a sub-command of `docs_preprocessor` that wraps the builtin `html` renderer and applies post-processing to the `html` files, to add support for page-specific title and meta description values. + +An example of the syntax can be found in `git.md`, as well as below + +```md +--- +title: Some more detailed title for this page +description: A page-specific description +--- + +# Editor +``` + +The above will be transformed into (with non-relevant tags removed) + +```html +<head> + <title>Editor | Some more detailed title for this page + + + +

Editor

+ +``` + +If no front-matter is provided, or If one or both keys aren't provided, the title and description will be set based on the `default-title` and `default-description` keys in `book.toml` respectively. + +### Implementation details + +Unfortunately, `mdbook` does not support post-processing like it does pre-processing, and only supports defining one description to put in the meta tag per book rather than per file. So in order to apply post-processing (necessary to modify the html head tags) the global book description is set to a marker value `#description#` and the html renderer is replaced with a sub-command of `docs_preprocessor` that wraps the builtin `html` renderer and applies post-processing to the `html` files, replacing the marker value and the `(.*)` with the contents of the front-matter if there is one. + +### Known limitations + +The front-matter parsing is extremely simple, which avoids needing to take on an additional dependency, or implement full yaml parsing. + +- Double quotes and multi-line values are not supported, i.e. Keys and values must be entirely on the same line, with no double quotes around the value. + +The following will not work: + +```md +--- +title: Some + Multi-line + Title +--- +``` + +And neither will: + +```md +--- +title: "Some title" +--- +``` + +- The front-matter must be at the top of the file, with only white-space preceding it + +- The contents of the title and description will not be html-escaped. They should be simple ascii text with no unicode or emoji characters diff --git a/docs/book.toml b/docs/book.toml index 518fbec819..60ddc5ac51 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -6,13 +6,27 @@ src = "src" title = "Zed" site-url = "/docs/" -[output.html] +[build] +extra-watch-dirs = ["../crates/docs_preprocessor"] + +# zed-html is a "custom" renderer that just wraps the +# builtin mdbook html renderer, and applies post-processing +# as post-processing is not possible with mdbook in the same way +# pre-processing is +# The config is passed directly to the html renderer, so all config +# options that apply to html apply to zed-html +[output.zed-html] +command = "cargo run -p docs_preprocessor -- postprocess" +# Set here instead of above as we only use it replace the `#description#` we set in the template +# when no front-matter is provided value +default-description = "Learn how to use and customize Zed, the fast, collaborative code editor. Official docs on features, configuration, AI tools, and workflows." +default-title = "Zed Code Editor Documentation" no-section-label = true preferred-dark-theme = "dark" additional-css = ["theme/page-toc.css", "theme/plugins.css", "theme/highlight.css"] additional-js = ["theme/page-toc.js", "theme/plugins.js"] -[output.html.print] +[output.zed-html.print] enable = false # Redirects for `/docs` pages. @@ -24,7 +38,7 @@ enable = false # The destination URLs are interpreted relative to `https://zed.dev`. # - Redirects to other docs pages should end in `.html` # - You can link to pages on the Zed site by omitting the `/docs` in front of it. -[output.html.redirect] +[output.zed-html.redirect] # AI "/ai.html" = "/docs/ai/overview.html" "/assistant-panel.html" = "/docs/ai/agent-panel.html" diff --git a/docs/src/git.md b/docs/src/git.md index 5b5c8a3b15..cccbad9b2e 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -1,3 +1,8 @@ +--- +description: Zed is a text editor that supports lots of Git features +title: Zed Editor Git integration documentation +--- + # Git Zed currently offers a set of fundamental Git features, with support coming in the future for more advanced ones, like conflict resolution tools, line by line staging, and more. diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs index 8ab4f21cf1..4339a02d17 100644 --- a/docs/theme/index.hbs +++ b/docs/theme/index.hbs @@ -15,7 +15,7 @@ {{> head}} - + diff --git a/extensions/emmet/Cargo.toml b/extensions/emmet/Cargo.toml index db8aaaae41..9d72a6c5c4 100644 --- a/extensions/emmet/Cargo.toml +++ b/extensions/emmet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_emmet" -version = "0.0.3" +version = "0.0.4" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/script/bundle-windows.ps1 b/script/bundle-windows.ps1 index 3aac8700ce..2f751f1d10 100644 --- a/script/bundle-windows.ps1 +++ b/script/bundle-windows.ps1 @@ -26,6 +26,7 @@ if ($Help) { Push-Location -Path crates/zed $channel = Get-Content "RELEASE_CHANNEL" $env:ZED_RELEASE_CHANNEL = $channel +$env:RELEASE_CHANNEL = $channel Pop-Location function CheckEnvironmentVariables { diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index 1026454026..e5123d5ab3 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -284,7 +284,6 @@ winnow = { version = "0.7", features = ["simd"] } codespan-reporting = { version = "0.12" } core-foundation = { version = "0.9" } core-foundation-sys = { version = "0.8" } -coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } @@ -310,11 +309,9 @@ tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } [target.x86_64-apple-darwin.build-dependencies] -clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] } codespan-reporting = { version = "0.12" } core-foundation = { version = "0.9" } core-foundation-sys = { version = "0.8" } -coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } @@ -344,7 +341,6 @@ tower = { version = "0.5", default-features = false, features = ["timeout", "uti codespan-reporting = { version = "0.12" } core-foundation = { version = "0.9" } core-foundation-sys = { version = "0.8" } -coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } @@ -370,11 +366,9 @@ tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } [target.aarch64-apple-darwin.build-dependencies] -clang-sys = { version = "1", default-features = false, features = ["clang_11_0", "runtime"] } codespan-reporting = { version = "0.12" } core-foundation = { version = "0.9" } core-foundation-sys = { version = "0.8" } -coreaudio-sys = { version = "0.2", default-features = false, features = ["audio_toolbox", "audio_unit", "core_audio", "core_midi", "open_al"] } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] }