From f31b0690e7de1688cd88f0a8cd91eb9a9661531f Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 25 Mar 2026 09:23:05 -0500 Subject: [PATCH 01/45] Remove Sweep Integration (#52348) Closes #52115 ## Context Removes the third party edit prediction integration for Sweep AI ahead of their servers shutting down. This PR will not affect those who are already use or plan to use their open weighted models. The code removed by this PR was required for integrating with their proprietary API. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Removed support for using the Sweep AI edit prediction provider through their proprietary API, as the servers are shutting down https://discord.com/channels/1100625416022138902/1100625417272045639/1480644297903575142, https://x.com/wwzeng1/status/2033302698360180949 --- Cargo.lock | 37 - assets/icons/sweep_ai.svg | 1 - assets/icons/sweep_ai_disabled.svg | 1 - assets/icons/sweep_ai_down.svg | 1 - assets/icons/sweep_ai_error.svg | 1 - assets/icons/sweep_ai_up.svg | 1 - assets/settings/default.json | 7 - crates/agent_ui/src/agent_ui.rs | 1 - crates/edit_prediction/Cargo.toml | 1 - crates/edit_prediction/src/edit_prediction.rs | 134 +--- crates/edit_prediction/src/sweep_ai.rs | 669 ------------------ .../src/zed_edit_prediction_delegate.rs | 11 +- crates/edit_prediction_cli/src/main.rs | 5 +- crates/edit_prediction_cli/src/predict.rs | 1 - .../src/edit_prediction_button.rs | 25 +- crates/icons/src/icons.rs | 5 - crates/language/src/language_settings.rs | 16 - crates/settings_content/src/language.rs | 23 +- .../pages/edit_prediction_provider_setup.rs | 58 -- .../zed/src/zed/edit_prediction_registry.rs | 5 +- docs/src/ai/edit-prediction.md | 28 +- 21 files changed, 15 insertions(+), 1016 deletions(-) delete mode 100644 assets/icons/sweep_ai.svg delete mode 100644 assets/icons/sweep_ai_disabled.svg delete mode 100644 assets/icons/sweep_ai_down.svg delete mode 100644 assets/icons/sweep_ai_error.svg delete mode 100644 assets/icons/sweep_ai_up.svg delete mode 100644 crates/edit_prediction/src/sweep_ai.rs diff --git a/Cargo.lock b/Cargo.lock index 07a058a4032ecea1d85c2571c246767a373e0193..37cb42f56bb2ea465d1d17fa1f1f6a8e79600750 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,21 +511,6 @@ dependencies = [ "equator", ] -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -2247,27 +2232,6 @@ dependencies = [ "workspace", ] -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "brush-parser" version = "0.3.0" @@ -5244,7 +5208,6 @@ version = "0.1.0" dependencies = [ "ai_onboarding", "anyhow", - "brotli", "buffer_diff", "client", "clock", diff --git a/assets/icons/sweep_ai.svg b/assets/icons/sweep_ai.svg deleted file mode 100644 index 9c63c810dd9e164c14c1ad1a1bca9c6ec68fc95e..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_disabled.svg b/assets/icons/sweep_ai_disabled.svg deleted file mode 100644 index b15a8d8526f36f312482effefd3d7538ce5f7a04..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_disabled.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_down.svg b/assets/icons/sweep_ai_down.svg deleted file mode 100644 index f08dcb171811c761cd13c4efd0ef0acdc78f9951..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_down.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_error.svg b/assets/icons/sweep_ai_error.svg deleted file mode 100644 index 95285a1273e72ec4f02cb23e3c2fb39460f42761..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_error.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/sweep_ai_up.svg b/assets/icons/sweep_ai_up.svg deleted file mode 100644 index 7c28282a6a14c47561a50ab456c0bec2e05b07cc..0000000000000000000000000000000000000000 --- a/assets/icons/sweep_ai_up.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/settings/default.json b/assets/settings/default.json index d3fb32fd438eddd42ca5bf69815cf66d618ad570..6a973314fc36b3b3cc1056dbb10a629f7868d2a1 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1585,13 +1585,6 @@ "model": "codestral-latest", "max_tokens": 150, }, - "sweep": { - // When enabled, Sweep will not store edit prediction inputs or outputs. - // When disabled, Sweep may collect data including buffer contents, - // diagnostics, file paths, repository names, and generated predictions - // to improve the service. - "privacy_mode": false, - }, "ollama": { "api_url": "http://localhost:11434", "model": "qwen2.5-coder:7b-base", diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 8f41944723e05e5a89d5df6ccc947c0d62488ff0..1817fdbe31a4fed12caf2e5f804461aecf9da973 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -502,7 +502,6 @@ fn update_command_palette_filter(cx: &mut App) { | EditPredictionProvider::Codestral | EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Experimental(_) => { filter.show_namespace("edit_prediction"); diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index a6a7d8777cbf0d52575489e91a5ae03be2d031ea..75a589dea8f9c7fefe7bf13400cbdde54bf90bf1 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -18,7 +18,6 @@ cli-support = [] ai_onboarding.workspace = true anyhow.workspace = true heapless.workspace = true -brotli.workspace = true buffer_diff.workspace = true client.workspace = true clock.workspace = true diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 421a51b055693617a915e622b617298f5f8a01c5..3ae4eb72b3a60ab56d865a235c43e2f0e3adab31 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -50,7 +50,8 @@ use std::path::Path; use std::rc::Rc; use std::str::FromStr as _; use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; + use thiserror::Error; use util::{RangeExt as _, ResultExt as _}; @@ -63,7 +64,6 @@ pub mod ollama; mod onboarding_modal; pub mod open_ai_response; mod prediction; -pub mod sweep_ai; pub mod udiff; @@ -83,7 +83,6 @@ use crate::onboarding_modal::ZedPredictModal; pub use crate::prediction::EditPrediction; pub use crate::prediction::EditPredictionId; use crate::prediction::EditPredictionResult; -pub use crate::sweep_ai::SweepAi; pub use capture_example::capture_example; pub use language_model::ApiKeyState; pub use telemetry_events::EditPredictionRating; @@ -143,7 +142,6 @@ pub struct EditPredictionStore { zeta2_raw_config: Option, preferred_experiment: Option, available_experiments: Vec, - pub sweep_ai: SweepAi, pub mercury: Mercury, data_collection_choice: DataCollectionChoice, reject_predictions_tx: mpsc::UnboundedSender, @@ -163,7 +161,6 @@ pub(crate) struct EditPredictionRejectionPayload { pub enum EditPredictionModel { Zeta, Fim { format: EditPredictionPromptFormat }, - Sweep, Mercury, } @@ -175,13 +172,11 @@ pub struct EditPredictionModelInput { position: Anchor, events: Vec>, related_files: Vec, - recent_paths: VecDeque, trigger: PredictEditsRequestTrigger, diagnostic_search_range: Range, debug_tx: Option>, can_collect_data: bool, is_open_source: bool, - pub user_actions: Vec, } #[derive(Debug)] @@ -220,26 +215,6 @@ pub struct EditPredictionFinishedDebugEvent { pub model_output: Option, } -const USER_ACTION_HISTORY_SIZE: usize = 16; - -#[derive(Clone, Debug)] -pub struct UserActionRecord { - pub action_type: UserActionType, - pub buffer_id: EntityId, - pub line_number: u32, - pub offset: usize, - pub timestamp_epoch_ms: u64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum UserActionType { - InsertChar, - InsertSelection, - DeleteChar, - DeleteSelection, - CursorMovement, -} - /// An event with associated metadata for reconstructing buffer state. #[derive(Clone)] pub struct StoredEvent { @@ -339,19 +314,11 @@ struct ProjectState { cancelled_predictions: HashSet, context: Entity, license_detection_watchers: HashMap>, - user_actions: VecDeque, _subscriptions: [gpui::Subscription; 2], copilot: Option>, } impl ProjectState { - fn record_user_action(&mut self, action: UserActionRecord) { - if self.user_actions.len() >= USER_ACTION_HISTORY_SIZE { - self.user_actions.pop_front(); - } - self.user_actions.push_back(action); - } - pub fn events(&self, cx: &App) -> Vec { self.events .iter() @@ -828,7 +795,6 @@ impl EditPredictionStore { zeta2_raw_config: Self::zeta2_raw_config_from_env(), preferred_experiment: None, available_experiments: Vec::new(), - sweep_ai: SweepAi::new(cx), mercury: Mercury::new(cx), data_collection_choice, @@ -939,13 +905,6 @@ impl EditPredictionStore { pub fn icons(&self, cx: &App) -> edit_prediction_types::EditPredictionIconSet { use ui::IconName; match self.edit_prediction_model { - EditPredictionModel::Sweep => { - edit_prediction_types::EditPredictionIconSet::new(IconName::SweepAi) - .with_disabled(IconName::SweepAiDisabled) - .with_up(IconName::SweepAiUp) - .with_down(IconName::SweepAiDown) - .with_error(IconName::SweepAiError) - } EditPredictionModel::Mercury => { edit_prediction_types::EditPredictionIconSet::new(IconName::Inception) } @@ -970,10 +929,6 @@ impl EditPredictionStore { } } - pub fn has_sweep_api_token(&self, cx: &App) -> bool { - self.sweep_ai.api_token.read(cx).has_key() - } - pub fn has_mercury_api_token(&self, cx: &App) -> bool { self.mercury.api_token.read(cx).has_key() } @@ -1132,7 +1087,6 @@ impl EditPredictionStore { last_edit_prediction_refresh: None, last_jump_prediction_refresh: None, license_detection_watchers: HashMap::default(), - user_actions: VecDeque::with_capacity(USER_ACTION_HISTORY_SIZE), _subscriptions: [ cx.subscribe(&project, Self::handle_project_event), cx.observe_release(&project, move |this, _, cx| { @@ -1347,24 +1301,16 @@ impl EditPredictionStore { } let old_file = mem::replace(&mut registered_buffer.file, new_file.clone()); let old_snapshot = mem::replace(&mut registered_buffer.snapshot, new_snapshot.clone()); - let mut num_edits = 0usize; - let mut total_deleted = 0usize; - let mut total_inserted = 0usize; let mut edit_range: Option> = None; - let mut last_offset: Option = None; let now = cx.background_executor().now(); - for (edit, anchor_range) in + for (_edit, anchor_range) in new_snapshot.anchored_edits_since::(&old_snapshot.version) { - num_edits += 1; - total_deleted += edit.old.len(); - total_inserted += edit.new.len(); edit_range = Some(match edit_range { None => anchor_range, Some(acc) => acc.start..anchor_range.end, }); - last_offset = Some(edit.new.end); } let Some(edit_range) = edit_range else { @@ -1387,32 +1333,6 @@ impl EditPredictionStore { cx, ); - if is_local { - let action_type = match (total_deleted, total_inserted, num_edits) { - (0, ins, n) if ins == n => UserActionType::InsertChar, - (0, _, _) => UserActionType::InsertSelection, - (del, 0, n) if del == n => UserActionType::DeleteChar, - (_, 0, _) => UserActionType::DeleteSelection, - (_, ins, n) if ins == n => UserActionType::InsertChar, - (_, _, _) => UserActionType::InsertSelection, - }; - - if let Some(offset) = last_offset { - let point = new_snapshot.offset_to_point(offset); - let timestamp_epoch_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - project_state.record_user_action(UserActionRecord { - action_type, - buffer_id: buffer.entity_id(), - line_number: point.row, - offset, - timestamp_epoch_ms, - }); - } - } - if !include_in_history { return; } @@ -1562,9 +1482,6 @@ impl EditPredictionStore { } match self.edit_prediction_model { - EditPredictionModel::Sweep => { - sweep_ai::edit_prediction_accepted(self, current_prediction, cx) - } EditPredictionModel::Mercury => { mercury::edit_prediction_accepted( current_prediction.prediction.id, @@ -1792,7 +1709,7 @@ impl EditPredictionStore { &mut self, project: &Entity, display_type: edit_prediction_types::SuggestionDisplayType, - cx: &mut Context, + _cx: &mut Context, ) { let Some(project_state) = self.projects.get_mut(&project.entity_id()) else { return; @@ -1815,18 +1732,6 @@ impl EditPredictionStore { current_prediction.was_shown = true; } - let display_type_changed = previous_shown_with != Some(display_type); - - if self.edit_prediction_model == EditPredictionModel::Sweep && display_type_changed { - sweep_ai::edit_prediction_shown( - &self.sweep_ai, - self.client.clone(), - ¤t_prediction.prediction, - display_type, - cx, - ); - } - if is_first_non_jump_show { self.shown_predictions .push_front(current_prediction.prediction.clone()); @@ -1883,7 +1788,7 @@ impl EditPredictionStore { cx, ); } - EditPredictionModel::Sweep | EditPredictionModel::Fim { .. } => {} + EditPredictionModel::Fim { .. } => {} } } @@ -2108,7 +2013,6 @@ fn currently_following(project: &Entity, cx: &App) -> bool { fn is_ep_store_provider(provider: EditPredictionProvider) -> bool { match provider { EditPredictionProvider::Zed - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi @@ -2148,7 +2052,6 @@ impl EditPredictionStore { let (needs_acceptance_tracking, max_pending_predictions) = match all_language_settings(None, cx).edit_predictions.provider { EditPredictionProvider::Zed - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Experimental(_) => (true, 2), EditPredictionProvider::Ollama => (false, 1), @@ -2370,28 +2273,6 @@ impl EditPredictionStore { let snapshot = active_buffer.read(cx).snapshot(); let cursor_point = position.to_point(&snapshot); - let current_offset = position.to_offset(&snapshot); - - let mut user_actions: Vec = - project_state.user_actions.iter().cloned().collect(); - - if let Some(last_action) = user_actions.last() { - if last_action.buffer_id == active_buffer.entity_id() - && current_offset != last_action.offset - { - let timestamp_epoch_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - user_actions.push(UserActionRecord { - action_type: UserActionType::CursorMovement, - buffer_id: active_buffer.entity_id(), - line_number: cursor_point.row, - offset: current_offset, - timestamp_epoch_ms, - }); - } - } let diagnostic_search_start = cursor_point.row.saturating_sub(DIAGNOSTIC_LINES_RANGE); let diagnostic_search_end = cursor_point.row + DIAGNOSTIC_LINES_RANGE; let diagnostic_search_range = @@ -2410,8 +2291,6 @@ impl EditPredictionStore { && self.is_data_collection_enabled(cx) && matches!(self.edit_prediction_model, EditPredictionModel::Zeta); - let recent_paths = project_state.recent_paths.clone(); - let inputs = EditPredictionModelInput { project: project.clone(), buffer: active_buffer, @@ -2419,11 +2298,9 @@ impl EditPredictionStore { position, events, related_files, - recent_paths, trigger, diagnostic_search_range: diagnostic_search_range, debug_tx, - user_actions, can_collect_data, is_open_source, }; @@ -2435,7 +2312,6 @@ impl EditPredictionStore { zeta::request_prediction_with_zeta(self, inputs, capture_data, cx) } EditPredictionModel::Fim { format } => fim::request_prediction(inputs, format, cx), - EditPredictionModel::Sweep => self.sweep_ai.request_prediction_with_sweep(inputs, cx), EditPredictionModel::Mercury => self.mercury.request_prediction(inputs, cx), }; diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs deleted file mode 100644 index 93a9a34340cfe0b55e40d35bb4c8980dff983fa5..0000000000000000000000000000000000000000 --- a/crates/edit_prediction/src/sweep_ai.rs +++ /dev/null @@ -1,669 +0,0 @@ -use crate::{ - CurrentEditPrediction, DebugEvent, EditPrediction, EditPredictionFinishedDebugEvent, - EditPredictionId, EditPredictionModelInput, EditPredictionStartedDebugEvent, - EditPredictionStore, UserActionRecord, UserActionType, prediction::EditPredictionResult, -}; -use anyhow::{Result, bail}; -use client::Client; -use edit_prediction_types::SuggestionDisplayType; -use futures::{AsyncReadExt as _, channel::mpsc}; -use gpui::{ - App, AppContext as _, Entity, Global, SharedString, Task, - http_client::{self, AsyncBody, Method}, -}; -use language::language_settings::all_language_settings; -use language::{Anchor, Buffer, BufferSnapshot, Point, ToOffset as _}; -use language_model::{ApiKeyState, EnvVar, env_var}; -use lsp::DiagnosticSeverity; -use serde::{Deserialize, Serialize}; -use std::{ - fmt::{self, Write as _}, - ops::Range, - path::Path, - sync::Arc, -}; - -const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; -const SWEEP_METRICS_URL: &str = "https://backend.app.sweep.dev/backend/track_autocomplete_metrics"; - -pub struct SweepAi { - pub api_token: Entity, - pub debug_info: Arc, -} - -impl SweepAi { - pub fn new(cx: &mut App) -> Self { - SweepAi { - api_token: sweep_api_token(cx), - debug_info: debug_info(cx), - } - } - - pub fn request_prediction_with_sweep( - &self, - inputs: EditPredictionModelInput, - cx: &mut App, - ) -> Task>> { - let privacy_mode_enabled = all_language_settings(None, cx) - .edit_predictions - .sweep - .privacy_mode; - let debug_info = self.debug_info.clone(); - let request_start = cx.background_executor().now(); - self.api_token.update(cx, |key_state, cx| { - _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); - }); - - let buffer = inputs.buffer.clone(); - let debug_tx = inputs.debug_tx.clone(); - - let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { - return Task::ready(Ok(None)); - }; - let full_path: Arc = inputs - .snapshot - .file() - .map(|file| file.full_path(cx)) - .unwrap_or_else(|| "untitled".into()) - .into(); - - let project_file = project::File::from_dyn(inputs.snapshot.file()); - let repo_name = project_file - .map(|file| file.worktree.read(cx).root_name_str()) - .unwrap_or("untitled") - .into(); - let offset = inputs.position.to_offset(&inputs.snapshot); - let buffer_entity_id = inputs.buffer.entity_id(); - - let recent_buffers = inputs.recent_paths.iter().cloned(); - let http_client = cx.http_client(); - - let recent_buffer_snapshots = recent_buffers - .filter_map(|project_path| { - let buffer = inputs.project.read(cx).get_open_buffer(&project_path, cx)?; - if inputs.buffer == buffer { - None - } else { - Some(buffer.read(cx).snapshot()) - } - }) - .take(3) - .collect::>(); - - let result = cx.background_spawn(async move { - let text = inputs.snapshot.text(); - - let mut recent_changes = String::new(); - for event in &inputs.events { - write_event(event.as_ref(), &mut recent_changes).unwrap(); - } - - let file_chunks = recent_buffer_snapshots - .into_iter() - .map(|snapshot| { - let end_point = Point::new(30, 0).min(snapshot.max_point()); - FileChunk { - content: snapshot.text_for_range(Point::zero()..end_point).collect(), - file_path: snapshot - .file() - .map(|f| f.path().as_unix_str()) - .unwrap_or("untitled") - .to_string(), - start_line: 0, - end_line: end_point.row as usize, - timestamp: snapshot.file().and_then(|file| { - Some( - file.disk_state() - .mtime()? - .to_seconds_and_nanos_for_persistence()? - .0, - ) - }), - } - }) - .collect::>(); - - let mut retrieval_chunks: Vec = inputs - .related_files - .iter() - .flat_map(|related_file| { - related_file.excerpts.iter().map(|excerpt| FileChunk { - file_path: related_file.path.to_string_lossy().to_string(), - start_line: excerpt.row_range.start as usize, - end_line: excerpt.row_range.end as usize, - content: excerpt.text.to_string(), - timestamp: None, - }) - }) - .collect(); - - let diagnostic_entries = inputs - .snapshot - .diagnostics_in_range(inputs.diagnostic_search_range, false); - let mut diagnostic_content = String::new(); - let mut diagnostic_count = 0; - - for entry in diagnostic_entries { - let start_point: Point = entry.range.start; - - let severity = match entry.diagnostic.severity { - DiagnosticSeverity::ERROR => "error", - DiagnosticSeverity::WARNING => "warning", - DiagnosticSeverity::INFORMATION => "info", - DiagnosticSeverity::HINT => "hint", - _ => continue, - }; - - diagnostic_count += 1; - - writeln!( - &mut diagnostic_content, - "{}:{}:{}: {}: {}", - full_path.display(), - start_point.row + 1, - start_point.column + 1, - severity, - entry.diagnostic.message - )?; - } - - if !diagnostic_content.is_empty() { - retrieval_chunks.push(FileChunk { - file_path: "diagnostics".to_string(), - start_line: 1, - end_line: diagnostic_count, - content: diagnostic_content, - timestamp: None, - }); - } - - let file_path_str = full_path.display().to_string(); - let recent_user_actions = inputs - .user_actions - .iter() - .filter(|r| r.buffer_id == buffer_entity_id) - .map(|r| to_sweep_user_action(r, &file_path_str)) - .collect(); - - let request_body = AutocompleteRequest { - debug_info, - repo_name, - file_path: full_path.clone(), - file_contents: text.clone(), - original_file_contents: text, - cursor_position: offset, - recent_changes: recent_changes.clone(), - changes_above_cursor: true, - multiple_suggestions: false, - branch: None, - file_chunks, - retrieval_chunks, - recent_user_actions, - use_bytes: true, - privacy_mode_enabled, - }; - - let mut buf: Vec = Vec::new(); - let writer = brotli::CompressorWriter::new(&mut buf, 4096, 1, 22); - serde_json::to_writer(writer, &request_body)?; - let body: AsyncBody = buf.into(); - - let ep_inputs = zeta_prompt::ZetaPromptInput { - events: inputs.events, - related_files: Some(inputs.related_files.clone()), - active_buffer_diagnostics: vec![], - cursor_path: full_path.clone(), - cursor_excerpt: request_body.file_contents.clone().into(), - cursor_offset_in_excerpt: request_body.cursor_position, - excerpt_start_row: Some(0), - excerpt_ranges: zeta_prompt::ExcerptRanges { - editable_150: 0..inputs.snapshot.len(), - editable_180: 0..inputs.snapshot.len(), - editable_350: 0..inputs.snapshot.len(), - editable_150_context_350: 0..inputs.snapshot.len(), - editable_180_context_350: 0..inputs.snapshot.len(), - editable_350_context_150: 0..inputs.snapshot.len(), - ..Default::default() - }, - syntax_ranges: None, - experiment: None, - in_open_source_repo: false, - can_collect_data: false, - repo_url: None, - }; - - send_started_event( - &debug_tx, - &buffer, - inputs.position, - serde_json::to_string(&request_body).unwrap_or_default(), - ); - - let request = http_client::Request::builder() - .uri(SWEEP_API_URL) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_token)) - .header("Connection", "keep-alive") - .header("Content-Encoding", "br") - .method(Method::POST) - .body(body)?; - - let mut response = http_client.send(request).await?; - - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - - if !response.status().is_success() { - let message = format!( - "Request failed with status: {:?}\nBody: {}", - response.status(), - body, - ); - send_finished_event(&debug_tx, &buffer, inputs.position, message.clone()); - bail!(message); - }; - - let response: AutocompleteResponse = serde_json::from_str(&body)?; - - send_finished_event(&debug_tx, &buffer, inputs.position, body); - - let old_text = inputs - .snapshot - .text_for_range(response.start_index..response.end_index) - .collect::(); - let edits = language::text_diff(&old_text, &response.completion) - .into_iter() - .map(|(range, text)| { - ( - inputs - .snapshot - .anchor_after(response.start_index + range.start) - ..inputs - .snapshot - .anchor_before(response.start_index + range.end), - text, - ) - }) - .collect::>(); - - anyhow::Ok((response.autocomplete_id, edits, inputs.snapshot, ep_inputs)) - }); - - let buffer = inputs.buffer.clone(); - - cx.spawn(async move |cx| { - let (id, edits, old_snapshot, inputs) = result.await?; - anyhow::Ok(Some( - EditPredictionResult::new( - EditPredictionId(id.into()), - &buffer, - &old_snapshot, - edits.into(), - None, - inputs, - None, - cx.background_executor().now() - request_start, - cx, - ) - .await, - )) - }) - } -} - -fn send_started_event( - debug_tx: &Option>, - buffer: &Entity, - position: Anchor, - prompt: String, -) { - if let Some(debug_tx) = debug_tx { - _ = debug_tx.unbounded_send(DebugEvent::EditPredictionStarted( - EditPredictionStartedDebugEvent { - buffer: buffer.downgrade(), - position, - prompt: Some(prompt), - }, - )); - } -} - -fn send_finished_event( - debug_tx: &Option>, - buffer: &Entity, - position: Anchor, - model_output: String, -) { - if let Some(debug_tx) = debug_tx { - _ = debug_tx.unbounded_send(DebugEvent::EditPredictionFinished( - EditPredictionFinishedDebugEvent { - buffer: buffer.downgrade(), - position, - model_output: Some(model_output), - }, - )); - } -} - -pub const SWEEP_CREDENTIALS_URL: SharedString = - SharedString::new_static("https://autocomplete.sweep.dev"); -pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token"; -pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("SWEEP_AI_TOKEN"); - -struct GlobalSweepApiKey(Entity); - -impl Global for GlobalSweepApiKey {} - -pub fn sweep_api_token(cx: &mut App) -> Entity { - if let Some(global) = cx.try_global::() { - return global.0.clone(); - } - let entity = - cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone())); - cx.set_global(GlobalSweepApiKey(entity.clone())); - entity -} - -pub fn load_sweep_api_token(cx: &mut App) -> Task> { - sweep_api_token(cx).update(cx, |key_state, cx| { - key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx) - }) -} - -#[derive(Debug, Clone, Serialize)] -struct AutocompleteRequest { - pub debug_info: Arc, - pub repo_name: String, - pub branch: Option, - pub file_path: Arc, - pub file_contents: String, - pub recent_changes: String, - pub cursor_position: usize, - pub original_file_contents: String, - pub file_chunks: Vec, - pub retrieval_chunks: Vec, - pub recent_user_actions: Vec, - pub multiple_suggestions: bool, - pub privacy_mode_enabled: bool, - pub changes_above_cursor: bool, - pub use_bytes: bool, -} - -#[derive(Debug, Clone, Serialize)] -struct FileChunk { - pub file_path: String, - pub start_line: usize, - pub end_line: usize, - pub content: String, - pub timestamp: Option, -} - -#[derive(Debug, Clone, Serialize)] -struct UserAction { - pub action_type: ActionType, - pub line_number: usize, - pub offset: usize, - pub file_path: String, - pub timestamp: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -enum ActionType { - CursorMovement, - InsertChar, - DeleteChar, - InsertSelection, - DeleteSelection, -} - -fn to_sweep_user_action(record: &UserActionRecord, file_path: &str) -> UserAction { - UserAction { - action_type: match record.action_type { - UserActionType::InsertChar => ActionType::InsertChar, - UserActionType::InsertSelection => ActionType::InsertSelection, - UserActionType::DeleteChar => ActionType::DeleteChar, - UserActionType::DeleteSelection => ActionType::DeleteSelection, - UserActionType::CursorMovement => ActionType::CursorMovement, - }, - line_number: record.line_number as usize, - offset: record.offset, - file_path: file_path.to_string(), - timestamp: record.timestamp_epoch_ms, - } -} - -#[derive(Debug, Clone, Deserialize)] -struct AutocompleteResponse { - pub autocomplete_id: String, - pub start_index: usize, - pub end_index: usize, - pub completion: String, - #[allow(dead_code)] - pub confidence: f64, - #[allow(dead_code)] - pub logprobs: Option, - #[allow(dead_code)] - pub finish_reason: Option, - #[allow(dead_code)] - pub elapsed_time_ms: u64, - #[allow(dead_code)] - #[serde(default, rename = "completions")] - pub additional_completions: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] -struct AdditionalCompletion { - pub start_index: usize, - pub end_index: usize, - pub completion: String, - pub confidence: f64, - pub autocomplete_id: String, - pub logprobs: Option, - pub finish_reason: Option, -} - -fn write_event(event: &zeta_prompt::Event, f: &mut impl fmt::Write) -> fmt::Result { - match event { - zeta_prompt::Event::BufferChange { - old_path, - path, - diff, - .. - } => { - if old_path != path { - // TODO confirm how to do this for sweep - // writeln!(f, "User renamed {:?} to {:?}\n", old_path, new_path)?; - } - - if !diff.is_empty() { - write!(f, "File: {}:\n{}\n", path.display(), diff)? - } - - fmt::Result::Ok(()) - } - } -} - -fn debug_info(cx: &gpui::App) -> Arc { - format!( - "Zed v{version} ({sha}) - OS: {os} - Zed v{version}", - version = release_channel::AppVersion::global(cx), - sha = release_channel::AppCommitSha::try_global(cx) - .map_or("unknown".to_string(), |sha| sha.full()), - os = client::telemetry::os_name(), - ) - .into() -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum SweepEventType { - AutocompleteSuggestionShown, - AutocompleteSuggestionAccepted, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub enum SweepSuggestionType { - GhostText, - Popup, - JumpToEdit, -} - -#[derive(Debug, Clone, Serialize)] -struct AutocompleteMetricsRequest { - event_type: SweepEventType, - suggestion_type: SweepSuggestionType, - additions: u32, - deletions: u32, - autocomplete_id: String, - edit_tracking: String, - edit_tracking_line: Option, - lifespan: Option, - debug_info: Arc, - device_id: String, - privacy_mode_enabled: bool, -} - -fn send_autocomplete_metrics_request( - cx: &App, - client: Arc, - api_token: Arc, - request_body: AutocompleteMetricsRequest, -) { - let http_client = client.http_client(); - cx.background_spawn(async move { - let body: AsyncBody = serde_json::to_string(&request_body)?.into(); - - let request = http_client::Request::builder() - .uri(SWEEP_METRICS_URL) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", api_token)) - .method(Method::POST) - .body(body)?; - - let mut response = http_client.send(request).await?; - - if !response.status().is_success() { - let mut body = String::new(); - response.body_mut().read_to_string(&mut body).await?; - anyhow::bail!( - "Failed to send autocomplete metrics for sweep_ai: {:?}\nBody: {}", - response.status(), - body, - ); - } - - Ok(()) - }) - .detach_and_log_err(cx); -} - -pub(crate) fn edit_prediction_accepted( - store: &EditPredictionStore, - current_prediction: CurrentEditPrediction, - cx: &App, -) { - let Some(api_token) = store - .sweep_ai - .api_token - .read(cx) - .key(&SWEEP_CREDENTIALS_URL) - else { - return; - }; - let debug_info = store.sweep_ai.debug_info.clone(); - - let prediction = current_prediction.prediction; - - let (additions, deletions) = compute_edit_metrics(&prediction.edits, &prediction.snapshot); - let autocomplete_id = prediction.id.to_string(); - - let device_id = store - .client - .user_id() - .as_ref() - .map(ToString::to_string) - .unwrap_or_default(); - - let suggestion_type = match current_prediction.shown_with { - Some(SuggestionDisplayType::DiffPopover) => SweepSuggestionType::Popup, - Some(SuggestionDisplayType::Jump) => return, // should'nt happen - Some(SuggestionDisplayType::GhostText) | None => SweepSuggestionType::GhostText, - }; - - let request_body = AutocompleteMetricsRequest { - event_type: SweepEventType::AutocompleteSuggestionAccepted, - suggestion_type, - additions, - deletions, - autocomplete_id, - edit_tracking: String::new(), - edit_tracking_line: None, - lifespan: None, - debug_info, - device_id, - privacy_mode_enabled: false, - }; - - send_autocomplete_metrics_request(cx, store.client.clone(), api_token, request_body); -} - -pub fn edit_prediction_shown( - sweep_ai: &SweepAi, - client: Arc, - prediction: &EditPrediction, - display_type: SuggestionDisplayType, - cx: &App, -) { - let Some(api_token) = sweep_ai.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { - return; - }; - let debug_info = sweep_ai.debug_info.clone(); - - let (additions, deletions) = compute_edit_metrics(&prediction.edits, &prediction.snapshot); - let autocomplete_id = prediction.id.to_string(); - - let suggestion_type = match display_type { - SuggestionDisplayType::GhostText => SweepSuggestionType::GhostText, - SuggestionDisplayType::DiffPopover => SweepSuggestionType::Popup, - SuggestionDisplayType::Jump => SweepSuggestionType::JumpToEdit, - }; - - let request_body = AutocompleteMetricsRequest { - event_type: SweepEventType::AutocompleteSuggestionShown, - suggestion_type, - additions, - deletions, - autocomplete_id, - edit_tracking: String::new(), - edit_tracking_line: None, - lifespan: None, - debug_info, - device_id: String::new(), - privacy_mode_enabled: false, - }; - - send_autocomplete_metrics_request(cx, client, api_token, request_body); -} - -fn compute_edit_metrics( - edits: &[(Range, Arc)], - snapshot: &BufferSnapshot, -) -> (u32, u32) { - let mut additions = 0u32; - let mut deletions = 0u32; - - for (range, new_text) in edits { - let old_text = snapshot.text_for_range(range.clone()); - deletions += old_text - .map(|chunk| chunk.lines().count()) - .sum::() - .max(1) as u32; - additions += new_text.lines().count().max(1) as u32; - } - - (additions, deletions) -} diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs index b5ae954e84ca84505a47761235be71655477a9f7..c5e97fd87eaad9b98aeb9b946a9a69b1c1071db2 100644 --- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -10,7 +10,7 @@ use gpui::{App, Entity, prelude::*}; use language::{Buffer, ToPoint as _}; use project::Project; -use crate::{BufferEditPrediction, EditPredictionModel, EditPredictionStore}; +use crate::{BufferEditPrediction, EditPredictionStore}; pub struct ZedEditPredictionDelegate { store: Entity, @@ -103,14 +103,9 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { &self, _buffer: &Entity, _cursor_position: language::Anchor, - cx: &App, + _cx: &App, ) -> bool { - let store = self.store.read(cx); - if store.edit_prediction_model == EditPredictionModel::Sweep { - store.has_sweep_api_token(cx) - } else { - true - } + true } fn is_refreshing(&self, cx: &App) -> bool { diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 06fdbadbf53ce0f9f84b909081691c0097c4c5a4..cf9232a04a40df507c187d53becfedcd8db03188 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -358,7 +358,6 @@ impl TeacherBackend { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum PredictionProvider { - Sweep, Mercury, Zeta1, Zeta2(ZetaFormat), @@ -379,7 +378,6 @@ impl Default for PredictionProvider { impl std::fmt::Display for PredictionProvider { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - PredictionProvider::Sweep => write!(f, "sweep"), PredictionProvider::Mercury => write!(f, "mercury"), PredictionProvider::Zeta1 => write!(f, "zeta1"), PredictionProvider::Zeta2(format) => write!(f, "zeta2:{format}"), @@ -407,7 +405,6 @@ impl std::str::FromStr for PredictionProvider { let provider_lower = provider.to_lowercase(); match provider_lower.as_str() { - "sweep" => Ok(PredictionProvider::Sweep), "mercury" => Ok(PredictionProvider::Mercury), "zeta1" => Ok(PredictionProvider::Zeta1), "zeta2" => { @@ -452,7 +449,7 @@ impl std::str::FromStr for PredictionProvider { } _ => { anyhow::bail!( - "unknown provider `{provider}`. Valid options: sweep, mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-multi-region, teacher-multi-region:, teacher-non-batching, teacher-multi-region-non-batching, repair\n\ + "unknown provider `{provider}`. Valid options: mercury, zeta1, zeta2, zeta2:, teacher, teacher:, teacher-multi-region, teacher-multi-region:, teacher-non-batching, teacher-multi-region-non-batching, repair\n\ For zeta2, you can optionally specify a version like `zeta2:ordered` or `zeta2:V0113_Ordered`.\n\ For teacher providers, you can specify a backend like `teacher:sonnet46`, `teacher-multi-region:sonnet46`, `teacher-multi-region-non-batching:sonnet46`, or `teacher:gpt52`.\n\ Available zeta versions:\n{}", diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 1effca9d21a297d28ebf1eab738beead9f1af837..f2a55455b36326b58daa0adada7ec39124ffc317 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -137,7 +137,6 @@ pub async fn run_prediction( let model = match provider { PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta, PredictionProvider::Zeta2(_) => edit_prediction::EditPredictionModel::Zeta, - PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, PredictionProvider::Teacher(..) | PredictionProvider::TeacherMultiRegion(..) diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index d85ccded26058331c787f89ad74721d9572db623..e6e65012123c0fdf3571115bded43f8840f997ee 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -325,7 +325,6 @@ impl Render for EditPredictionButton { } provider @ (EditPredictionProvider::Experimental(_) | EditPredictionProvider::Zed - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury) => { let enabled = self.editor_enabled.unwrap_or(true); let file = self.file.clone(); @@ -349,16 +348,6 @@ impl Render for EditPredictionButton { let mut missing_token = false; match provider { - EditPredictionProvider::Sweep => { - missing_token = edit_prediction::EditPredictionStore::try_global(cx) - .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx)); - ep_icon = if enabled { icons.base } else { icons.disabled }; - tooltip_meta = if missing_token { - "Missing API key for Sweep" - } else { - "Powered by Sweep" - }; - } EditPredictionProvider::Mercury => { ep_icon = if enabled { icons.base } else { icons.disabled }; let mercury_has_error = @@ -548,17 +537,12 @@ impl EditPredictionButton { .detach(); edit_prediction::ollama::ensure_authenticated(cx); - let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx); let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx); let open_ai_compatible_api_token_task = edit_prediction::open_ai_compatible::load_open_ai_compatible_api_token(cx); cx.spawn(async move |this, cx| { - _ = futures::join!( - sweep_api_token_task, - mercury_api_token_task, - open_ai_compatible_api_token_task - ); + _ = futures::join!(mercury_api_token_task, open_ai_compatible_api_token_task); this.update(cx, |_, cx| { cx.notify(); }) @@ -1457,13 +1441,6 @@ pub fn get_available_providers(cx: &mut App) -> Vec { providers.push(EditPredictionProvider::OpenAiCompatibleApi); } - if edit_prediction::sweep_ai::sweep_api_token(cx) - .read(cx) - .has_key() - { - providers.push(EditPredictionProvider::Sweep); - } - if edit_prediction::mercury::mercury_api_token(cx) .read(cx) .has_key() diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 625b51f9c1b9f5cbf04f4474b72d08557542352f..c8bf3b4e7708650a030218c91bb71bfd6a398635 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -233,11 +233,6 @@ pub enum IconName { Star, StarFilled, Stop, - SweepAi, - SweepAiDisabled, - SweepAiDown, - SweepAiError, - SweepAiUp, Tab, Terminal, TerminalAlt, diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 5f74247ab04758e18dd38edc54929f85403d9e97..1e7910fa54f91938ea0d8e34a7818384c3b81a0e 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -469,8 +469,6 @@ pub struct EditPredictionSettings { pub copilot: CopilotSettings, /// Settings specific to Codestral. pub codestral: CodestralSettings, - /// Settings specific to Sweep. - pub sweep: SweepSettings, /// Settings specific to Ollama. pub ollama: Option, pub open_ai_compatible_api: Option, @@ -522,15 +520,6 @@ pub struct CodestralSettings { pub api_url: Option, } -#[derive(Clone, Debug, Default)] -pub struct SweepSettings { - /// When enabled, Sweep will not store edit prediction inputs or outputs. - /// When disabled, Sweep may collect data including buffer contents, - /// diagnostics, file paths, repository names, and generated predictions - /// to improve the service. - pub privacy_mode: bool, -} - #[derive(Clone, Debug, Default)] pub struct OpenAiCompatibleEditPredictionSettings { /// Model to use for completions. @@ -805,10 +794,6 @@ impl settings::Settings for AllLanguageSettings { api_url: codestral.api_url, }; - let sweep = edit_predictions.sweep.unwrap(); - let sweep_settings = SweepSettings { - privacy_mode: sweep.privacy_mode.unwrap(), - }; let ollama = edit_predictions.ollama.unwrap(); let ollama_settings = ollama .model @@ -872,7 +857,6 @@ impl settings::Settings for AllLanguageSettings { mode: edit_predictions_mode, copilot: copilot_settings, codestral: codestral_settings, - sweep: sweep_settings, ollama: ollama_settings, open_ai_compatible_api: openai_compatible_settings, enabled_in_text_threads, diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index 30a1e7a3179988071784e94a8a9b8b60b13df468..4578d2eb589313e57688d7c604beb4eced83de29 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -85,7 +85,6 @@ pub enum EditPredictionProvider { Codestral, Ollama, OpenAiCompatibleApi, - Sweep, Mercury, Experimental(&'static str), } @@ -106,7 +105,6 @@ impl<'de> Deserialize<'de> for EditPredictionProvider { Codestral, Ollama, OpenAiCompatibleApi, - Sweep, Mercury, Experimental(String), } @@ -118,7 +116,6 @@ impl<'de> Deserialize<'de> for EditPredictionProvider { Content::Codestral => EditPredictionProvider::Codestral, Content::Ollama => EditPredictionProvider::Ollama, Content::OpenAiCompatibleApi => EditPredictionProvider::OpenAiCompatibleApi, - Content::Sweep => EditPredictionProvider::Sweep, Content::Mercury => EditPredictionProvider::Mercury, Content::Experimental(name) if name == EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME => @@ -144,7 +141,6 @@ impl EditPredictionProvider { | EditPredictionProvider::Codestral | EditPredictionProvider::Ollama | EditPredictionProvider::OpenAiCompatibleApi - | EditPredictionProvider::Sweep | EditPredictionProvider::Mercury | EditPredictionProvider::Experimental(_) => false, } @@ -155,7 +151,6 @@ impl EditPredictionProvider { EditPredictionProvider::Zed => Some("Zed AI"), EditPredictionProvider::Copilot => Some("GitHub Copilot"), EditPredictionProvider::Codestral => Some("Codestral"), - EditPredictionProvider::Sweep => Some("Sweep"), EditPredictionProvider::Mercury => Some("Mercury"), EditPredictionProvider::Experimental(_) | EditPredictionProvider::None => None, EditPredictionProvider::Ollama => Some("Ollama"), @@ -181,8 +176,6 @@ pub struct EditPredictionSettingsContent { pub copilot: Option, /// Settings specific to Codestral. pub codestral: Option, - /// Settings specific to Sweep. - pub sweep: Option, /// Settings specific to Ollama. pub ollama: Option, /// Settings specific to using custom OpenAI-compatible servers for edit prediction. @@ -209,8 +202,7 @@ pub struct CustomEditPredictionProviderSettingsContent { /// /// Default: "" pub model: Option, - /// Maximum tokens to generate for FIM models. - /// This setting does not apply to sweep models. + /// Maximum tokens to generate. /// /// Default: 256 pub max_output_tokens: Option, @@ -283,18 +275,6 @@ pub struct CodestralSettingsContent { pub api_url: Option, } -#[with_fallible_options] -#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] -pub struct SweepSettingsContent { - /// When enabled, Sweep will not store edit prediction inputs or outputs. - /// When disabled, Sweep may collect data including buffer contents, - /// diagnostics, file paths, repository names, and generated predictions - /// to improve the service. - /// - /// Default: false - pub privacy_mode: Option, -} - /// Ollama model name for edit predictions. #[with_fallible_options] #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] @@ -327,7 +307,6 @@ pub struct OllamaEditPredictionSettingsContent { /// Default: none pub model: Option, /// Maximum tokens to generate for FIM models. - /// This setting does not apply to sweep models. /// /// Default: 256 pub max_output_tokens: Option, diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs index 736a8e83e34339b3aab18d865938a49f31ba7783..0357f2040b0125d39d34fd36b1aca3d299a8501b 100644 --- a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -3,7 +3,6 @@ use edit_prediction::{ ApiKeyState, mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token}, open_ai_compatible::{open_ai_compatible_api_token, open_ai_compatible_api_url}, - sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token}, }; use edit_prediction_ui::{get_available_providers, set_completion_provider}; use gpui::{Entity, ScrollHandle, prelude::*}; @@ -45,30 +44,6 @@ pub(crate) fn render_edit_prediction_setup_page( ) .into_any_element(), ), - Some( - render_api_key_provider( - IconName::SweepAi, - "Sweep", - ApiKeyDocs::Link { - dashboard_url: "https://app.sweep.dev/".into(), - }, - sweep_api_token(cx), - |_cx| SWEEP_CREDENTIALS_URL, - Some( - settings_window - .render_sub_page_items_section( - sweep_settings().iter().enumerate(), - true, - window, - cx, - ) - .into_any_element(), - ), - window, - cx, - ) - .into_any_element(), - ), Some( render_api_key_provider( IconName::AiMistral, @@ -345,39 +320,6 @@ fn render_api_key_provider( }) } -fn sweep_settings() -> Box<[SettingsPageItem]> { - Box::new([SettingsPageItem::SettingItem(SettingItem { - title: "Privacy Mode", - description: "When enabled, Sweep will not store edit prediction inputs or outputs. When disabled, Sweep may collect data including buffer contents, diagnostics, file paths, and generated predictions to improve the service.", - field: Box::new(SettingField { - pick: |settings| { - settings - .project - .all_languages - .edit_predictions - .as_ref()? - .sweep - .as_ref()? - .privacy_mode - .as_ref() - }, - write: |settings, value| { - settings - .project - .all_languages - .edit_predictions - .get_or_insert_default() - .sweep - .get_or_insert_default() - .privacy_mode = value; - }, - json_path: Some("edit_predictions.sweep.privacy_mode"), - }), - metadata: None, - files: USER, - })]) -} - fn render_ollama_provider( settings_window: &SettingsWindow, window: &mut Window, diff --git a/crates/zed/src/zed/edit_prediction_registry.rs b/crates/zed/src/zed/edit_prediction_registry.rs index 952c840d4abe0cb99be170e27f66a2ba188c08ca..8c9e74a42e6c3ddb2b340ac58da39752009825f0 100644 --- a/crates/zed/src/zed/edit_prediction_registry.rs +++ b/crates/zed/src/zed/edit_prediction_registry.rs @@ -141,9 +141,7 @@ fn edit_prediction_provider_config_for_settings(cx: &App) -> Option Some(EditPredictionProviderConfig::Zed( - EditPredictionModel::Sweep, - )), + EditPredictionProvider::Mercury => Some(EditPredictionProviderConfig::Zed( EditPredictionModel::Mercury, )), @@ -183,7 +181,6 @@ impl EditPredictionProviderConfig { EditPredictionProviderConfig::Zed(model) => match model { EditPredictionModel::Zeta => "Zeta", EditPredictionModel::Fim { .. } => "FIM", - EditPredictionModel::Sweep => "Sweep", EditPredictionModel::Mercury => "Mercury", }, } diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 92fde3eddd3be0a2dbfb1b6d37065b58cf2ad411..5c0bae7f93a65ea919e820097bb15fc2ce2267b6 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -1,6 +1,6 @@ --- -title: AI Code Completion in Zed - Zeta, Copilot, Sweep, Mercury Coder -description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copilot, Sweep, Codestral, or Mercury Coder. Multi-line predictions on every keystroke. +title: AI Code Completion in Zed - Zeta, Copilot, Codestral, Mercury Coder +description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copilot, Codestral, or Mercury Coder. Multi-line predictions on every keystroke. --- # Edit Prediction @@ -8,7 +8,7 @@ description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copi Edit Prediction is how Zed's AI code completions work: an LLM predicts the code you want to write. Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions that can be quickly accepted by pressing `tab`. -The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Sweep, Mercury Coder, and Codestral. +The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Mercury Coder, and Codestral. ## Configuring Zeta @@ -338,28 +338,6 @@ Copilot can provide multiple completion alternatives, and these can be navigated - {#action editor::NextEditPrediction} ({#kb editor::NextEditPrediction}): To cycle to the next edit prediction - {#action editor::PreviousEditPrediction} ({#kb editor::PreviousEditPrediction}): To cycle to the previous edit prediction -### Sweep {#sweep} - -To use [Sweep](https://sweep.dev/) as your provider: - -1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) -2. Search for "Edit Predictions" and click **Configure Providers** -3. Find the Sweep section and enter your API key from the - [Sweep dashboard](https://app.sweep.dev/) - -Alternatively, click the edit prediction icon in the status bar and select -**Configure Providers** from the menu. - -After adding your API key, Sweep will appear in the provider dropdown in the status bar menu, where you can select it. You can also set it directly in your settings file: - -```json [settings] -{ - "edit_predictions": { - "provider": "sweep" - } -} -``` - ### Mercury Coder {#mercury-coder} To use [Mercury Coder](https://www.inceptionlabs.ai/) by Inception Labs as your provider: From a48bcf2e7a2c8028bef6b3df8b9464f1e975535e Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 25 Mar 2026 11:21:01 -0400 Subject: [PATCH 02/45] Bump Zed to v0.231 (#52420) Release Notes: - N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37cb42f56bb2ea465d1d17fa1f1f6a8e79600750..ae05451fb1efde4fcb7a4404d6ff2f0526bd1466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21932,7 +21932,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.230.0" +version = "0.231.0" dependencies = [ "acp_thread", "acp_tools", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index a509bf67920d20ad7004ac2a14f3ee8dff5aa4e2..4529fe35ccf1866a21539eeafa09aafaa3239cbf 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.230.0" +version = "0.231.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 0512b72106f7d996cac24d8a4215c7a986f1063e Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 25 Mar 2026 08:33:56 -0700 Subject: [PATCH 03/45] Fix flaky terminal kill test (#52370) ## Context This test was bugging me, so I had claude take a look at it. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 3b143efbfdbd9c1ef636f76db795cd2f5b3b43e7..2195f9e73ef085bdbaf91aaec6e42383f533ee8f 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -3177,9 +3177,27 @@ mod tests { ); }); - // Wait for the printf command to execute and produce output - // Use real time since parking is enabled - cx.executor().timer(Duration::from_millis(500)).await; + // Poll until the printf command produces output, rather than using a + // fixed sleep which is flaky on loaded machines. + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let has_output = thread.read_with(cx, |thread, cx| { + let term = thread + .terminals + .get(&terminal_id) + .expect("terminal not found"); + let content = term.read(cx).inner().read(cx).get_content(); + content.contains("output_before_kill") + }); + if has_output { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Timed out waiting for printf output to appear in terminal", + ); + cx.executor().timer(Duration::from_millis(50)).await; + } // Get the acp_thread Terminal and kill it let wait_for_exit = thread.update(cx, |thread, cx| { From 4ad582a47f66058a4d72989755376e2463fb79e4 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Wed, 25 Mar 2026 23:44:02 +0800 Subject: [PATCH 04/45] acp_tools: Only toggle ACP log expansion from header clicks (#50981) Release Notes: - Added only toggle ACP log expansion from header clicks This change limits expand/collapse in open acp logs to header clicks only, instead of the entire message row. It prevents accidental expand/zoom toggles when users click or drag inside log text, making it easier to select and copy debug information. before: https://github.com/user-attachments/assets/ceea7a6c-9b6a-4a38-8926-3c43f2bfb74e after: https://github.com/user-attachments/assets/f07537b1-70f1-4537-b38a-4148a84b41c7 Signed-off-by: Xiaobo Liu --- crates/acp_tools/src/acp_tools.rs | 35 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 30d13effcb53395972879ef109a253be0c134ec1..78c873c3a1a12c1f24a2c64e96ce1d1801bc4eb9 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -291,7 +291,6 @@ impl AcpTools { v_flex() .id(index) .group("message") - .cursor_pointer() .font_buffer(cx) .w_full() .py_3() @@ -303,27 +302,29 @@ impl AcpTools { .border_color(colors.border) .border_b_1() .hover(|this| this.bg(colors.element_background.opacity(0.5))) - .on_click(cx.listener(move |this, _, _, cx| { - if this.expanded.contains(&index) { - this.expanded.remove(&index); - } else { - this.expanded.insert(index); - let Some(connection) = &mut this.watched_connection else { - return; - }; - let Some(message) = connection.messages.get_mut(index) else { - return; - }; - message.expanded(this.project.read(cx).languages().clone(), cx); - connection.list_state.scroll_to_reveal_item(index); - } - cx.notify() - })) .child( h_flex() + .id(("acp-log-message-header", index)) .w_full() .gap_2() .flex_shrink_0() + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + if this.expanded.contains(&index) { + this.expanded.remove(&index); + } else { + this.expanded.insert(index); + let Some(connection) = &mut this.watched_connection else { + return; + }; + let Some(message) = connection.messages.get_mut(index) else { + return; + }; + message.expanded(this.project.read(cx).languages().clone(), cx); + connection.list_state.scroll_to_reveal_item(index); + } + cx.notify() + })) .child(match message.direction { acp::StreamMessageDirection::Incoming => Icon::new(IconName::ArrowDown) .color(Color::Error) From 6aaf08de09eff5785fdc08984337e17821322016 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 25 Mar 2026 17:54:53 +0200 Subject: [PATCH 05/45] ep: Change beta in deltaChrF to favor precision over recall (#52422) Also store and print more detailed information related to this metric (precision, recall, tp/fp/fn counts) Release Notes: - N/A --- crates/edit_prediction_cli/src/example.rs | 31 +++++ crates/edit_prediction_cli/src/metrics.rs | 157 ++++++++++++++++------ crates/edit_prediction_cli/src/score.rs | 81 ++++++++--- 3 files changed, 214 insertions(+), 55 deletions(-) diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs index 196f4f96d99b64aed2ff3ae2d7a9897295a60b29..4827337d37a211056d04cf9ca13f8d49fb91c392 100644 --- a/crates/edit_prediction_cli/src/example.rs +++ b/crates/edit_prediction_cli/src/example.rs @@ -1,4 +1,5 @@ use crate::PredictionProvider; +use crate::metrics::ClassificationMetrics; use crate::paths::WORKTREES_DIR; use crate::qa::QaResult; use anyhow::{Context as _, Result}; @@ -150,6 +151,18 @@ where #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ExampleScore { pub delta_chr_f: f32, + #[serde(default)] + pub delta_chr_f_true_positives: usize, + #[serde(default)] + pub delta_chr_f_false_positives: usize, + #[serde(default)] + pub delta_chr_f_false_negatives: usize, + #[serde(default)] + pub delta_chr_f_precision: f64, + #[serde(default)] + pub delta_chr_f_recall: f64, + #[serde(default)] + pub delta_chr_f_beta: f64, pub braces_disbalance: usize, #[serde(default)] pub exact_lines_tp: usize, @@ -176,6 +189,24 @@ pub struct ExampleScore { pub avg_logprob: Option, } +impl ExampleScore { + pub fn delta_chr_f_counts(&self) -> ClassificationMetrics { + ClassificationMetrics { + true_positives: self.delta_chr_f_true_positives, + false_positives: self.delta_chr_f_false_positives, + false_negatives: self.delta_chr_f_false_negatives, + } + } + + pub fn exact_lines_counts(&self) -> ClassificationMetrics { + ClassificationMetrics { + true_positives: self.exact_lines_tp, + false_positives: self.exact_lines_fp, + false_negatives: self.exact_lines_fn, + } + } +} + impl Example { pub fn repo_name(&self) -> Result> { // git@github.com:owner/repo.git diff --git a/crates/edit_prediction_cli/src/metrics.rs b/crates/edit_prediction_cli/src/metrics.rs index 1bfd8e542fa3d74b55f091d2ac13aa22883f6a2f..8037699f4bb6f851fdadb05b435b090b911b010a 100644 --- a/crates/edit_prediction_cli/src/metrics.rs +++ b/crates/edit_prediction_cli/src/metrics.rs @@ -48,6 +48,12 @@ impl ClassificationMetrics { } } + pub fn accumulate(&mut self, other: &ClassificationMetrics) { + self.true_positives += other.true_positives; + self.false_positives += other.false_positives; + self.false_negatives += other.false_negatives; + } + pub fn precision(&self) -> f64 { if self.true_positives + self.false_positives == 0 { 0.0 @@ -89,10 +95,23 @@ enum ChrfWhitespace { } const CHR_F_CHAR_ORDER: usize = 6; -const CHR_F_BETA: f64 = 2.0; +const CHR_F_BETA: f64 = 0.5; const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Collapse; -/// Computes a delta-chrF score that compares two sets of edits. +pub fn delta_chr_f_beta() -> f64 { + CHR_F_BETA +} + +#[derive(Default, Debug, Clone)] +pub struct DeltaChrFMetrics { + pub score: f64, + pub beta: f64, + pub counts: ClassificationMetrics, + pub precision: f64, + pub recall: f64, +} + +/// Computes delta-chrF metrics that compare two sets of edits. /// /// This metric works by: /// 1. Computing n-gram count differences (deltas) between original→expected and original→actual @@ -100,13 +119,17 @@ const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Collapse; /// /// Returns a score from 0.0 to 100.0, where 100.0 means the actual edits perfectly match /// the expected edits. -pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 { - // Edge case: if all texts are identical, the edits match perfectly +pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> DeltaChrFMetrics { if original == expected && expected == actual { - return 100.0; + return DeltaChrFMetrics { + score: 100.0, + beta: CHR_F_BETA, + precision: 1.0, + recall: 1.0, + ..DeltaChrFMetrics::default() + }; } - // Pre-filter whitespace once for all texts let orig_chars: Vec = filter_whitespace_chars(original); let exp_chars: Vec = filter_whitespace_chars(expected); let act_chars: Vec = filter_whitespace_chars(actual); @@ -118,9 +141,9 @@ pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 { let mut total_precision = 0.0; let mut total_recall = 0.0; + let mut total_counts = ClassificationMetrics::default(); for order in 1..=CHR_F_CHAR_ORDER { - // Compute n-grams only on the affected regions let orig_ngrams_for_exp = count_ngrams_from_chars(&orig_for_exp, order); let exp_ngrams = count_ngrams_from_chars(&exp_region, order); let expected_delta = compute_ngram_delta(&exp_ngrams, &orig_ngrams_for_exp); @@ -138,28 +161,43 @@ pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 { let expected_counts = ngram_delta_to_counts(&expected_delta); let actual_counts = ngram_delta_to_counts(&actual_delta); - let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); - total_precision += score.precision(); - total_recall += score.recall(); + let counts = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); + total_precision += counts.precision(); + total_recall += counts.recall(); + total_counts.accumulate(&counts); } - let prec = total_precision / CHR_F_CHAR_ORDER as f64; - let recall = total_recall / CHR_F_CHAR_ORDER as f64; - let f_score = if prec + recall == 0.0 { + let average_precision = total_precision / CHR_F_CHAR_ORDER as f64; + let average_recall = total_recall / CHR_F_CHAR_ORDER as f64; + let score = if average_precision + average_recall == 0.0 { 0.0 } else { - (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall) + (1.0 + CHR_F_BETA * CHR_F_BETA) * average_precision * average_recall + / (CHR_F_BETA * CHR_F_BETA * average_precision + average_recall) + * 100.0 }; - f_score * 100.0 + DeltaChrFMetrics { + score, + beta: CHR_F_BETA, + counts: total_counts, + precision: average_precision, + recall: average_recall, + } } -/// Reference implementation of delta_chr_f (original, non-optimized version). +/// Reference implementation of delta-chrF metrics (original, non-optimized version). /// Used for testing that the optimized version produces identical results. #[cfg(test)] -fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 { +fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> DeltaChrFMetrics { if original == expected && expected == actual { - return 100.0; + return DeltaChrFMetrics { + score: 100.0, + beta: CHR_F_BETA, + precision: 1.0, + recall: 1.0, + ..DeltaChrFMetrics::default() + }; } let original_ngrams = chr_f_ngram_counts(original); @@ -168,6 +206,7 @@ fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 { let mut total_precision = 0.0; let mut total_recall = 0.0; + let mut total_counts = ClassificationMetrics::default(); for order in 0..CHR_F_CHAR_ORDER { let expected_delta = compute_ngram_delta(&expected_ngrams[order], &original_ngrams[order]); @@ -182,20 +221,29 @@ fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 { let expected_counts = ngram_delta_to_counts(&expected_delta); let actual_counts = ngram_delta_to_counts(&actual_delta); - let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); - total_precision += score.precision(); - total_recall += score.recall(); + let counts = ClassificationMetrics::from_counts(&expected_counts, &actual_counts); + total_precision += counts.precision(); + total_recall += counts.recall(); + total_counts.accumulate(&counts); } - let prec = total_precision / CHR_F_CHAR_ORDER as f64; - let recall = total_recall / CHR_F_CHAR_ORDER as f64; - let f_score = if prec + recall == 0.0 { + let average_precision = total_precision / CHR_F_CHAR_ORDER as f64; + let average_recall = total_recall / CHR_F_CHAR_ORDER as f64; + let score = if average_precision + average_recall == 0.0 { 0.0 } else { - (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall) + (1.0 + CHR_F_BETA * CHR_F_BETA) * average_precision * average_recall + / (CHR_F_BETA * CHR_F_BETA * average_precision + average_recall) + * 100.0 }; - f_score * 100.0 + DeltaChrFMetrics { + score, + beta: CHR_F_BETA, + counts: total_counts, + precision: average_precision, + recall: average_recall, + } } /// Filter whitespace from a string and return as Vec @@ -664,7 +712,7 @@ mod test_optimization { ]; for (original, expected, actual) in test_cases { - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; // Just verify it produces a reasonable score (0-100) assert!( score >= 0.0 && score <= 100.0, @@ -733,20 +781,51 @@ mod test_optimization { ]; for (original, expected, actual) in test_cases { - let optimized_score = delta_chr_f(original, expected, actual); - let reference_score = delta_chr_f_reference(original, expected, actual); + let optimized_metrics = delta_chr_f(original, expected, actual); + let reference_metrics = delta_chr_f_reference(original, expected, actual); assert!( - (optimized_score - reference_score).abs() < 1e-10, - "Mismatch for ({:?}, {:?}, {:?}):\n optimized: {}\n reference: {}", + (optimized_metrics.score - reference_metrics.score).abs() < 1e-10, + "Score mismatch for ({:?}, {:?}, {:?}):\n optimized: {}\n reference: {}", original, expected, actual, - optimized_score, - reference_score + optimized_metrics.score, + reference_metrics.score + ); + assert_eq!( + optimized_metrics.counts.true_positives, + reference_metrics.counts.true_positives + ); + assert_eq!( + optimized_metrics.counts.false_positives, + reference_metrics.counts.false_positives ); + assert_eq!( + optimized_metrics.counts.false_negatives, + reference_metrics.counts.false_negatives + ); + assert!((optimized_metrics.precision - reference_metrics.precision).abs() < 1e-10); + assert!((optimized_metrics.recall - reference_metrics.recall).abs() < 1e-10); } } + + #[test] + fn test_delta_chr_f_metrics_include_counts_and_rates() { + let original = "one two three"; + let expected = "one three"; + let actual = "one two four"; + + let metrics = delta_chr_f(original, expected, actual); + + assert!(metrics.score > 20.0 && metrics.score < 40.0); + assert!(metrics.counts.true_positives > 0); + assert!(metrics.counts.false_positives > 0); + assert!(metrics.counts.false_negatives > 0); + assert!(metrics.precision > 0.0 && metrics.precision < 1.0); + assert!(metrics.recall > 0.0 && metrics.recall < 1.0); + assert_eq!(metrics.beta, CHR_F_BETA); + } } #[cfg(test)] @@ -770,7 +849,7 @@ mod test { let original = "fn main() { println!(\"Hello\");}"; let expected = "fn main() { println!(\"Hello, World!\");}"; - let score = delta_chr_f(original, expected, expected); + let score = delta_chr_f(original, expected, expected).score; assert!((score - 100.0).abs() < 1e-2); } @@ -782,7 +861,7 @@ mod test { let actual = "one two four"; // deleted "three", added "four" // Then the score should be low - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score > 20.0 && score < 40.0); } @@ -794,7 +873,7 @@ mod test { // We got the edit location right, but the replacement text is wrong. // Deleted ngrams will match, bringing the score somewhere in the middle. - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score > 40.0 && score < 60.0); } @@ -806,7 +885,7 @@ mod test { let actual = "prefix old suffix"; // no change // Then the score should be low (all expected changes are false negatives) - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score < 20.0); } @@ -818,14 +897,14 @@ mod test { let actual = "helloextraworld"; // added "extra" // Then the score should be low (all actual changes are false positives) - let score = delta_chr_f(original, expected, actual); + let score = delta_chr_f(original, expected, actual).score; assert!(score < 20.0); } #[test] fn test_delta_chr_f_no_changes() { let text = "unchanged text"; - let score = delta_chr_f(text, text, text); + let score = delta_chr_f(text, text, text).score; assert!((score - 100.0).abs() < 1e-2); } diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index d75cf55e85b198bc28469e83d8f9209a8a59a83f..be9b185809e6e0cd49e0befbeecec0f317339342 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -67,6 +67,12 @@ pub async fn run_scoring( let zero_scores = ExampleScore { delta_chr_f: 0.0, + delta_chr_f_true_positives: 0, + delta_chr_f_false_positives: 0, + delta_chr_f_false_negatives: 0, + delta_chr_f_precision: 0.0, + delta_chr_f_recall: 0.0, + delta_chr_f_beta: metrics::delta_chr_f_beta(), braces_disbalance: 0, exact_lines_tp: 0, exact_lines_fp: 0, @@ -111,14 +117,14 @@ pub async fn run_scoring( } }; - let mut best_delta_chr_f = 0.0f32; + let mut best_delta_chr_f_metrics = metrics::DeltaChrFMetrics::default(); let mut best_expected_cursor: Option = None; let mut best_patch_idx: Option = None; for (idx, expected) in expected_texts.iter().enumerate() { - let delta_chr_f = metrics::delta_chr_f(original_text, expected, &actual_text) as f32; - if delta_chr_f > best_delta_chr_f { - best_delta_chr_f = delta_chr_f; + let delta_chr_f_metrics = metrics::delta_chr_f(original_text, expected, &actual_text); + if delta_chr_f_metrics.score > best_delta_chr_f_metrics.score { + best_delta_chr_f_metrics = delta_chr_f_metrics; best_patch_idx = Some(idx); } } @@ -179,7 +185,13 @@ pub async fn run_scoring( ); scores.push(ExampleScore { - delta_chr_f: best_delta_chr_f, + delta_chr_f: best_delta_chr_f_metrics.score as f32, + delta_chr_f_true_positives: best_delta_chr_f_metrics.counts.true_positives, + delta_chr_f_false_positives: best_delta_chr_f_metrics.counts.false_positives, + delta_chr_f_false_negatives: best_delta_chr_f_metrics.counts.false_negatives, + delta_chr_f_precision: best_delta_chr_f_metrics.precision, + delta_chr_f_recall: best_delta_chr_f_metrics.recall, + delta_chr_f_beta: best_delta_chr_f_metrics.beta, braces_disbalance, exact_lines_tp: best_exact_lines.true_positives, exact_lines_fp: best_exact_lines.false_positives, @@ -238,6 +250,10 @@ pub fn print_report(examples: &[Example], verbose: bool) { let mut all_delta_chr_f_scores = Vec::new(); let mut all_reversal_ratios = Vec::new(); let mut braces_disbalance_sum: usize = 0; + let mut total_delta_chr_f = ClassificationMetrics::default(); + let mut total_delta_chr_f_precision = 0.0; + let mut total_delta_chr_f_recall = 0.0; + let mut delta_chr_f_beta = 0.0; let mut total_exact_lines = ClassificationMetrics::default(); let mut total_scores: usize = 0; let mut qa_reverts_count: usize = 0; @@ -260,11 +276,7 @@ pub fn print_report(examples: &[Example], verbose: bool) { for example in examples { for (score_idx, score) in example.score.iter().enumerate() { - let exact_lines = ClassificationMetrics { - true_positives: score.exact_lines_tp, - false_positives: score.exact_lines_fp, - false_negatives: score.exact_lines_fn, - }; + let exact_lines = score.exact_lines_counts(); // Get QA results for this prediction if available let qa_result = example.qa.get(score_idx).and_then(|q| q.as_ref()); @@ -314,9 +326,11 @@ pub fn print_report(examples: &[Example], verbose: bool) { all_reversal_ratios.push(score.reversal_ratio); total_scores += 1; braces_disbalance_sum += score.braces_disbalance; - total_exact_lines.true_positives += score.exact_lines_tp; - total_exact_lines.false_positives += score.exact_lines_fp; - total_exact_lines.false_negatives += score.exact_lines_fn; + total_delta_chr_f.accumulate(&score.delta_chr_f_counts()); + total_delta_chr_f_precision += score.delta_chr_f_precision; + total_delta_chr_f_recall += score.delta_chr_f_recall; + delta_chr_f_beta = score.delta_chr_f_beta; + total_exact_lines.accumulate(&score.exact_lines_counts()); // Accumulate QA metrics if let Some(qa) = qa_result { @@ -448,6 +462,15 @@ pub fn print_report(examples: &[Example], verbose: bool) { wrong_er_str ); println!("{}", separator); + println!( + "Delta chrF (β={:.1}): TP={}, FP={}, FN={}, P={:.1}%, R={:.1}%", + delta_chr_f_beta, + total_delta_chr_f.true_positives, + total_delta_chr_f.false_positives, + total_delta_chr_f.false_negatives, + total_delta_chr_f_precision / total_scores as f64 * 100.0, + total_delta_chr_f_recall / total_scores as f64 * 100.0 + ); // Print additional cursor metrics if available if let Some(avg_dist) = avg_cursor_distance { @@ -540,6 +563,12 @@ fn truncate_name(name: &str, max_len: usize) -> String { pub struct SummaryJson { pub total_examples: usize, pub avg_delta_chr_f: f32, + pub delta_chr_f_beta: f64, + pub delta_chr_f_true_positives: usize, + pub delta_chr_f_false_positives: usize, + pub delta_chr_f_false_negatives: usize, + pub delta_chr_f_precision: f64, + pub delta_chr_f_recall: f64, pub avg_braces_disbalance: f32, pub exact_lines_true_positives: usize, pub exact_lines_false_positives: usize, @@ -569,6 +598,10 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson { let mut all_delta_chr_f_scores = Vec::new(); let mut all_reversal_ratios = Vec::new(); let mut braces_disbalance_sum: usize = 0; + let mut total_delta_chr_f = ClassificationMetrics::default(); + let mut total_delta_chr_f_precision = 0.0; + let mut total_delta_chr_f_recall = 0.0; + let mut delta_chr_f_beta = 0.0; let mut total_exact_lines = ClassificationMetrics::default(); let mut total_scores: usize = 0; let mut qa_reverts_count: usize = 0; @@ -589,9 +622,11 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson { all_reversal_ratios.push(score.reversal_ratio); total_scores += 1; braces_disbalance_sum += score.braces_disbalance; - total_exact_lines.true_positives += score.exact_lines_tp; - total_exact_lines.false_positives += score.exact_lines_fp; - total_exact_lines.false_negatives += score.exact_lines_fn; + total_delta_chr_f.accumulate(&score.delta_chr_f_counts()); + total_delta_chr_f_precision += score.delta_chr_f_precision; + total_delta_chr_f_recall += score.delta_chr_f_recall; + delta_chr_f_beta = score.delta_chr_f_beta; + total_exact_lines.accumulate(&score.exact_lines_counts()); // Accumulate QA metrics if let Some(Some(qa)) = example.qa.get(score_idx) { @@ -697,6 +732,20 @@ pub fn compute_summary(examples: &[Example]) -> SummaryJson { SummaryJson { total_examples: total_scores, avg_delta_chr_f, + delta_chr_f_beta, + delta_chr_f_true_positives: total_delta_chr_f.true_positives, + delta_chr_f_false_positives: total_delta_chr_f.false_positives, + delta_chr_f_false_negatives: total_delta_chr_f.false_negatives, + delta_chr_f_precision: if total_scores == 0 { + 0.0 + } else { + total_delta_chr_f_precision / total_scores as f64 + }, + delta_chr_f_recall: if total_scores == 0 { + 0.0 + } else { + total_delta_chr_f_recall / total_scores as f64 + }, avg_braces_disbalance, exact_lines_true_positives: total_exact_lines.true_positives, exact_lines_false_positives: total_exact_lines.false_positives, From b95542410ca1afb6155a27e09d973c1bdcccabaa Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 25 Mar 2026 09:01:15 -0700 Subject: [PATCH 06/45] Add a button to remove the plan UI (#52360) ## Context This PR adds a button to remove the plan UI, if the user wants. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 5 +++++ crates/agent_ui/src/conversation_view/thread_view.rs | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 2195f9e73ef085bdbaf91aaec6e42383f533ee8f..df59c67bb4576e34f76539df34147fb4606bb9f3 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -2072,6 +2072,11 @@ impl AcpThread { cx.notify(); } + pub fn clear_plan(&mut self, cx: &mut Context) { + self.plan.entries.clear(); + cx.notify(); + } + #[cfg(any(test, feature = "test-support"))] pub fn send_raw( &mut self, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index ad7efa94324e910ed6f5bc63efe1cdf1b26bdc9e..1ad07efd52ddcffe29bd3d50e382d85813c3c994 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2540,7 +2540,17 @@ impl ThreadView { this.border_b_1().border_color(cx.theme().colors().border) }) .child(Disclosure::new("plan_disclosure", plan_expanded)) - .child(title) + .child(title.flex_1()) + .child( + IconButton::new("dismiss-plan", IconName::Close) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .tooltip(Tooltip::text("Clear plan")) + .on_click(cx.listener(|this, _, _, cx| { + this.thread.update(cx, |thread, cx| thread.clear_plan(cx)); + cx.stop_propagation(); + })), + ) .on_click(cx.listener(|this, _, _, cx| { this.plan_expanded = !this.plan_expanded; cx.notify(); From e63bd1ab326c387c9c55e64ba49f4cb9d58b271c Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 25 Mar 2026 17:01:17 +0100 Subject: [PATCH 07/45] language: Improve highlight map resolution (#52183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR refactors the highlight map capture name resolution to be faster and more predictable. Speficically, - it changes the capture name matching to explicit prefix matching (e.g., `function.call.whatever.jsx` will now be matched by only `function`, `function.call`, `function.call.whatever` and `function.call.whatever.jsx`). This matches the behavior VSCode has - resolving highlights is now much more efficient, as we now look up captures in a BTreeMap as opposed to searching in a Vector for these. This substantially improves the performance for resolving capture names against themes. With the benchmark added here for creating the HighlightMap, we see quite some improvements: ``` Running benches/highlight_map.rs (target/release/deps/highlight_map-f99da68650aac85b) HighlightMap::new/small_captures/small_theme time: [161.90 ns 162.70 ns 163.55 ns] change: [-39.027% -38.352% -37.742%] (p = 0.00 < 0.05) Performance has improved. Found 3 outliers among 100 measurements (3.00%) 3 (3.00%) high mild HighlightMap::new/small_captures/large_theme time: [231.37 ns 233.02 ns 234.70 ns] change: [-91.570% -91.516% -91.464%] (p = 0.00 < 0.05) Performance has improved. HighlightMap::new/large_captures/small_theme time: [991.82 ns 994.94 ns 998.50 ns] change: [-50.670% -50.443% -50.220%] (p = 0.00 < 0.05) Performance has improved. Found 5 outliers among 100 measurements (5.00%) 5 (5.00%) high mild HighlightMap::new/large_captures/large_theme time: [1.6528 µs 1.6650 µs 1.6784 µs] change: [-91.684% -91.637% -91.593%] (p = 0.00 < 0.05) Performance has improved. Found 1 outliers among 100 measurements (1.00%) 1 (1.00%) low mild ``` For large themes and many capture names, the revised approach is much faster. With that in place, we can also add better fallbacks whenever we change tokens, since e.g. a change from `@variable` to `@preproc` would previously cause tokens to not be highlighted at all, whereas now we can add fallbacks for such cases more efficiently. I'll add this later on to this PR. ## Self-Review Checklist - [X] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [X] Tests cover the new/changed behavior - [X] Performance impact has been considered and is acceptable Release Notes: - Improved resolution speed of theme highlight capture names. This might change highlighting in some rare edge cases, but should overall make highlighting more predicatable. Theme captures will now follow a strict prefix matching, so e.g. function.call.decorator.jsx` will now be matched by only `function`, `function.call`, `function.call.decorator` and `function.call.decorator.jsx` with the most specific capture always taking precedence. --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Co-authored-by: Gaauwe Rombouts --- Cargo.lock | 1 + .../src/session/running/variable_list.rs | 7 +- crates/editor/src/editor.rs | 6 +- crates/editor/src/semantic_tokens.rs | 5 +- crates/language/Cargo.toml | 5 + crates/language/benches/highlight_map.rs | 144 ++++++++++++++++++ crates/language/src/highlight_map.rs | 34 ++--- .../src/highlights_tree_view.rs | 4 +- crates/onboarding/src/theme_preview.rs | 14 +- crates/theme/src/fallback_themes.rs | 126 ++++++++------- crates/theme/src/styles/syntax.rs | 125 +++++++++------ crates/theme/src/theme.rs | 49 +++--- 12 files changed, 346 insertions(+), 174 deletions(-) create mode 100644 crates/language/benches/highlight_map.rs diff --git a/Cargo.lock b/Cargo.lock index ae05451fb1efde4fcb7a4404d6ff2f0526bd1466..a3b1e7b48ad76de494fb13f10391957ccc604816 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9331,6 +9331,7 @@ dependencies = [ "async-trait", "clock", "collections", + "criterion", "ctor", "diffy", "ec4rs", diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs index 8329a6baf04061cc33e8130a4e6b3a33b35267b6..fd8fd736b9e5194d34df3928c0c2983bb40be954 100644 --- a/crates/debugger_ui/src/session/running/variable_list.rs +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -1076,7 +1076,12 @@ impl VariableList { presentation_hint: Option<&VariablePresentationHint>, cx: &Context, ) -> VariableColor { - let syntax_color_for = |name| cx.theme().syntax().get(name).color; + let syntax_color_for = |name| { + cx.theme() + .syntax() + .style_for_name(name) + .and_then(|style| style.color) + }; let name = if self.disabled { Some(Color::Disabled.color(cx)) } else { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ee659f2870502a96d1e052035d974b10213f5604..b633b1d77359c6f7888e69ffc21615210f075e13 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -601,7 +601,11 @@ pub fn make_inlay_hints_style(cx: &App) -> HighlightStyle { .inlay_hints .show_background; - let mut style = cx.theme().syntax().get("hint"); + let mut style = cx + .theme() + .syntax() + .style_for_name("hint") + .unwrap_or_default(); if style.color.is_none() { style.color = Some(cx.theme().status().hint); diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index 6a82068410f074c3246f2d84eab9a3576f2e8848..1a895465277d02078f1bf23da21f061a94f94be7 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -377,7 +377,10 @@ fn convert_token( for rule in matching { empty = false; - let style = rule.style.iter().find_map(|style| theme.get_opt(style)); + let style = rule + .style + .iter() + .find_map(|style| theme.style_for_name(style)); macro_rules! overwrite { ( diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 37c19172f7c48743e1436ba41e30d0c7ebf99d1d..5cc2550d471750e4b1cc4744a5a91af748a0de91 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -101,6 +101,11 @@ toml.workspace = true unindent.workspace = true util = { workspace = true, features = ["test-support"] } zlog.workspace = true +criterion.workspace = true + +[[bench]] +name = "highlight_map" +harness = false [package.metadata.cargo-machete] ignored = ["tracing"] diff --git a/crates/language/benches/highlight_map.rs b/crates/language/benches/highlight_map.rs new file mode 100644 index 0000000000000000000000000000000000000000..97c7204deec8088575fc79f3c4507d9143a70666 --- /dev/null +++ b/crates/language/benches/highlight_map.rs @@ -0,0 +1,144 @@ +use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; +use gpui::rgba; +use language::HighlightMap; +use theme::SyntaxTheme; + +fn syntax_theme(highlight_names: &[&str]) -> SyntaxTheme { + SyntaxTheme::new(highlight_names.iter().enumerate().map(|(i, name)| { + let r = ((i * 37) % 256) as u8; + let g = ((i * 53) % 256) as u8; + let b = ((i * 71) % 256) as u8; + let color = rgba(u32::from_be_bytes([r, g, b, 0xff])); + (name.to_string(), color.into()) + })) +} + +static SMALL_THEME_KEYS: &[&str] = &[ + "comment", "function", "keyword", "string", "type", "variable", +]; + +static LARGE_THEME_KEYS: &[&str] = &[ + "attribute", + "boolean", + "comment", + "comment.doc", + "constant", + "constant.builtin", + "constructor", + "embedded", + "emphasis", + "emphasis.strong", + "function", + "function.builtin", + "function.method", + "function.method.builtin", + "function.special.definition", + "keyword", + "keyword.control", + "keyword.control.conditional", + "keyword.control.import", + "keyword.control.repeat", + "keyword.control.return", + "keyword.modifier", + "keyword.operator", + "label", + "link_text", + "link_uri", + "number", + "operator", + "property", + "punctuation", + "punctuation.bracket", + "punctuation.delimiter", + "punctuation.list_marker", + "punctuation.special", + "string", + "string.escape", + "string.regex", + "string.special", + "string.special.symbol", + "tag", + "text.literal", + "title", + "type", + "type.builtin", + "type.super", + "variable", + "variable.builtin", + "variable.member", + "variable.parameter", + "variable.special", +]; + +static SMALL_CAPTURE_NAMES: &[&str] = &[ + "function", + "keyword", + "string.escape", + "type.builtin", + "variable.builtin", +]; + +static LARGE_CAPTURE_NAMES: &[&str] = &[ + "attribute", + "boolean", + "comment", + "comment.doc", + "constant", + "constant.builtin", + "constructor", + "function", + "function.builtin", + "function.method", + "keyword", + "keyword.control", + "keyword.control.conditional", + "keyword.control.import", + "keyword.modifier", + "keyword.operator", + "label", + "number", + "operator", + "property", + "punctuation.bracket", + "punctuation.delimiter", + "punctuation.special", + "string", + "string.escape", + "string.regex", + "string.special", + "tag", + "type", + "type.builtin", + "variable", + "variable.builtin", + "variable.member", + "variable.parameter", +]; + +fn bench_highlight_map_new(c: &mut Criterion) { + let mut group = c.benchmark_group("HighlightMap::new"); + + for (capture_label, capture_names) in [ + ("small_captures", SMALL_CAPTURE_NAMES as &[&str]), + ("large_captures", LARGE_CAPTURE_NAMES as &[&str]), + ] { + for (theme_label, theme_keys) in [ + ("small_theme", SMALL_THEME_KEYS as &[&str]), + ("large_theme", LARGE_THEME_KEYS as &[&str]), + ] { + let theme = syntax_theme(theme_keys); + group.bench_with_input( + BenchmarkId::new(capture_label, theme_label), + &(capture_names, &theme), + |b, (capture_names, theme)| { + b.iter(|| HighlightMap::new(black_box(capture_names), black_box(theme))); + }, + ); + } + } + + group.finish(); +} + +criterion_group!(benches, bench_highlight_map_new); +criterion_main!(benches); diff --git a/crates/language/src/highlight_map.rs b/crates/language/src/highlight_map.rs index ed9eb5d11d7bc4b156dc9bd660fb10a485129c3d..caab0e47f1e10e565aaeed541d99ed0d729b70a8 100644 --- a/crates/language/src/highlight_map.rs +++ b/crates/language/src/highlight_map.rs @@ -11,7 +11,7 @@ pub struct HighlightId(pub u32); const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX); impl HighlightMap { - pub(crate) fn new(capture_names: &[&str], theme: &SyntaxTheme) -> Self { + pub fn new(capture_names: &[&str], theme: &SyntaxTheme) -> Self { // For each capture name in the highlight query, find the longest // key in the theme's syntax styles that matches all of the // dot-separated components of the capture name. @@ -20,23 +20,8 @@ impl HighlightMap { .iter() .map(|capture_name| { theme - .highlights - .iter() - .enumerate() - .filter_map(|(i, (key, _))| { - let mut len = 0; - let capture_parts = capture_name.split('.'); - for key_part in key.split('.') { - if capture_parts.clone().any(|part| part == key_part) { - len += 1; - } else { - return None; - } - } - Some((i, len)) - }) - .max_by_key(|(_, len)| *len) - .map_or(DEFAULT_SYNTAX_HIGHLIGHT_ID, |(i, _)| HighlightId(i as u32)) + .highlight_id(capture_name) + .map_or(DEFAULT_SYNTAX_HIGHLIGHT_ID, HighlightId) }) .collect(), ) @@ -59,11 +44,11 @@ impl HighlightId { } pub fn style(&self, theme: &SyntaxTheme) -> Option { - theme.highlights.get(self.0 as usize).map(|entry| entry.1) + theme.get(self.0 as usize).cloned() } pub fn name<'a>(&self, theme: &'a SyntaxTheme) -> Option<&'a str> { - theme.highlights.get(self.0 as usize).map(|e| e.0.as_str()) + theme.get_capture_name(self.0 as usize) } } @@ -86,8 +71,8 @@ mod tests { #[test] fn test_highlight_map() { - let theme = SyntaxTheme { - highlights: [ + let theme = SyntaxTheme::new( + [ ("function", rgba(0x100000ff)), ("function.method", rgba(0x200000ff)), ("function.async", rgba(0x300000ff)), @@ -96,9 +81,8 @@ mod tests { ("variable", rgba(0x600000ff)), ] .iter() - .map(|(name, color)| (name.to_string(), (*color).into())) - .collect(), - }; + .map(|(name, color)| (name.to_string(), (*color).into())), + ); let capture_names = &[ "function.special", diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index 8a139958897c261816171c364b6d1f62ccb3b8c6..1e19ee47b3c0c42005a266f7e1cd081aa74b2095 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -375,7 +375,9 @@ impl HighlightsTreeView { rule.style .iter() .find(|style_name| { - semantic_theme.get_opt(style_name).is_some() + semantic_theme + .style_for_name(style_name) + .is_some() }) .map(|style_name| { SharedString::from(style_name.clone()) diff --git a/crates/onboarding/src/theme_preview.rs b/crates/onboarding/src/theme_preview.rs index 8bd65d8a2707acdc53333071486f41741398a82a..602695cca6a643d4eb4d3476286bba7fcfe74c40 100644 --- a/crates/onboarding/src/theme_preview.rs +++ b/crates/onboarding/src/theme_preview.rs @@ -87,13 +87,13 @@ impl ThemePreviewTile { let colors = theme.colors(); let syntax = theme.syntax(); - let keyword_color = syntax.get("keyword").color; - let function_color = syntax.get("function").color; - let string_color = syntax.get("string").color; - let comment_color = syntax.get("comment").color; - let variable_color = syntax.get("variable").color; - let type_color = syntax.get("type").color; - let punctuation_color = syntax.get("punctuation").color; + let keyword_color = syntax.style_for_name("keyword").and_then(|s| s.color); + let function_color = syntax.style_for_name("function").and_then(|s| s.color); + let string_color = syntax.style_for_name("string").and_then(|s| s.color); + let comment_color = syntax.style_for_name("comment").and_then(|s| s.color); + let variable_color = syntax.style_for_name("variable").and_then(|s| s.color); + let type_color = syntax.style_for_name("type").and_then(|s| s.color); + let punctuation_color = syntax.style_for_name("punctuation").and_then(|s| s.color); let syntax_colors = [ keyword_color, diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 72b65f85c9ecb2776fc6066c8b926cfa4bd42929..bfff86b5c614e41711ae1d1be3d9b4aca08cc822 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -314,70 +314,68 @@ pub(crate) fn zed_default_dark() -> Theme { warning_border: yellow, }, player, - syntax: Arc::new(SyntaxTheme { - highlights: vec![ - ("attribute".into(), purple.into()), - ("boolean".into(), orange.into()), - ("comment".into(), gray.into()), - ("comment.doc".into(), gray.into()), - ("constant".into(), yellow.into()), - ("constructor".into(), blue.into()), - ("embedded".into(), HighlightStyle::default()), - ( - "emphasis".into(), - HighlightStyle { - font_style: Some(FontStyle::Italic), - ..HighlightStyle::default() - }, - ), - ( - "emphasis.strong".into(), - HighlightStyle { - font_weight: Some(FontWeight::BOLD), - ..HighlightStyle::default() - }, - ), - ("enum".into(), teal.into()), - ("function".into(), blue.into()), - ("function.method".into(), blue.into()), - ("function.definition".into(), blue.into()), - ("hint".into(), blue.into()), - ("keyword".into(), purple.into()), - ("label".into(), HighlightStyle::default()), - ("link_text".into(), blue.into()), - ( - "link_uri".into(), - HighlightStyle { - color: Some(teal), - font_style: Some(FontStyle::Italic), - ..HighlightStyle::default() - }, - ), - ("number".into(), orange.into()), - ("operator".into(), HighlightStyle::default()), - ("predictive".into(), HighlightStyle::default()), - ("preproc".into(), HighlightStyle::default()), - ("primary".into(), HighlightStyle::default()), - ("property".into(), red.into()), - ("punctuation".into(), HighlightStyle::default()), - ("punctuation.bracket".into(), HighlightStyle::default()), - ("punctuation.delimiter".into(), HighlightStyle::default()), - ("punctuation.list_marker".into(), HighlightStyle::default()), - ("punctuation.special".into(), HighlightStyle::default()), - ("string".into(), green.into()), - ("string.escape".into(), HighlightStyle::default()), - ("string.regex".into(), red.into()), - ("string.special".into(), HighlightStyle::default()), - ("string.special.symbol".into(), HighlightStyle::default()), - ("tag".into(), HighlightStyle::default()), - ("text.literal".into(), HighlightStyle::default()), - ("title".into(), HighlightStyle::default()), - ("type".into(), teal.into()), - ("variable".into(), HighlightStyle::default()), - ("variable.special".into(), red.into()), - ("variant".into(), HighlightStyle::default()), - ], - }), + syntax: Arc::new(SyntaxTheme::new(vec![ + ("attribute".into(), purple.into()), + ("boolean".into(), orange.into()), + ("comment".into(), gray.into()), + ("comment.doc".into(), gray.into()), + ("constant".into(), yellow.into()), + ("constructor".into(), blue.into()), + ("embedded".into(), HighlightStyle::default()), + ( + "emphasis".into(), + HighlightStyle { + font_style: Some(FontStyle::Italic), + ..HighlightStyle::default() + }, + ), + ( + "emphasis.strong".into(), + HighlightStyle { + font_weight: Some(FontWeight::BOLD), + ..HighlightStyle::default() + }, + ), + ("enum".into(), teal.into()), + ("function".into(), blue.into()), + ("function.method".into(), blue.into()), + ("function.definition".into(), blue.into()), + ("hint".into(), blue.into()), + ("keyword".into(), purple.into()), + ("label".into(), HighlightStyle::default()), + ("link_text".into(), blue.into()), + ( + "link_uri".into(), + HighlightStyle { + color: Some(teal), + font_style: Some(FontStyle::Italic), + ..HighlightStyle::default() + }, + ), + ("number".into(), orange.into()), + ("operator".into(), HighlightStyle::default()), + ("predictive".into(), HighlightStyle::default()), + ("preproc".into(), HighlightStyle::default()), + ("primary".into(), HighlightStyle::default()), + ("property".into(), red.into()), + ("punctuation".into(), HighlightStyle::default()), + ("punctuation.bracket".into(), HighlightStyle::default()), + ("punctuation.delimiter".into(), HighlightStyle::default()), + ("punctuation.list_marker".into(), HighlightStyle::default()), + ("punctuation.special".into(), HighlightStyle::default()), + ("string".into(), green.into()), + ("string.escape".into(), HighlightStyle::default()), + ("string.regex".into(), red.into()), + ("string.special".into(), HighlightStyle::default()), + ("string.special.symbol".into(), HighlightStyle::default()), + ("tag".into(), HighlightStyle::default()), + ("text.literal".into(), HighlightStyle::default()), + ("title".into(), HighlightStyle::default()), + ("type".into(), teal.into()), + ("variable".into(), HighlightStyle::default()), + ("variable.special".into(), red.into()), + ("variant".into(), HighlightStyle::default()), + ])), }, } } diff --git a/crates/theme/src/styles/syntax.rs b/crates/theme/src/styles/syntax.rs index 6a1615387835e0db1aefa03c63efd5c27ca2518d..aa2590547c204ccd33871c00f74dc961470fdd4b 100644 --- a/crates/theme/src/styles/syntax.rs +++ b/crates/theme/src/styles/syntax.rs @@ -1,15 +1,38 @@ #![allow(missing_docs)] -use std::sync::Arc; +use std::{ + collections::{BTreeMap, btree_map::Entry}, + sync::Arc, +}; -use gpui::{HighlightStyle, Hsla}; +use gpui::HighlightStyle; +#[cfg(any(test, feature = "test-support"))] +use gpui::Hsla; #[derive(Debug, PartialEq, Eq, Clone, Default)] pub struct SyntaxTheme { - pub highlights: Vec<(String, HighlightStyle)>, + pub(self) highlights: Vec, + pub(self) capture_name_map: BTreeMap, } impl SyntaxTheme { + pub fn new(highlights: impl IntoIterator) -> Self { + let (capture_names, highlights) = highlights.into_iter().unzip(); + + Self { + capture_name_map: Self::create_capture_name_map(capture_names), + highlights, + } + } + + fn create_capture_name_map(highlights: Vec) -> BTreeMap { + highlights + .into_iter() + .enumerate() + .map(|(i, key)| (key, i)) + .collect() + } + #[cfg(any(test, feature = "test-support"))] pub fn new_test(colors: impl IntoIterator) -> Self { Self::new_test_styles(colors.into_iter().map(|(key, color)| { @@ -27,34 +50,45 @@ impl SyntaxTheme { pub fn new_test_styles( colors: impl IntoIterator, ) -> Self { - Self { - highlights: colors + Self::new( + colors .into_iter() - .map(|(key, style)| (key.to_owned(), style)) - .collect(), - } + .map(|(key, style)| (key.to_owned(), style)), + ) } - pub fn get(&self, name: &str) -> HighlightStyle { - self.highlights - .iter() - .find_map(|entry| if entry.0 == name { Some(entry.1) } else { None }) - .unwrap_or_default() + pub fn get(&self, highlight_index: usize) -> Option<&HighlightStyle> { + self.highlights.get(highlight_index) } - pub fn get_opt(&self, name: &str) -> Option { - self.highlights - .iter() - .find_map(|entry| if entry.0 == name { Some(entry.1) } else { None }) + pub fn style_for_name(&self, name: &str) -> Option { + self.capture_name_map + .get(name) + .map(|highlight_idx| self.highlights[*highlight_idx]) } - pub fn color(&self, name: &str) -> Hsla { - self.get(name).color.unwrap_or_default() + pub fn get_capture_name(&self, idx: usize) -> Option<&str> { + self.capture_name_map + .iter() + .find(|(_, value)| **value == idx) + .map(|(key, _)| key.as_ref()) } - pub fn highlight_id(&self, name: &str) -> Option { - let ix = self.highlights.iter().position(|entry| entry.0 == name)?; - Some(ix as u32) + pub fn highlight_id(&self, capture_name: &str) -> Option { + self.capture_name_map + .range::(( + capture_name.split(".").next().map_or( + std::ops::Bound::Included(capture_name), + std::ops::Bound::Included, + ), + std::ops::Bound::Included(capture_name), + )) + .rfind(|(prefix, _)| { + capture_name + .strip_prefix(*prefix) + .is_some_and(|remainder| remainder.is_empty() || remainder.starts_with('.')) + }) + .map(|(_, index)| *index as u32) } /// Returns a new [`Arc`] with the given syntax styles merged in. @@ -63,33 +97,36 @@ impl SyntaxTheme { return base; } - let mut merged_highlights = base.highlights.clone(); + let mut base = Arc::try_unwrap(base).unwrap_or_else(|base| (*base).clone()); for (name, highlight) in user_syntax_styles { - if let Some((_, existing_highlight)) = merged_highlights - .iter_mut() - .find(|(existing_name, _)| existing_name == &name) - { - existing_highlight.color = highlight.color.or(existing_highlight.color); - existing_highlight.font_weight = - highlight.font_weight.or(existing_highlight.font_weight); - existing_highlight.font_style = - highlight.font_style.or(existing_highlight.font_style); - existing_highlight.background_color = highlight - .background_color - .or(existing_highlight.background_color); - existing_highlight.underline = highlight.underline.or(existing_highlight.underline); - existing_highlight.strikethrough = - highlight.strikethrough.or(existing_highlight.strikethrough); - existing_highlight.fade_out = highlight.fade_out.or(existing_highlight.fade_out); - } else { - merged_highlights.push((name, highlight)); + match base.capture_name_map.entry(name) { + Entry::Occupied(entry) => { + if let Some(existing_highlight) = base.highlights.get_mut(*entry.get()) { + existing_highlight.color = highlight.color.or(existing_highlight.color); + existing_highlight.font_weight = + highlight.font_weight.or(existing_highlight.font_weight); + existing_highlight.font_style = + highlight.font_style.or(existing_highlight.font_style); + existing_highlight.background_color = highlight + .background_color + .or(existing_highlight.background_color); + existing_highlight.underline = + highlight.underline.or(existing_highlight.underline); + existing_highlight.strikethrough = + highlight.strikethrough.or(existing_highlight.strikethrough); + existing_highlight.fade_out = + highlight.fade_out.or(existing_highlight.fade_out); + } + } + Entry::Vacant(vacant) => { + vacant.insert(base.highlights.len()); + base.highlights.push(highlight); + } } } - Arc::new(Self { - highlights: merged_highlights, - }) + Arc::new(base) } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index ca330beee3c9604278ce187e0609f60fbc58170e..3449e039a9e3f4135a0f8471b8346f6b6e6b9fcc 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -258,30 +258,25 @@ impl ThemeFamily { }; refined_accent_colors.merge(&theme.style.accents); - let syntax_highlights = theme - .style - .syntax - .iter() - .map(|(syntax_token, highlight)| { - ( - syntax_token.clone(), - HighlightStyle { - color: highlight - .color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - background_color: highlight - .background_color - .as_ref() - .and_then(|color| try_parse_color(color).ok()), - font_style: highlight.font_style.map(|s| s.into_gpui()), - font_weight: highlight.font_weight.map(|w| w.into_gpui()), - ..Default::default() - }, - ) - }) - .collect::>(); - let syntax_theme = SyntaxTheme::merge(Arc::new(SyntaxTheme::default()), syntax_highlights); + let syntax_highlights = theme.style.syntax.iter().map(|(syntax_token, highlight)| { + ( + syntax_token.clone(), + HighlightStyle { + color: highlight + .color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + background_color: highlight + .background_color + .as_ref() + .and_then(|color| try_parse_color(color).ok()), + font_style: highlight.font_style.map(|s| s.into_gpui()), + font_weight: highlight.font_weight.map(|w| w.into_gpui()), + ..Default::default() + }, + ) + }); + let syntax_theme = Arc::new(SyntaxTheme::new(syntax_highlights)); let window_background_appearance = theme .style @@ -381,12 +376,6 @@ impl Theme { &self.styles.status } - /// Returns the color for the syntax node with the given name. - #[inline(always)] - pub fn syntax_color(&self, name: &str) -> Hsla { - self.syntax().color(name) - } - /// Returns the [`Appearance`] for the theme. #[inline(always)] pub fn appearance(&self) -> Appearance { From 72540bb6dc57c8e9384e69530114152283f95a2d Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 25 Mar 2026 11:05:26 -0500 Subject: [PATCH 08/45] docs: Update ep docs for new bindings (#52326) ## Context Split out from https://github.com/zed-industries/zed/pull/52258, because #50136 isn't ready yet :( Need to add descriptions of how to do each example binding in the Keymap UI ## How to Review - Ensure wording/structure is good - Ensure no missing examples ## Self-Review Checklist - [ ] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [ ] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - [ ] Performance impact has been considered and is acceptable Release Notes: - N/A or Added/Fixed/Improved ... --------- Co-authored-by: Max Co-authored-by: Katie Geer --- docs/src/ai/edit-prediction.md | 198 +++++++++------------------------ 1 file changed, 51 insertions(+), 147 deletions(-) diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 5c0bae7f93a65ea919e820097bb15fc2ce2267b6..496bf925e5b137c8b4749207c6785d30913440ae 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -6,7 +6,7 @@ description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copi # Edit Prediction Edit Prediction is how Zed's AI code completions work: an LLM predicts the code you want to write. -Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions that can be quickly accepted by pressing `tab`. +Each keystroke sends a new request to the edit prediction provider, which returns individual or multi-line suggestions you accept by pressing `tab`. The default provider is [Zeta, a proprietary open source and open dataset model](https://huggingface.co/zed-industries/zeta), but you can also use [other providers](#other-providers) like GitHub Copilot, Mercury Coder, and Codestral. @@ -15,7 +15,9 @@ The default provider is [Zeta, a proprietary open source and open dataset model] To use Zeta, [sign in](../authentication.md#what-features-require-signing-in). Once signed in, predictions appear as you type. -You can confirm that Zeta is properly configured either by verifying whether you have the following code in your settings file: +You can confirm that Zeta is properly configured by opening the [Settings Editor](zed://settings/edit_predictions.providers) (`Cmd+,` on macOS or `Ctrl+,` on Linux/Windows) and searching for `edit_predictions`. The `provider` field should be set to `Zed AI`. + +Or verify this in your settings.json: ```json [settings] { @@ -33,7 +35,7 @@ The free plan includes 2,000 Zeta predictions per month. The [Pro plan](../ai/pl ### Switching Modes {#switching-modes} -Zed's Edit Prediction comes with two different display modes: +Edit Prediction has two display modes: 1. `eager` (default): predictions are displayed inline as long as it doesn't conflict with language server completions 2. `subtle`: predictions only appear inline when holding a modifier key (`alt` by default) @@ -52,191 +54,93 @@ Or directly via the UI through the status bar menu: > Note that edit prediction modes work with any prediction provider. -### Conflict With Other `tab` Actions {#edit-predictions-conflict} - -By default, when `tab` would normally perform a different action, Zed requires a modifier key to accept predictions: - -1. When the language server completions menu is visible. -2. When your cursor isn't at the right indentation level. +## Default Key Bindings -In these cases, `alt-tab` is used instead to accept the prediction. When the language server completions menu is open, holding `alt` first will cause it to temporarily disappear in order to preview the prediction within the buffer. +On macOS and Windows, you can accept edit predictions with `alt-tab`. On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is the default key binding for edit predictions. -On Linux, `alt-tab` is often used by the window manager for switching windows, so `alt-l` is provided as the default binding for accepting predictions. `tab` and `alt-tab` also work, but aren't displayed by default. +In `eager` mode, you can also use the `tab` key to accept edit predictions, unless the completion menu is open, in which case `tab` accepts LSP completions. To use `tab` to insert whitespace, you need to dismiss the prediction with {#kb editor::Cancel} before hitting `tab`. {#action editor::AcceptNextWordEditPrediction} ({#kb editor::AcceptNextWordEditPrediction}) can be used to accept the current edit prediction up to the next word boundary. {#action editor::AcceptNextLineEditPrediction} ({#kb editor::AcceptNextLineEditPrediction}) can be used to accept the current edit prediction up to the new line boundary. ## Configuring Edit Prediction Keybindings {#edit-predictions-keybinding} -By default, `tab` is used to accept edit predictions. You can use another keybinding by inserting this in your keymap: - -```json [keymap] -{ - "context": "Editor && edit_prediction", - "bindings": { - // Here we also allow `alt-enter` to accept the prediction - "alt-enter": "editor::AcceptEditPrediction" - } -} -``` - -When there's a [conflict with the `tab` key](#edit-predictions-conflict), Zed uses a different key context to accept keybindings (`edit_prediction_conflict`). -If you want to use a different one, you can insert this in your keymap: - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict", - "bindings": { - "ctrl-enter": "editor::AcceptEditPrediction" // Example of a modified keybinding - } -} -``` - -If your keybinding contains a modifier (`ctrl` in the example above), it will also be used to preview the edit prediction and temporarily hide the language server completion menu. - -You can also bind this action to keybind without a modifier. -In that case, Zed will use the default modifier (`alt`) to preview the edit prediction. - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict", - "bindings": { - // Here we bind tab to accept even when there's a language server completion - // or the cursor isn't at the correct indentation level - "tab": "editor::AcceptEditPrediction" - } -} -``` - -To maintain the use of the modifier key for accepting predictions when there is a language server completions menu, but allow `tab` to accept predictions regardless of cursor position, you can specify the context further with `showing_completions`: - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict && !showing_completions", - "bindings": { - // Here we don't require a modifier unless there's a language server completion - "tab": "editor::AcceptEditPrediction" - } -} -``` - ### Keybinding Example: Always Use Tab -If you want to use `tab` to always accept edit predictions, you can use the following keybinding: - -```json [keymap] -{ - "context": "Editor && edit_prediction_conflict && showing_completions", - "bindings": { - "tab": "editor::AcceptEditPrediction" - } -} -``` - -This will make `tab` work to accept edit predictions _even when_ you're also seeing language server completions. -That means that you need to rely on `enter` for accepting the latter. +To always use `tab` for accepting edit predictions, regardless of whether the LSP completions menu is open, you can add the following to your keymap: -### Keybinding Example: Always Use Alt-Tab +Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and hit `edit`. Then change the context the binding is active in to just `Editor && edit_prediction` and save it. -The keybinding example below causes `alt-tab` to always be used instead of sometimes using `tab`. -You might want this in order to have just one (alternative) keybinding to use for accepting edit predictions, since the behavior of `tab` varies based on context. +Alternatively, you can put the following in your `keymap.json`: ```json [keymap] +[ { "context": "Editor && edit_prediction", "bindings": { - "alt-tab": "editor::AcceptEditPrediction" + "tab": "editor::AcceptEditPrediction" } - }, - // Bind `tab` back to its original behavior. - { - "context": "Editor", - "bindings": { - "tab": "editor::Tab" - } - }, - { - "context": "Editor && showing_completions", - "bindings": { - "tab": "editor::ComposeCompletion" - } - }, + } +] ``` -If you are using [Vim mode](../vim.md), then additional bindings are needed after the above to return `tab` to its original behavior: +After that, {#kb editor::ComposeCompletion} remains available for accepting LSP completions. -```json [keymap] - { - "context": "(VimControl && !menu) || vim_mode == replace || vim_mode == waiting", - "bindings": { - "tab": "vim::Tab" - } - }, - { - "context": "vim_mode == literal", - "bindings": { - "tab": ["vim::Literal", ["tab", "\u0009"]] - } - }, -``` +### Keybinding Example: Always Use Alt-Tab + +To stop using `tab` for accepting edit predictions and always use `alt-tab` instead, unbind the default `tab` binding in the eager edit prediction context: -### Keybinding Example: Displaying Tab and Alt-Tab on Linux +Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it. -While `tab` and `alt-tab` are supported on Linux, `alt-l` is displayed instead. -If your window manager does not reserve `alt-tab`, and you would prefer to use `tab` and `alt-tab`, include these bindings in `keymap.json`: +Alternatively, you can put the following in your `keymap.json`: ```json [keymap] +[ { "context": "Editor && edit_prediction", - "bindings": { - "tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null + "unbind": { + "tab": "editor::AcceptEditPrediction" } - }, - { - "context": "Editor && edit_prediction_conflict", - "bindings": { - "alt-tab": "editor::AcceptEditPrediction", - // Optional: This makes the default `alt-l` binding do nothing. - "alt-l": null - } - }, + } +] ``` -### Missing keybind {#edit-predictions-missing-keybinding} +After that, `alt-tab` remains available for accepting edit predictions, and on Linux `alt-l` does too unless you unbind it. -Zed requires at least one keybinding for the {#action editor::AcceptEditPrediction} action in both the `Editor && edit_prediction` and `Editor && edit_prediction_conflict` contexts ([learn more above](#edit-predictions-keybinding)). +### Keybinding Example: Rebind Both Tab and Alt-Tab -If you have previously bound the default keybindings to different actions in the global context, you will not be able to preview or accept edit predictions. For example: +To move both default accept bindings to something else, unbind them and add your replacement: -```json [keymap] -[ - // Your keymap - { - "bindings": { - // Binds `alt-tab` to a different action globally - "alt-tab": "menu::SelectNext" - } - } -] -``` +Open the keymap editor with {#action zed::OpenKeymap} ({#kb zed::OpenKeymap}), search for `AcceptEditPrediction`, right click on the binding for `tab` and delete it. Then right click on the binding for `alt-tab`, select "Edit", and record your desired keystrokes before hitting saving. -To fix this, you can specify your own keybinding for accepting edit predictions: +Alternatively, you can put the following in your `keymap.json`: ```json [keymap] [ - // ... { - "context": "Editor && edit_prediction_conflict", + "context": "Editor && edit_prediction", + "unbind": { + "alt-tab": "editor::AcceptEditPrediction", + // Add this as well on Windows/Linux + // "alt-l": "editor::AcceptEditPrediction", + "tab": "editor::AcceptEditPrediction" + }, "bindings": { - "alt-l": "editor::AcceptEditPrediction" + "ctrl-enter": "editor::AcceptEditPrediction" } } ] ``` -If you would like to use the default keybinding, you can free it up by either moving yours to a more specific context or changing it to something else. +In this case, because the binding contains the modifier `ctrl`, it will be used to preview the prediction in subtle mode, or when the completions menu is open. + +### Cleaning Up Older Keymap Entries + +If you configured edit prediction keybindings before Zed `v0.229.0`, your `keymap.json` may have entries that are now redundant. + +**Old tab workaround**: Before `unbind` existed, the only way to prevent `tab` from accepting edit predictions was to copy all the default non-edit-prediction `tab` bindings into your keymap alongside a custom `AcceptEditPrediction` binding. If your keymap still contains those copy-pasted entries, delete them and use a single `"unbind"` entry as shown in the examples above. + +**Renamed context**: The `edit_prediction_conflict` context has been replaced by `edit_prediction && (showing_completions || in_leading_whitespace)`. Zed automatically migrates any bindings that used `edit_prediction_conflict`, so no changes are required on your end. ## Disabling Automatic Edit Prediction @@ -329,8 +233,8 @@ If your organization uses GitHub Copilot Enterprise, you can configure Zed to us Replace `"https://your.enterprise.domain"` with the URL provided by your GitHub Enterprise administrator (e.g., `https://foo.ghe.com`). -Once set, Zed will route Copilot requests through your enterprise endpoint. -When you sign in by clicking the Copilot icon in the status bar, you will be redirected to your configured enterprise URL to complete authentication. +Once set, Zed routes Copilot requests through your enterprise endpoint. +When you sign in by clicking the Copilot icon in the status bar, you are redirected to your configured enterprise URL to complete authentication. All other Copilot features and usage remain the same. Copilot can provide multiple completion alternatives, and these can be navigated with the following actions: @@ -342,7 +246,7 @@ Copilot can provide multiple completion alternatives, and these can be navigated To use [Mercury Coder](https://www.inceptionlabs.ai/) by Inception Labs as your provider: -1. Open the Settings Editor (`Cmd+,` on macOS, `Ctrl+,` on Linux/Windows) +1. Open the Settings Editor ({#kb zed::OpenSettings}) 2. Search for "Edit Predictions" and click **Configure Providers** 3. Find the Mercury section and enter your API key from the [Inception Labs dashboard](https://platform.inceptionlabs.ai/dashboard/api-keys) From d954f784c06291e0f2c87d55ecbf601645de54bd Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 25 Mar 2026 17:42:00 +0100 Subject: [PATCH 09/45] extension_cli: Ensure extension that provide themes do not provide further features (#52272) We generally enforce this policy already and also want this to be this way, so better to be error than sorry here. Release Notes: - N/A --- crates/extension_cli/src/main.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index d0a533bfeb331c196d802df9894e726201794ce7..4d290992f318dc8fec78dad0e40d347d4826ed65 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::collections::HashMap; use std::env; use std::fs; @@ -7,6 +8,7 @@ use std::sync::Arc; use ::fs::{CopyOptions, Fs, RealFs, copy_recursive}; use anyhow::{Context as _, Result, anyhow, bail}; use clap::Parser; +use cloud_api_types::ExtensionProvides; use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder}; use extension::{ExtensionManifest, ExtensionSnippets}; use language::LanguageConfig; @@ -80,10 +82,7 @@ async fn main() -> Result<()> { .context("failed to compile extension")?; let extension_provides = manifest.provides(); - - if extension_provides.is_empty() { - bail!("extension does not provide any features"); - } + validate_extension_features(&extension_provides)?; let grammars = test_grammars(&manifest, &extension_path, &mut wasm_store)?; test_languages(&manifest, &extension_path, &grammars)?; @@ -203,7 +202,7 @@ async fn copy_extension_resources( }, ) .await - .with_context(|| "failed to copy icons")?; + .context("failed to copy icons")?; } for (_, agent_entry) in &manifest.agent_servers { @@ -297,6 +296,22 @@ async fn copy_extension_resources( Ok(()) } +fn validate_extension_features(provides: &BTreeSet) -> Result<()> { + if provides.is_empty() { + bail!("extension does not provide any features"); + } + + if provides.contains(&ExtensionProvides::Themes) && provides.len() != 1 { + bail!("extension must not provide other features along with themes"); + } + + if provides.contains(&ExtensionProvides::IconThemes) && provides.len() != 1 { + bail!("extension must not provide other features along with icon themes"); + } + + Ok(()) +} + fn test_grammars( manifest: &ExtensionManifest, extension_path: &Path, From 46120c907fe463e4d0e2f677ddc6cef194e99fd4 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Wed, 25 Mar 2026 10:16:51 -0700 Subject: [PATCH 10/45] Sidebar and git cleanups (#52431) A few minor cleanups in sidebar.rs and some related areas. Release notes: - N/A --- crates/project/src/git_store.rs | 17 +++ crates/sidebar/src/sidebar.rs | 184 +++++++++++++++++--------------- 2 files changed, 114 insertions(+), 87 deletions(-) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 3a4653345d7a84e702e657e80a360eeae00385ab..346ebb1614e3b536d78765ce7ca90ad1e30f6bfc 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -3706,6 +3706,23 @@ impl RepositorySnapshot { } } + /// The main worktree is the original checkout that other worktrees were + /// created from. + /// + /// For example, if you had both `~/code/zed` and `~/code/worktrees/zed-2`, + /// then `~/code/zed` is the main worktree and `~/code/worktrees/zed-2` is a linked worktree. + pub fn is_main_worktree(&self) -> bool { + self.work_directory_abs_path == self.original_repo_abs_path + } + + /// Returns true if this repository is a linked worktree, that is, one that + /// was created from another worktree. + /// + /// This is by definition the opposite of [`Self::is_main_worktree`]. + pub fn is_linked_worktree(&self) -> bool { + !self.is_main_worktree() + } + pub fn linked_worktrees(&self) -> &[GitWorktree] { &self.linked_worktrees } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index da78c634764fedf0643a7b225190ae5cb28f8e58..dfde1f7454178fdd383f5fbf5e3a7e65548d595d 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -190,21 +190,17 @@ fn fuzzy_match_positions(query: &str, candidate: &str) -> Option> { fn root_repository_snapshots( workspace: &Entity, cx: &App, -) -> Vec { +) -> impl Iterator { let path_list = workspace_path_list(workspace, cx); let project = workspace.read(cx).project().read(cx); - project - .repositories(cx) - .values() - .filter_map(|repo| { - let snapshot = repo.read(cx).snapshot(); - let is_root = path_list - .paths() - .iter() - .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); - is_root.then_some(snapshot) - }) - .collect() + project.repositories(cx).values().filter_map(move |repo| { + let snapshot = repo.read(cx).snapshot(); + let is_root = path_list + .paths() + .iter() + .any(|p| p.as_path() == snapshot.work_directory_abs_path.as_ref()); + is_root.then_some(snapshot) + }) } fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { @@ -544,59 +540,6 @@ impl Sidebar { result } - fn all_thread_infos_for_workspace( - workspace: &Entity, - cx: &App, - ) -> Vec { - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return Vec::new(); - }; - let agent_panel_ref = agent_panel.read(cx); - - agent_panel_ref - .parent_threads(cx) - .into_iter() - .map(|thread_view| { - let thread_view_ref = thread_view.read(cx); - let thread = thread_view_ref.thread.read(cx); - - let icon = thread_view_ref.agent_icon; - let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); - let title = thread - .title() - .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()); - let is_native = thread_view_ref.as_native_thread(cx).is_some(); - let is_title_generating = is_native && thread.has_provisional_title(); - let session_id = thread.session_id().clone(); - let is_background = agent_panel_ref.is_background_thread(&session_id); - - let status = if thread.is_waiting_for_confirmation() { - AgentThreadStatus::WaitingForConfirmation - } else if thread.had_error() { - AgentThreadStatus::Error - } else { - match thread.status() { - ThreadStatus::Generating => AgentThreadStatus::Running, - ThreadStatus::Idle => AgentThreadStatus::Completed, - } - }; - - let diff_stats = thread.action_log().read(cx).diff_stats(cx); - - ActiveThreadInfo { - session_id, - title, - status, - icon, - icon_from_external_svg, - is_background, - is_title_generating, - diff_stats, - } - }) - .collect() - } - /// When modifying this thread, aim for a single forward pass over workspaces /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data. fn rebuild_contents(&mut self, cx: &App) { @@ -686,7 +629,7 @@ impl Sidebar { for (i, workspace) in workspaces.iter().enumerate() { for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.work_directory_abs_path == snapshot.original_repo_abs_path { + if snapshot.is_main_worktree() { main_repo_workspace .entry(snapshot.work_directory_abs_path.clone()) .or_insert(i); @@ -773,7 +716,7 @@ impl Sidebar { .is_some_and(|(main_idx, _)| *main_idx == ws_index) }); - let mut live_infos = Self::all_thread_infos_for_workspace(workspace, cx); + let mut live_infos: Vec<_> = all_thread_infos_for_workspace(workspace, cx).collect(); let mut threads: Vec = Vec::new(); let mut has_running_threads = false; @@ -831,7 +774,7 @@ impl Sidebar { let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = Vec::new(); for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + if snapshot.is_linked_worktree() { continue; } @@ -852,17 +795,16 @@ impl Sidebar { for (worktree_path_list, worktree_name, worktree_path) in &linked_worktree_queries { - let target_workspace = - match absorbed_workspace_by_path.get(worktree_path.as_ref()) { - Some(&idx) => { - live_infos.extend(Self::all_thread_infos_for_workspace( - &workspaces[idx], - cx, - )); - ThreadEntryWorkspace::Open(workspaces[idx].clone()) - } - None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), - }; + let target_workspace = match absorbed_workspace_by_path + .get(worktree_path.as_ref()) + { + Some(&idx) => { + live_infos + .extend(all_thread_infos_for_workspace(&workspaces[idx], cx)); + ThreadEntryWorkspace::Open(workspaces[idx].clone()) + } + None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), + }; let worktree_rows: Vec<_> = thread_store .read(cx) @@ -1721,7 +1663,7 @@ impl Sidebar { let mut known_worktree_paths: HashSet = HashSet::new(); for workspace in &workspaces { for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.work_directory_abs_path != snapshot.original_repo_abs_path { + if snapshot.is_linked_worktree() { continue; } for git_worktree in snapshot.linked_worktrees() { @@ -1740,12 +1682,10 @@ impl Sidebar { if path_list.paths().len() != 1 { continue; } - let should_prune = root_repository_snapshots(workspace, cx) - .iter() - .any(|snapshot| { - snapshot.work_directory_abs_path != snapshot.original_repo_abs_path - && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) - }); + let should_prune = root_repository_snapshots(workspace, cx).any(|snapshot| { + snapshot.is_linked_worktree() + && !known_worktree_paths.contains(snapshot.work_directory_abs_path.as_ref()) + }); if should_prune { to_remove.push(workspace.clone()); } @@ -3217,6 +3157,76 @@ impl Render for Sidebar { } } +fn all_thread_infos_for_workspace( + workspace: &Entity, + cx: &App, +) -> impl Iterator { + enum ThreadInfoIterator> { + Empty, + Threads(T), + } + + impl> Iterator for ThreadInfoIterator { + type Item = ActiveThreadInfo; + + fn next(&mut self) -> Option { + match self { + ThreadInfoIterator::Empty => None, + ThreadInfoIterator::Threads(threads) => threads.next(), + } + } + } + + let Some(agent_panel) = workspace.read(cx).panel::(cx) else { + return ThreadInfoIterator::Empty; + }; + let agent_panel = agent_panel.read(cx); + + let threads = agent_panel + .parent_threads(cx) + .into_iter() + .map(|thread_view| { + let thread_view_ref = thread_view.read(cx); + let thread = thread_view_ref.thread.read(cx); + + let icon = thread_view_ref.agent_icon; + let icon_from_external_svg = thread_view_ref.agent_icon_from_external_svg.clone(); + let title = thread + .title() + .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into()); + let is_native = thread_view_ref.as_native_thread(cx).is_some(); + let is_title_generating = is_native && thread.has_provisional_title(); + let session_id = thread.session_id().clone(); + let is_background = agent_panel.is_background_thread(&session_id); + + let status = if thread.is_waiting_for_confirmation() { + AgentThreadStatus::WaitingForConfirmation + } else if thread.had_error() { + AgentThreadStatus::Error + } else { + match thread.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + } + }; + + let diff_stats = thread.action_log().read(cx).diff_stats(cx); + + ActiveThreadInfo { + session_id, + title, + status, + icon, + icon_from_external_svg, + is_background, + is_title_generating, + diff_stats, + } + }); + + ThreadInfoIterator::Threads(threads) +} + #[cfg(test)] mod tests { use super::*; From 7be004cf895b55d01c5cf7a7c7e0ae79fa7e3f38 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 25 Mar 2026 19:07:34 +0100 Subject: [PATCH 11/45] Remove some outdated comments (#52432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These comments come from the scaffolding script for new crates, yet are no longer relevant in the given files. Thus, just removing them here to do some 🧹 Release Notes: - N/A --- crates/language_onboarding/Cargo.toml | 6 ------ crates/settings_content/Cargo.toml | 6 ------ crates/settings_json/Cargo.toml | 6 ------ 3 files changed, 18 deletions(-) diff --git a/crates/language_onboarding/Cargo.toml b/crates/language_onboarding/Cargo.toml index 38cf8a604a87f403e2d2720be6a2ba69a61e7484..1ab0a75fc3f726de5ec81c18f5b7ae5c136caeea 100644 --- a/crates/language_onboarding/Cargo.toml +++ b/crates/language_onboarding/Cargo.toml @@ -21,9 +21,3 @@ gpui.workspace = true project.workspace = true ui.workspace = true workspace.workspace = true - -# Uncomment other workspace dependencies as needed -# assistant.workspace = true -# client.workspace = true -# project.workspace = true -# settings.workspace = true diff --git a/crates/settings_content/Cargo.toml b/crates/settings_content/Cargo.toml index 1908e6623be5766c1ab8b8a9bb91c67906e7b76c..b3599e9eef3b7ac5680f441369a7cbdc98a5d043 100644 --- a/crates/settings_content/Cargo.toml +++ b/crates/settings_content/Cargo.toml @@ -28,9 +28,3 @@ settings_json.workspace = true settings_macros.workspace = true strum.workspace = true util.workspace = true - -# Uncomment other workspace dependencies as needed -# assistant.workspace = true -# client.workspace = true -# project.workspace = true -# settings.workspace = true diff --git a/crates/settings_json/Cargo.toml b/crates/settings_json/Cargo.toml index 2ba9887ca016b645bafa2974bbd9029373348838..aeaf5ec3c16a9c0d0fc6e9be047fb33a4ab74373 100644 --- a/crates/settings_json/Cargo.toml +++ b/crates/settings_json/Cargo.toml @@ -27,9 +27,3 @@ serde_path_to_error.workspace = true [dev-dependencies] unindent.workspace = true pretty_assertions.workspace = true - -# Uncomment other workspace dependencies as needed -# assistant.workspace = true -# client.workspace = true -# project.workspace = true -# settings.workspace = true From 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 25 Mar 2026 19:27:17 +0100 Subject: [PATCH 12/45] ci: Bump runner size for extension CLI publishing job (#52433) As of https://github.com/zed-industries/zed/pull/50750, the runner size for building the extension CLI is no longer sufficient due to the addition of the `settings_content` crate as a dependency. The [most recent workflow run](https://github.com/zed-industries/zed/actions/runs/23554506504/job/68577404831) fails while building that crate due to insufficient RAM availlable during the build. Removing the dependency is not an option, as we do want to have the validation here and need structs from the settings content crate to do so. Thus, changing the job here to a larger runner to fix the build and also build it faster. Release Notes: - N/A --- .github/workflows/publish_extension_cli.yml | 2 +- tooling/xtask/src/tasks/workflows/publish_extension_cli.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index 75f1b16b007e33d0c4f346a33a1403648f1cd6c6..254800329f3dfbe04664853dca9617778a5930d1 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -11,7 +11,7 @@ on: jobs: publish_job: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') - runs-on: namespace-profile-2x4-ubuntu-2404 + runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 diff --git a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs index 2269201a2de383bc5ae7147d9e1d08105c540d15..0f9bf521247522d0ee68ca32d116e73048bee1a7 100644 --- a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs +++ b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs @@ -42,7 +42,7 @@ fn publish_job() -> NamedJob { named::job( Job::default() .with_repository_owner_guard() - .runs_on(runners::LINUX_SMALL) + .runs_on(runners::LINUX_DEFAULT) .add_step(steps::checkout_repo()) .add_step(steps::cache_rust_dependencies_namespace()) .add_step(steps::setup_linux()) From f84af034bad21401b453fd686fecfe343d31dc72 Mon Sep 17 00:00:00 2001 From: Hilder Santos Date: Wed, 25 Mar 2026 15:41:37 -0300 Subject: [PATCH 13/45] Remove search_on_input from docs (#52435) ## Context CleanShot 2026-03-25 at 15 32 24@2x Although search_on_input is a merged feature, it doesn't look like it's live yet. As a result, some people might be trying to activate the feature without success. This commit removes its reference from the docs. ## How to Review - Check if the feature is live before merging ## Self-Review Checklist - [X] I've reviewed my own diff for quality, security, and reliability - [X] Unsafe blocks (if any) have justifying comments - [X] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [X] Tests cover the new/changed behavior - [X] Performance impact has been considered and is acceptable Release Notes: - N/A --- docs/src/finding-navigating.md | 8 -------- docs/src/reference/all-settings.md | 6 ------ 2 files changed, 14 deletions(-) diff --git a/docs/src/finding-navigating.md b/docs/src/finding-navigating.md index f1d3536f8c909f18240f83eac6f4309159b764e1..af30b0ee71554012c2292092f4d7694784ff14cd 100644 --- a/docs/src/finding-navigating.md +++ b/docs/src/finding-navigating.md @@ -23,14 +23,6 @@ Search across all files with {#kb pane::DeploySearch}. Start typing in the searc Results appear in a [multibuffer](./multibuffers.md), letting you edit matches in place. -To disable automatic search and require pressing Enter instead, open the Settings Editor ({#kb zed::OpenSettings}), search for "search on input", and toggle the setting off. Or add this to your settings.json: - -```json -{ - "search_on_input": false -} -``` - ## Go to Definition Jump to where a symbol is defined with {#kb editor::GoToDefinition} (or `Cmd+Click` / `Ctrl+Click`). If there are multiple definitions, they open in a multibuffer. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 7427a1ceb2038cbb2af26e3db7c096d2aaab2bcd..ce80fe78f4734135bd6bba0f3329a651059dbfdf 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3462,12 +3462,6 @@ Non-negative `integer` values - Setting: `regex` - Default: `false` -### Search On Input - -- Description: Whether to search on input in project search. -- Setting: `search_on_input` -- Default: `true` - ### Center On Match - Description: Whether to center the cursor on each search match when navigating. From 3183c04515a489e394dca8a254304e96dacec62a Mon Sep 17 00:00:00 2001 From: "zed-zippy[bot]" <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:50:16 +0100 Subject: [PATCH 14/45] extension_ci: Bump extension CLI version to `1fa7f1a` (#52437) This PR bumps the extension CLI version used in the extension workflows to `1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7`. Release Notes: - N/A Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- .github/workflows/extension_bump.yml | 2 +- .github/workflows/extension_tests.yml | 2 +- tooling/xtask/src/tasks/workflows/extension_tests.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index cbe38ee9e5b958eeee80eb5576c93896cc6763e1..f19f64bc61b7d3cf67f8ca44002be3d50d26f0af 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 03d8e9aee95ea6117d75a48bcac2e19241f6e667 + ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 on: workflow_call: inputs: diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 89668c028a6d1fa4baddd417687226dd55a52426..5323967dd534c8ce81a1125c2e1af4e34564435c 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -5,7 +5,7 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' CARGO_INCREMENTAL: '0' - ZED_EXTENSION_CLI_SHA: 03d8e9aee95ea6117d75a48bcac2e19241f6e667 + ZED_EXTENSION_CLI_SHA: 1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7 RUSTUP_TOOLCHAIN: stable CARGO_BUILD_TARGET: wasm32-wasip2 on: diff --git a/tooling/xtask/src/tasks/workflows/extension_tests.rs b/tooling/xtask/src/tasks/workflows/extension_tests.rs index caf57ce130f7d7e9f0018ef20d4cf4892823f4ab..d724afc1353b0aa9205706c5f23eb0d0ee8e96c9 100644 --- a/tooling/xtask/src/tasks/workflows/extension_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extension_tests.rs @@ -12,7 +12,7 @@ use crate::tasks::workflows::{ vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token}, }; -pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667"; +pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7"; // This should follow the set target in crates/extension/src/extension_builder.rs const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2"; From 5f1fe654b26fdac4560840354a5d5cb129ac9f92 Mon Sep 17 00:00:00 2001 From: AltCode Date: Wed, 25 Mar 2026 21:19:30 +0100 Subject: [PATCH 15/45] docs: Rework Elixir docs (#52421) ## Context - Mention support for EEx templates - Rearranges a few sections (HEEx, `elixir-ls` workspace configuration) - Corrects naming of HEEx and mention that language server configurations also need to be applied to it (and EEx) - Removes unnecessary instructions for how to install `elixir-ls` - the extension has been able to do this on its own for a long time; additionally, these instructions were very macOS-specific - Demonstrates how to pass initialization options to `next-ls` and workspace configuration options to `expert` - Rewords the formatting with Mix section - all language servers already use Mix for code formatting, so these instructions are only useful if you work _without_ a language server - Adjust Tailwind LSP configuration instructions ## How to Review Check for typos and that the wording/structure of the new/reworded sections is also good. Release Notes: - N/A --------- Co-authored-by: Finn Evers --- docs/src/languages/elixir.md | 206 +++++++++++++++++++++--------- docs/src/languages/tailwindcss.md | 2 +- 2 files changed, 144 insertions(+), 64 deletions(-) diff --git a/docs/src/languages/elixir.md b/docs/src/languages/elixir.md index e046e5bb0d31b8dcc8b50b32cf876cd1eb11069f..d592df1805a04df2bc88d0802e03fac2c75883f1 100644 --- a/docs/src/languages/elixir.md +++ b/docs/src/languages/elixir.md @@ -7,94 +7,175 @@ description: "Configure Elixir language support in Zed, including language serve Elixir support is available through the [Elixir extension](https://github.com/zed-extensions/elixir). -- Tree-sitter: +- Tree-sitter Grammars: - [elixir-lang/tree-sitter-elixir](https://github.com/elixir-lang/tree-sitter-elixir) - [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex) -- Language servers: +- Language Servers: - [elixir-lang/expert](https://github.com/elixir-lang/expert) - [elixir-lsp/elixir-ls](https://github.com/elixir-lsp/elixir-ls) - [elixir-tools/next-ls](https://github.com/elixir-tools/next-ls) - [lexical-lsp/lexical](https://github.com/lexical-lsp/lexical) -## Choosing a language server +Furthermore, the extension provides support for [EEx](https://hexdocs.pm/eex/EEx.html) (Embedded Elixir) templates and [HEEx](https://hexdocs.pm/phoenix/components.html#heex) templates, a mix of HTML and EEx used by Phoenix LiveView applications. -The Elixir extension offers language server support for `expert`, `elixir-ls`, `next-ls`, and `lexical`. +## Language Servers -`elixir-ls` is enabled by default. +The Elixir extension offers language server support for ElixirLS, Expert, Next LS, and Lexical. By default, only ElixirLS is enabled. You can change or disable the enabled language servers in your settings ({#kb zed::OpenSettings}) under Languages > Elixir/EEx/HEEx or directly within your settings file. -### Expert +Some of the language servers can also accept initialization or workspace configuration options. See the sections below for an outline of what each server supports. The configuration can be passed in your settings file via `lsp.{language-server-id}.initialization_options` and `lsp.{language-server-id}.settings` respectively. -Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +Visit the [Configuring Zed](../configuring-zed.md#settings-files) guide for more information on how to edit your settings file. + +### Using ElixirLS + +ElixirLS can accept workspace configuration options. + +The following example disables [Dialyzer](https://github.com/elixir-lsp/elixir-ls#dialyzer-integration): + +```json [settings] + "lsp": { + "elixir-ls": { + "settings": { + "dialyzerEnabled": false + } + } + } +``` + +See the official list of [ElixirLS configuration settings](https://github.com/elixir-lsp/elixir-ls#elixirls-configuration-settings) for all available options. + +### Using Expert + +Enable Expert by adding the following to your settings file: ```json [settings] "languages": { "Elixir": { "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."] }, - "HEEX": { + "EEx": { "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."] + }, + "HEEx": { + "language_servers": ["expert", "!elixir-ls", "!next-ls", "!lexical", "..."] + } + } +``` + +Expert can accept workspace configuration options. + +The following example sets the minimum number of characters required for a project symbol search to return results: + +```json [settings] + "lsp": { + "expert": { + "settings": { + "workspaceSymbols": { + "minQueryLength": 0 + } + } } } ``` -### Next LS +See the [Expert configuration](https://expert-lsp.org/docs/configuration/) page for all available options. -Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +### Using Next LS + +Enable Next LS by adding the following to your settings file: ```json [settings] "languages": { "Elixir": { "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."] }, - "HEEX": { + "EEx": { + "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."] + }, + "HEEx": { "language_servers": ["next-ls", "!expert", "!elixir-ls", "!lexical", "..."] } } ``` -### Lexical +Next LS can accept initialization options. -Configure language servers in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +Completions are an experimental feature within Next LS, they are enabled by default in Zed. Disable them by adding the following to your settings file: ```json [settings] - "languages": { - "Elixir": { - "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] - }, - "HEEX": { - "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + "lsp": { + "next-ls": { + "initialization_options": { + "experimental": { + "completions": { + "enable": false + } + } + } } } ``` -## Setting up `elixir-ls` +Next LS also has an extension for [Credo](https://hexdocs.pm/credo/overview.html) integration which is enabled by default. You can disable this by adding the following section to your settings file: -1. Install `elixir`: - -```sh -brew install elixir +```json [settings] + "lsp": { + "next-ls": { + "initialization_options": { + "extensions": { + "credo": { + "enable": false + } + } + } + } + } ``` -2. Install `elixir-ls`: +Next LS can also pass CLI options directly to Credo. The following example passes `--min-priority high` to it: -```sh -brew install elixir-ls +```json [settings] + "lsp": { + "next-ls": { + "initialization_options": { + "extensions": { + "credo": { + "cli_options": ["--min-priority high"] + } + } + } + } + } ``` -3. Restart Zed +See the [Credo Command Line Switches](https://hexdocs.pm/credo/suggest_command.html#command-line-switches) page for more CLI options. -> If `elixir-ls` is not running in an elixir project, check the error log via the command palette action `zed: open log`. If you find an error message mentioning: `invalid LSP message header "Shall I install Hex? (if running non-interactively, use \"mix local.hex --force\") [Yn]`, you might need to install [`Hex`](https://hex.pm). You run `elixir-ls` from the command line and accept the prompt to install `Hex`. +### Using Lexical -### Formatting with Mix +Enable Lexical by adding the following to your settings file: -If you prefer to format your code with [Mix](https://hexdocs.pm/mix/Mix.html), configure it as an external formatter. Formatting will occur on file save. +```json [settings] + "languages": { + "Elixir": { + "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + }, + "EEx": { + "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + }, + "HEEx": { + "language_servers": ["lexical", "!expert", "!elixir-ls", "!next-ls", "..."] + } + } +``` + +## Formatting without a language server -Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > Elixir, or add to your settings file: +If you prefer to work without a language server but would still like code formatting from [Mix](https://hexdocs.pm/mix/Mix.html), you can configure it as an external formatter by adding the following to your settings file: ```json [settings] -{ "languages": { "Elixir": { + "enable_language_server": false, "format_on_save": "on", "formatter": { "external": { @@ -102,46 +183,41 @@ Configure formatting in Settings ({#kb zed::OpenSettings}) under Languages > Eli "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"] } } - } - } -} -``` - -### Additional workspace configuration options - -You can pass additional elixir-ls workspace configuration options via `lsp` settings in your settings file ([how to edit](../configuring-zed.md#settings-files)). - -The following example disables dialyzer: - -```json [settings] - "lsp": { - "elixir-ls": { - "settings": { - "dialyzerEnabled": false + }, + "EEx": { + "enable_language_server": false, + "format_on_save": "on", + "formatter": { + "external": { + "command": "mix", + "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"] + } + } + }, + "HEEx": { + "enable_language_server": false, + "format_on_save": "on", + "formatter": { + "external": { + "command": "mix", + "arguments": ["format", "--stdin-filename", "{buffer_path}", "-"] + } } } } ``` -See [ElixirLS configuration settings](https://github.com/elixir-lsp/elixir-ls#elixirls-configuration-settings) for more options. +## Using the Tailwind CSS Language Server with HEEx templates -### HEEx - -Zed also supports HEEx templates. HEEx is a mix of [EEx](https://hexdocs.pm/eex/1.12.3/EEx.html) (Embedded Elixir) and HTML, and is used in Phoenix LiveView applications. - -- Tree-sitter: [phoenixframework/tree-sitter-heex](https://github.com/phoenixframework/tree-sitter-heex) - -#### Using the Tailwind CSS Language Server with HEEx - -To get all features (autocomplete, linting, and hover docs) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in HEEx files, add the following to your settings file ([how to edit](../configuring-zed.md#settings-files)): +To get all features (autocomplete, linting, and hover docs) from the [Tailwind CSS language server](https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme) in HEEx templates, add the following to your settings file: ```json [settings] -{ "lsp": { "tailwindcss-language-server": { "settings": { "includeLanguages": { - "phoenix-heex": "html" + "elixir": "html", + "heex": "html" }, "experimental": { "classRegex": ["class=\"([^\"]*)\"", "class='([^']*)'"] @@ -149,10 +225,9 @@ To get all features (autocomplete, linting, and hover docs) from the [Tailwind C } } } -} ``` -With these settings, you will get completions for Tailwind CSS classes in HEEx template files. Examples: +With these settings, you will get completions for Tailwind CSS classes in HEEx templates. Examples: ```heex <%!-- Standard class attribute --%> @@ -170,3 +245,8 @@ With these settings, you will get completions for Tailwind CSS classes in HEEx t Content ``` + +## See also + +- [Erlang](./erlang.md) +- [Gleam](./gleam.md) diff --git a/docs/src/languages/tailwindcss.md b/docs/src/languages/tailwindcss.md index db0eb1b4d255474ed671ab16ba9f6d235372efa6..e461aa2d7fa53d2e2d19e0af985a4830e8f477d1 100644 --- a/docs/src/languages/tailwindcss.md +++ b/docs/src/languages/tailwindcss.md @@ -15,7 +15,7 @@ Languages which can be used with Tailwind CSS in Zed: - [CSS](./css.md) - [ERB](./ruby.md#using-the-tailwind-css-language-server-with-ruby) - [Gleam](./gleam.md) -- [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex) +- [HEEx](./elixir.md#using-the-tailwind-css-language-server-with-heex-templates) - [HTML](./html.md#using-the-tailwind-css-language-server-with-html) - [TypeScript](./typescript.md#using-the-tailwind-css-language-server-with-typescript) - [JavaScript](./javascript.md#using-the-tailwind-css-language-server-with-javascript) From e061fbaebc84ae7c3b6777c1782bf4cc454a5254 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:46:46 +0100 Subject: [PATCH 16/45] call: Update call location when active multi workspace changes (#52441) ## Context This fixes a participant location out of sync bug when the active workspace changes in a multi workspace. It wouldn't update the project id, which breaks following. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- crates/call/src/call_impl/mod.rs | 34 ++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/call/src/call_impl/mod.rs b/crates/call/src/call_impl/mod.rs index e060ec5edae6277a92c2c09ab54ded449bc56e11..b4bad6d2f350c3caa03eccbb8ca6582a71c6128c 100644 --- a/crates/call/src/call_impl/mod.rs +++ b/crates/call/src/call_impl/mod.rs @@ -17,8 +17,8 @@ use room::Event; use settings::Settings; use std::sync::Arc; use workspace::{ - ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, Pane, RemoteCollaborator, SharedScreen, - Workspace, + ActiveCallEvent, AnyActiveCall, GlobalAnyActiveCall, MultiWorkspace, MultiWorkspaceEvent, Pane, + RemoteCollaborator, SharedScreen, Workspace, }; pub use livekit_client::{RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent}; @@ -28,6 +28,36 @@ use crate::call_settings::CallSettings; pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let active_call = cx.new(|cx| ActiveCall::new(client, user_store, cx)); + let active_call_handle = active_call.downgrade(); + + cx.observe_new(move |_multi_workspace: &mut MultiWorkspace, window, cx| { + let Some(window) = window else { + return; + }; + + let active_call_handle = active_call_handle.clone(); + cx.subscribe_in( + &cx.entity(), + window, + move |multi_workspace, _, event: &MultiWorkspaceEvent, window, cx| { + if !matches!(event, MultiWorkspaceEvent::ActiveWorkspaceChanged) + && window.is_window_active() + { + return; + } + + let project = multi_workspace.workspace().read(cx).project().clone(); + if let Ok(task) = active_call_handle.update(cx, |active_call, cx| { + active_call.set_location(Some(&project), cx) + }) { + task.detach_and_log_err(cx); + } + }, + ) + .detach(); + }) + .detach(); + cx.set_global(GlobalAnyActiveCall(Arc::new(ActiveCallEntity(active_call)))) } From 831595098279225abf1087604399376418e79bf5 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:19:41 -0300 Subject: [PATCH 17/45] sidebar: Use last used agent when creating a new thread from it (#52446) The "new thread" buttons as well as the `cmd-n` keybinding were resetting the agent to the Zed one instead of following the agent panel's behavior to use whatever agent was recently selected, as per the key-value store. Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index aa88773680faee1dd7b8ceb0d60f93ecc13016c7..fcc141c85db6a8698b17f2b97336c11b6e67bf34 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1179,7 +1179,8 @@ impl AgentPanel { } pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { - self.new_agent_thread(AgentType::NativeAgent, window, cx); + self.reset_start_thread_in_to_default(cx); + self.external_thread(None, None, None, None, None, true, window, cx); } fn new_native_agent_thread_from_summary( From 0a4dfe327a999b31350e8f35c6687bc6b3032b3f Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:23:45 -0300 Subject: [PATCH 18/45] sidebar: Fix space not working in the search editor (#52444) We were using space as an alternative to `enter` for selecting thread items even while the search editor was focused. The solution here was to create a dynamic key context based on the search editor focus state. Release Notes: - N/A --- assets/keymaps/default-linux.json | 8 +++++++- assets/keymaps/default-macos.json | 8 +++++++- assets/keymaps/default-windows.json | 8 +++++++- crates/sidebar/src/sidebar.rs | 21 ++++++++++++++++++--- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 10c78ffa1660d81e86b7b2614770c5390bb819a6..412bec85625412089b2435e46573c1cf40c50b4f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -698,12 +698,18 @@ "left": "menu::SelectParent", "right": "menu::SelectChild", "enter": "menu::Confirm", - "space": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", }, }, + { + "context": "ThreadsSidebar && not_searching", + "use_key_equivalents": true, + "bindings": { + "space": "menu::Confirm", + }, + }, { "context": "Workspace && debugger_running", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 1095c4b82316bf8debac010a9954a962c495ee28..5741c5a9af5517533c214f0f77050aa2faf1a669 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -764,12 +764,18 @@ "left": "menu::SelectParent", "right": "menu::SelectChild", "enter": "menu::Confirm", - "space": "menu::Confirm", "cmd-f": "agents_sidebar::FocusSidebarFilter", "cmd-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", }, }, + { + "context": "ThreadsSidebar && not_searching", + "use_key_equivalents": true, + "bindings": { + "space": "menu::Confirm", + }, + }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 02816c1adf48355b9ffada14608e248b29ab9270..d94cbfdac16b5a86c380c158fae9f467abd5d202 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -700,12 +700,18 @@ "left": "menu::SelectParent", "right": "menu::SelectChild", "enter": "menu::Confirm", - "space": "menu::Confirm", "ctrl-f": "agents_sidebar::FocusSidebarFilter", "ctrl-g": "agents_sidebar::ToggleArchive", "shift-backspace": "agent::RemoveSelectedThread", }, }, + { + "context": "ThreadsSidebar && not_searching", + "use_key_equivalents": true, + "bindings": { + "space": "menu::Confirm", + }, + }, { "context": "ApplicationMenu", "use_key_equivalents": true, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index dfde1f7454178fdd383f5fbf5e3a7e65548d595d..252b306ce0af157954971c986499b372f2a2290f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -12,8 +12,8 @@ use chrono::Utc; use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ - Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, ListState, Pixels, - Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px, + Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState, + Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, list, prelude::*, px, }; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, @@ -1732,6 +1732,21 @@ impl Sidebar { self.update_entries(cx); } + fn dispatch_context(&self, window: &Window, cx: &Context) -> KeyContext { + let mut dispatch_context = KeyContext::new_with_defaults(); + dispatch_context.add("ThreadsSidebar"); + dispatch_context.add("menu"); + + let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) { + "searching" + } else { + "not_searching" + }; + + dispatch_context.add(identifier); + dispatch_context + } + fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { if !self.focus_handle.is_focused(window) { return; @@ -3067,7 +3082,7 @@ impl Render for Sidebar { v_flex() .id("workspace-sidebar") - .key_context("ThreadsSidebar") + .key_context(self.dispatch_context(window, cx)) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) From ce0848af5ceb82f46279e688f25de6528677c651 Mon Sep 17 00:00:00 2001 From: Finn Eitreim <48069764+feitreim@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:29:40 -0400 Subject: [PATCH 19/45] gpui: Fix bottom-aligned scroll bar disappearing (#51223) Closes #51198 This actually doesn't effect any of the scrollbars in Zed, as they have a separate handler that prevents this issue from occurring in `crates/ui/src/components/scrollbar.rs`, line 856 ```rust let current_offset = current_offset .along(axis) .clamp(-max_offset, Pixels::ZERO) .abs(); ``` so it is gpui specific. I still added a test case and I have a manual test script:
Details

```rust //! Reproduction of the scrollbar-offset bug in bottom-aligned `ListState`. //! //! Run with: cargo run -p gpui --example list_bottom_scrollbar_bug //! //! The list starts pinned to the bottom. Before the fix, the red scrollbar //! thumb was pushed off the bottom of the track (invisible). use gpui::{ App, Bounds, Context, ListAlignment, ListState, Window, WindowBounds, WindowOptions, div, list, prelude::*, px, rgb, size, }; use gpui_platform::application; const ITEM_COUNT: usize = 40; const COLORS: [u32; 4] = [0xE8F0FE, 0xFCE8E6, 0xE6F4EA, 0xFEF7E0]; struct BugRepro { list_state: ListState, } impl BugRepro { fn new() -> Self { let list_state = ListState::new(ITEM_COUNT, ListAlignment::Bottom, px(5000.)); Self { list_state } } } impl Render for BugRepro { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { let state = &self.list_state; let max_offset = state.max_offset_for_scrollbar().y; let raw_offset = -state.scroll_px_offset_for_scrollbar().y; let viewport_h = state.viewport_bounds().size.height; let content_h = max_offset + viewport_h; let thumb_h = if content_h > px(0.) { ((viewport_h / content_h) * viewport_h).max(px(20.)) } else { viewport_h }; let thumb_top = if max_offset > px(0.) { (raw_offset / max_offset) * (viewport_h - thumb_h) } else { px(0.) }; div() .size_full() .flex() .flex_row() .bg(rgb(0xffffff)) .text_color(rgb(0x333333)) .text_xl() .child( div().flex_1().h_full().child( list(state.clone(), |ix, _window, _cx| { let height = if ix % 4 == 0 { px(70.) } else { px(40.) }; let bg = COLORS[ix % COLORS.len()]; div() .h(height) .w_full() .bg(rgb(bg)) .border_b_1() .border_color(rgb(0xcccccc)) .px_2() .flex() .items_center() .child(format!("Item {ix}")) .into_any() }) .h_full() .w_full(), ), ) .child( div() .w(px(14.)) .h_full() .bg(rgb(0xe0e0e0)) .relative() .child( div() .absolute() .right(px(0.)) .top(thumb_top) .h(thumb_h) .w(px(14.)) .rounded_sm() .bg(rgb(0xff3333)), ), ) } } fn main() { application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), ..Default::default() }, |_, cx| cx.new(|_| BugRepro::new()), ) .unwrap(); cx.activate(true); }); } ```

where I was able to test it out, here is a video of the new working behavior. https://github.com/user-attachments/assets/02e26308-da18-418b-97fc-dd52a3325dab Release Notes: - gpui: fixed a bug where the scollbar would disappear when using a bottom aligned list. --------- Co-authored-by: Mikayla Maki --- crates/gpui/Cargo.toml | 4 + crates/gpui/examples/list_example.rs | 170 +++++++++++++++++++++++++++ crates/gpui/src/elements/list.rs | 63 ++++++++-- 3 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 crates/gpui/examples/list_example.rs diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index b3812bb7cb5747ff40bd6d05a39b9ee7bebbdda1..9eb2de936c2e1db1d80cc3627db5594152e7223e 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -235,6 +235,10 @@ path = "examples/window_shadow.rs" name = "grid_layout" path = "examples/grid_layout.rs" +[[example]] +name = "list_example" +path = "examples/list_example.rs" + [[example]] name = "mouse_pressure" path = "examples/mouse_pressure.rs" diff --git a/crates/gpui/examples/list_example.rs b/crates/gpui/examples/list_example.rs new file mode 100644 index 0000000000000000000000000000000000000000..7aeff7c24ec3755edf1e37f5ff1cc496c9fb597e --- /dev/null +++ b/crates/gpui/examples/list_example.rs @@ -0,0 +1,170 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, ListAlignment, ListState, Render, Window, WindowBounds, WindowOptions, + div, list, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +const ITEM_COUNT: usize = 40; +const SCROLLBAR_WIDTH: f32 = 12.; + +struct BottomListDemo { + list_state: ListState, +} + +impl BottomListDemo { + fn new() -> Self { + Self { + list_state: ListState::new(ITEM_COUNT, ListAlignment::Bottom, px(500.)).measure_all(), + } + } +} + +impl Render for BottomListDemo { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let max_offset = self.list_state.max_offset_for_scrollbar().y; + let current_offset = -self.list_state.scroll_px_offset_for_scrollbar().y; + + let viewport_height = self.list_state.viewport_bounds().size.height; + + let raw_fraction = if max_offset > px(0.) { + current_offset / max_offset + } else { + 0. + }; + + let total_height = viewport_height + max_offset; + let thumb_height = if total_height > px(0.) { + px(viewport_height.as_f32() * viewport_height.as_f32() / total_height.as_f32()) + .max(px(30.)) + } else { + px(30.) + }; + + let track_space = viewport_height - thumb_height; + let thumb_top = track_space * raw_fraction; + + let bug_detected = raw_fraction > 1.0; + + div() + .size_full() + .bg(rgb(0xFFFFFF)) + .flex() + .flex_col() + .p_4() + .gap_2() + .child( + div() + .text_sm() + .flex() + .flex_col() + .gap_1() + .child(format!( + "offset: {:.0} / max: {:.0} | fraction: {:.3}", + current_offset.as_f32(), + max_offset.as_f32(), + raw_fraction, + )) + .child( + div() + .text_color(if bug_detected { + rgb(0xCC0000) + } else { + rgb(0x008800) + }) + .child(if bug_detected { + format!( + "BUG: fraction is {:.3} (> 1.0) — thumb is off-track!", + raw_fraction + ) + } else { + "OK: fraction <= 1.0 — thumb is within track.".to_string() + }), + ), + ) + .child( + div() + .flex_1() + .flex() + .flex_row() + .overflow_hidden() + .border_1() + .border_color(rgb(0xCCCCCC)) + .rounded_sm() + .child( + list(self.list_state.clone(), |index, _window, _cx| { + let height = px(30. + (index % 5) as f32 * 10.); + div() + .h(height) + .w_full() + .flex() + .items_center() + .px_3() + .border_b_1() + .border_color(rgb(0xEEEEEE)) + .bg(if index % 2 == 0 { + rgb(0xFAFAFA) + } else { + rgb(0xFFFFFF) + }) + .text_sm() + .child(format!("Item {index}")) + .into_any() + }) + .flex_1(), + ) + // Scrollbar track + .child( + div() + .w(px(SCROLLBAR_WIDTH)) + .h_full() + .flex_shrink_0() + .bg(rgb(0xE0E0E0)) + .relative() + .child( + // Thumb — position is unclamped to expose the bug + div() + .absolute() + .top(thumb_top) + .w_full() + .h(thumb_height) + .bg(if bug_detected { + rgb(0xCC0000) + } else { + rgb(0x888888) + }) + .rounded_sm(), + ), + ), + ) + } +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx); + cx.open_window( + WindowOptions { + focus: true, + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| BottomListDemo::new()), + ) + .unwrap(); + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index b84241e9e0f79fe5cf8a24514cbf57982247a76b..578900085334baf27ab90ae77748fb7fd362e8ad 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -493,18 +493,17 @@ impl ListState { /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. pub fn max_offset_for_scrollbar(&self) -> Point { let state = self.0.borrow(); - let bounds = state.last_layout_bounds.unwrap_or_default(); - - let height = state - .scrollbar_drag_start_height - .unwrap_or_else(|| state.items.summary().height); - - point(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) + point(Pixels::ZERO, state.max_scroll_offset()) } /// Returns the current scroll offset adjusted for the scrollbar pub fn scroll_px_offset_for_scrollbar(&self) -> Point { let state = &self.0.borrow(); + + if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom { + return Point::new(px(0.), -state.max_scroll_offset()); + } + let logical_scroll_top = state.logical_scroll_top(); let mut cursor = state.items.cursor::(()); @@ -526,6 +525,14 @@ impl ListState { } impl StateInner { + fn max_scroll_offset(&self) -> Pixels { + let bounds = self.last_layout_bounds.unwrap_or_default(); + let height = self + .scrollbar_drag_start_height + .unwrap_or_else(|| self.items.summary().height); + (height - bounds.size.height).max(px(0.)) + } + fn visible_range( items: &SumTree, height: Pixels, @@ -1449,4 +1456,46 @@ mod test { assert_eq!(offset.item_ix, 2); assert_eq!(offset.offset_in_item, px(20.)); } + + #[gpui::test] + fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_SIZE: f32 = 50.0; + + let state = ListState::new( + ITEMS, + crate::ListAlignment::Bottom, + px(ITEMS as f32 * ITEM_SIZE), + ); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_SIZE)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + // Bottom-aligned lists start pinned to the end: logical_scroll_top returns + // item_ix == item_count, meaning no explicit scroll position has been set. + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + + let max_offset = state.max_offset_for_scrollbar(); + let scroll_offset = state.scroll_px_offset_for_scrollbar(); + + assert_eq!( + -scroll_offset.y, max_offset.y, + "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom", + -scroll_offset.y, max_offset.y, + ); + } } From 3684b5a42fad83266a9dcd87333f867c7d038ecf Mon Sep 17 00:00:00 2001 From: Finn Eitreim <48069764+feitreim@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:19:55 -0400 Subject: [PATCH 20/45] vim/helix: Use grapheme count on replace (#51776) Update vim and helix replace to repeat based on grapheme count instead of byte length or Unicode scalar count. This fixes cases where a single visible character is made up of multiple bytes or scalars, such as decomposed characters like `e\u{301}` and emoji. Closes #51772 Release Notes: - Fixed vim/helix's replace action to take into consideration grapheme count --------- Co-authored-by: dino --- Cargo.lock | 1 + crates/multi_buffer/Cargo.toml | 1 + crates/multi_buffer/src/multi_buffer.rs | 11 +++++ crates/vim/src/helix.rs | 56 ++++++++++++++----------- crates/vim/src/visual.rs | 22 +++++++++- 5 files changed, 65 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3b1e7b48ad76de494fb13f10391957ccc604816..1d36cd7198bab5a0f529d71904ef28ae1c915d5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10752,6 +10752,7 @@ dependencies = [ "theme", "tracing", "tree-sitter", + "unicode-segmentation", "util", "zlog", "ztracing", diff --git a/crates/multi_buffer/Cargo.toml b/crates/multi_buffer/Cargo.toml index 66c23101ab26ac6be58d482c752f366522bb9305..a06599999c8147dc464128ad8ab5e6bf5ad5755b 100644 --- a/crates/multi_buffer/Cargo.toml +++ b/crates/multi_buffer/Cargo.toml @@ -45,6 +45,7 @@ tree-sitter.workspace = true ztracing.workspace = true tracing.workspace = true util.workspace = true +unicode-segmentation.workspace = true [dev-dependencies] buffer_diff = { workspace = true, features = ["test-support"] } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index a1739629c6af498eccb6ea90b8c866e9cc7946b4..7b5f0135f57269b7c787031120f6eb22b0caf549 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -55,6 +55,7 @@ use text::{ subscription::{Subscription, Topic}, }; use theme::SyntaxTheme; +use unicode_segmentation::UnicodeSegmentation; use util::post_inc; use ztracing::instrument; @@ -7243,6 +7244,16 @@ impl MultiBufferSnapshot { } excerpt_edits } + + /// Returns the number of graphemes in `range`. + /// + /// This counts user-visible characters like `e\u{301}` as one. + pub fn grapheme_count_for_range(&self, range: &Range) -> usize { + self.text_for_range(range.clone()) + .collect::() + .graphemes(true) + .count() + } } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 0db3b5a3fe533f9e21503c6904ee0f62764003fb..56241275b5d8fa6de3645c6d00361b29dc49d259 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -711,38 +711,28 @@ impl Vim { let display_map = editor.display_snapshot(cx); let selections = editor.selections.all_display(&display_map); - // Store selection info for positioning after edit - let selection_info: Vec<_> = selections - .iter() - .map(|selection| { - let range = selection.range(); - let start_offset = range.start.to_offset(&display_map, Bias::Left); - let end_offset = range.end.to_offset(&display_map, Bias::Left); - let was_empty = range.is_empty(); - let was_reversed = selection.reversed; - ( - display_map.buffer_snapshot().anchor_before(start_offset), - end_offset - start_offset, - was_empty, - was_reversed, - ) - }) - .collect(); - let mut edits = Vec::new(); + let mut selection_info = Vec::new(); for selection in &selections { let mut range = selection.range(); + let was_empty = range.is_empty(); + let was_reversed = selection.reversed; - // For empty selections, extend to replace one character - if range.is_empty() { + if was_empty { range.end = movement::saturating_right(&display_map, range.start); } let byte_range = range.start.to_offset(&display_map, Bias::Left) ..range.end.to_offset(&display_map, Bias::Left); + let snapshot = display_map.buffer_snapshot(); + let grapheme_count = snapshot.grapheme_count_for_range(&byte_range); + let anchor = snapshot.anchor_before(byte_range.start); + + selection_info.push((anchor, grapheme_count, was_empty, was_reversed)); + if !byte_range.is_empty() { - let replacement_text = text.repeat(byte_range.end - byte_range.start); + let replacement_text = text.repeat(grapheme_count); edits.push((byte_range, replacement_text)); } } @@ -753,14 +743,12 @@ impl Vim { let snapshot = editor.buffer().read(cx).snapshot(cx); let ranges: Vec<_> = selection_info .into_iter() - .map(|(start_anchor, original_len, was_empty, was_reversed)| { + .map(|(start_anchor, grapheme_count, was_empty, was_reversed)| { let start_point = start_anchor.to_point(&snapshot); if was_empty { - // For cursor-only, collapse to start start_point..start_point } else { - // For selections, span the replaced text - let replacement_len = text.len() * original_len; + let replacement_len = text.len() * grapheme_count; let end_offset = start_anchor.to_offset(&snapshot) + replacement_len; let end_point = snapshot.offset_to_point(end_offset); if was_reversed { @@ -2375,4 +2363,22 @@ mod test { Mode::Insert, ); } + + #[gpui::test] + async fn test_helix_replace_uses_graphemes(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + cx.set_state("«Hällöˇ» Wörld", Mode::HelixNormal); + cx.simulate_keystrokes("r 1"); + cx.assert_state("«11111ˇ» Wörld", Mode::HelixNormal); + + cx.set_state("«e\u{301}ˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("r 1"); + cx.assert_state("«1ˇ»", Mode::HelixNormal); + + cx.set_state("«🙂ˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("r 1"); + cx.assert_state("«1ˇ»", Mode::HelixNormal); + } } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 502aa756b67889b1171464fde11be08ff0ccd508..bc53167b158d26717b1aa629b764a78dfe4c0ddc 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -788,7 +788,10 @@ impl Vim { { let range = row_range.start.to_offset(&display_map, Bias::Right) ..row_range.end.to_offset(&display_map, Bias::Right); - let text = text.repeat(range.end - range.start); + let grapheme_count = display_map + .buffer_snapshot() + .grapheme_count_for_range(&range); + let text = text.repeat(grapheme_count); edits.push((range, text)); } } @@ -2017,4 +2020,21 @@ mod test { // would depend on the key bindings configured, but the actions // are now available for use } + + #[gpui::test] + async fn test_visual_replace_uses_graphemes(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("«Hällöˇ» Wörld", Mode::Visual); + cx.simulate_keystrokes("r 1"); + cx.assert_state("ˇ11111 Wörld", Mode::Normal); + + cx.set_state("«e\u{301}ˇ»", Mode::Visual); + cx.simulate_keystrokes("r 1"); + cx.assert_state("ˇ1", Mode::Normal); + + cx.set_state("«🙂ˇ»", Mode::Visual); + cx.simulate_keystrokes("r 1"); + cx.assert_state("ˇ1", Mode::Normal); + } } From 3ce0cd11ec4dfb46c8feb2080c8e5a53a728f208 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Wed, 25 Mar 2026 17:41:09 -0600 Subject: [PATCH 21/45] Extract `language_core` and `grammars` crates from `language` (#52238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This extracts a `language_core` crate from the existing `language` crate, and creates a `grammars` data crate. The goal is to separate tree-sitter grammar infrastructure, language configuration, and LSP adapter types from the heavier buffer/editor integration layer in `language`. ## Motivation The `language` crate pulls in `text`, `theme`, `settings`, `rpc`, `task`, `fs`, `clock`, `sum_tree`, and `fuzzy` — all of which are needed for buffer integration (`Buffer`, `SyntaxMap`, `Outline`, `DiagnosticSet`) but not for grammar parsing or language configuration. Extracting the core types lets downstream consumers depend on `language_core` without pulling in the full integration stack. ## Dependency graph after extraction ``` language_core ← gpui, lsp, tree-sitter, util, collections grammars ← language_core, rust_embed, tree-sitter-{rust,python,...} language ← language_core, text, theme, settings, rpc, task, fs, ... languages ← language, grammars ``` ## What moved to `language_core` - `Grammar`, `GrammarId`, and all query config/builder types - `LanguageConfig`, `LanguageMatcher`, bracket/comment/indent config types - `HighlightMap`, `HighlightId` (theme-dependent free functions `highlight_style` and `highlight_name` stay in `language`) - `LanguageName`, `LanguageId` - `LanguageQueries`, `QUERY_FILENAME_PREFIXES` - `CodeLabel`, `CodeLabelBuilder`, `Symbol` - `Diagnostic`, `DiagnosticSourceKind` - `Toolchain`, `ToolchainScope`, `ToolchainList`, `ToolchainMetadata` - `ManifestName` - `SoftWrap` - LSP data types: `BinaryStatus`, `ServerHealth`, `LanguageServerStatusUpdate`, `PromptResponseContext`, `ToLspPosition` ## What stays in `language` - `Buffer`, `BufferSnapshot`, `SyntaxMap`, `Outline`, `DiagnosticSet`, `LanguageScope` - `LspAdapter`, `CachedLspAdapter`, `LspAdapterDelegate` (reference `Arc` and `WorktreeId`) - `ToolchainLister`, `LanguageToolchainStore` (reference `task` and `settings` types) - `ManifestQuery`, `ManifestProvider`, `ManifestDelegate` (reference `WorktreeId`) - Parser/query cursor pools, `PLAIN_TEXT`, point conversion functions ## What the `grammars` crate provides - Embedded `.scm` query files and `config.toml` files for all built-in languages (via `rust_embed`) - `load_queries(name)`, `load_config(name)`, `load_config_for_feature(name, grammars_loaded)`, and `get_file(path)` functions - `native_grammars()` for tree-sitter grammar registration (behind `load-grammars` feature) ## Pre-cleanup (also in this PR) - Removed unused `Option<&Buffer>` from `LspAdapter::process_diagnostics` - Removed unused `&App` from `LspAdapter::retain_old_diagnostic` - Removed `fs: &dyn Fs` from `ToolchainLister` trait methods (`PythonToolchainProvider` captures `fs` at construction time instead) - Moved `Diagnostic`/`DiagnosticSourceKind` out of `buffer.rs` into their own module ## Backward compatibility The `language` crate re-exports everything from `language_core`, so existing `use language::Grammar` (etc.) continues to work unchanged. The only downstream change required is importing `CodeLabelExt` where `.fallback_for_completion()` is called on the now-foreign `CodeLabel` type. Release Notes: - N/A --------- Co-authored-by: Agus Zubiaga Co-authored-by: Tom Houlé --- Cargo.lock | 62 +- Cargo.toml | 4 + crates/debugger_ui/src/tests/inline_values.rs | 10 +- .../src/filter_languages.rs | 6 +- .../src/edit_prediction_context_tests.rs | 5 +- crates/editor/src/display_map.rs | 9 +- crates/editor/src/editor.rs | 7 +- crates/editor/src/signature_help.rs | 3 +- crates/grammars/Cargo.toml | 60 + crates/grammars/LICENSE-GPL | 1 + .../src/bash/brackets.scm | 0 .../src/bash/config.toml | 0 .../src/bash/highlights.scm | 0 .../src/bash/indents.scm | 0 .../src/bash/injections.scm | 0 .../src/bash/overrides.scm | 0 .../src/bash/redactions.scm | 0 .../src/bash/runnables.scm | 0 .../src/bash/textobjects.scm | 0 .../src/c/brackets.scm | 0 .../{languages => grammars}/src/c/config.toml | 0 .../src/c/highlights.scm | 0 .../{languages => grammars}/src/c/imports.scm | 0 .../{languages => grammars}/src/c/indents.scm | 0 .../src/c/injections.scm | 0 .../{languages => grammars}/src/c/outline.scm | 0 .../src/c/overrides.scm | 0 .../src/c/runnables.scm | 0 .../src/c/textobjects.scm | 0 .../src/cpp/brackets.scm | 0 .../src/cpp/config.toml | 0 .../src/cpp/highlights.scm | 0 .../src/cpp/imports.scm | 0 .../src/cpp/indents.scm | 0 .../src/cpp/injections.scm | 0 .../src/cpp/outline.scm | 0 .../src/cpp/overrides.scm | 0 .../src/cpp/semantic_token_rules.json | 0 .../src/cpp/textobjects.scm | 0 .../src/css/brackets.scm | 0 .../src/css/config.toml | 0 .../src/css/highlights.scm | 0 .../src/css/indents.scm | 0 .../src/css/injections.scm | 0 .../src/css/outline.scm | 0 .../src/css/overrides.scm | 0 .../src/css/textobjects.scm | 0 .../src/diff/config.toml | 0 .../src/diff/highlights.scm | 0 .../src/diff/injections.scm | 0 .../src/gitcommit/config.toml | 0 .../src/gitcommit/highlights.scm | 0 .../src/gitcommit/injections.scm | 0 .../src/go/brackets.scm | 0 .../src/go/config.toml | 0 .../src/go/debugger.scm | 0 .../src/go/highlights.scm | 0 .../src/go/imports.scm | 0 .../src/go/indents.scm | 0 .../src/go/injections.scm | 0 .../src/go/outline.scm | 0 .../src/go/overrides.scm | 0 .../src/go/runnables.scm | 0 .../src/go/semantic_token_rules.json | 0 .../src/go/textobjects.scm | 0 .../src/gomod/config.toml | 0 .../src/gomod/highlights.scm | 0 .../src/gomod/injections.scm | 0 .../src/gomod/structure.scm | 0 .../src/gowork/config.toml | 0 .../src/gowork/highlights.scm | 0 .../src/gowork/injections.scm | 0 crates/grammars/src/grammars.rs | 108 ++ .../src/javascript/brackets.scm | 0 .../src/javascript/config.toml | 0 .../src/javascript/debugger.scm | 0 .../src/javascript/highlights.scm | 0 .../src/javascript/imports.scm | 0 .../src/javascript/indents.scm | 0 .../src/javascript/injections.scm | 0 .../src/javascript/outline.scm | 0 .../src/javascript/overrides.scm | 0 .../src/javascript/runnables.scm | 0 .../src/javascript/textobjects.scm | 0 .../src/jsdoc/brackets.scm | 0 .../src/jsdoc/config.toml | 0 .../src/jsdoc/highlights.scm | 0 .../src/json/brackets.scm | 0 .../src/json/config.toml | 0 .../src/json/highlights.scm | 0 .../src/json/indents.scm | 0 .../src/json/outline.scm | 0 .../src/json/overrides.scm | 0 .../src/json/redactions.scm | 0 .../src/json/runnables.scm | 0 .../src/json/textobjects.scm | 0 .../src/jsonc/brackets.scm | 0 .../src/jsonc/config.toml | 0 .../src/jsonc/highlights.scm | 0 .../src/jsonc/indents.scm | 0 .../src/jsonc/injections.scm | 0 .../src/jsonc/outline.scm | 0 .../src/jsonc/overrides.scm | 0 .../src/jsonc/redactions.scm | 0 .../src/jsonc/textobjects.scm | 0 .../src/markdown-inline/config.toml | 0 .../src/markdown-inline/highlights.scm | 0 .../src/markdown-inline/injections.scm | 0 .../src/markdown/brackets.scm | 0 .../src/markdown/config.toml | 0 .../src/markdown/highlights.scm | 0 .../src/markdown/indents.scm | 0 .../src/markdown/injections.scm | 0 .../src/markdown/outline.scm | 0 .../src/markdown/textobjects.scm | 0 .../src/python/brackets.scm | 0 .../src/python/config.toml | 0 .../src/python/debugger.scm | 0 .../src/python/highlights.scm | 0 .../src/python/imports.scm | 0 .../src/python/indents.scm | 0 .../src/python/injections.scm | 0 .../src/python/outline.scm | 0 .../src/python/overrides.scm | 0 .../src/python/runnables.scm | 0 .../src/python/semantic_token_rules.json | 0 .../src/python/textobjects.scm | 0 .../src/regex/brackets.scm | 0 .../src/regex/config.toml | 0 .../src/regex/highlights.scm | 0 .../src/rust/brackets.scm | 0 .../src/rust/config.toml | 0 .../src/rust/debugger.scm | 0 .../src/rust/highlights.scm | 0 .../src/rust/imports.scm | 0 .../src/rust/indents.scm | 0 .../src/rust/injections.scm | 0 .../src/rust/outline.scm | 0 .../src/rust/overrides.scm | 0 .../src/rust/runnables.scm | 0 .../src/rust/semantic_token_rules.json | 0 .../src/rust/textobjects.scm | 0 .../src/tsx/brackets.scm | 0 .../src/tsx/config.toml | 0 .../src/tsx/debugger.scm | 0 .../src/tsx/highlights.scm | 0 .../src/tsx/imports.scm | 0 .../src/tsx/indents.scm | 0 .../src/tsx/injections.scm | 0 .../src/tsx/outline.scm | 0 .../src/tsx/overrides.scm | 0 .../src/tsx/runnables.scm | 0 .../src/tsx/textobjects.scm | 0 .../src/typescript/brackets.scm | 0 .../src/typescript/config.toml | 0 .../src/typescript/debugger.scm | 0 .../src/typescript/highlights.scm | 0 .../src/typescript/imports.scm | 0 .../src/typescript/indents.scm | 0 .../src/typescript/injections.scm | 0 .../src/typescript/outline.scm | 0 .../src/typescript/overrides.scm | 0 .../src/typescript/runnables.scm | 0 .../src/typescript/textobjects.scm | 0 .../src/yaml/brackets.scm | 0 .../src/yaml/config.toml | 0 .../src/yaml/highlights.scm | 0 .../src/yaml/injections.scm | 0 .../src/yaml/outline.scm | 0 .../src/yaml/overrides.scm | 0 .../src/yaml/redactions.scm | 0 .../src/yaml/textobjects.scm | 0 .../src/zed-keybind-context/brackets.scm | 0 .../src/zed-keybind-context/config.toml | 0 .../src/zed-keybind-context/highlights.scm | 0 crates/keymap_editor/src/keymap_editor.rs | 4 +- crates/language/Cargo.toml | 2 +- crates/language/benches/highlight_map.rs | 10 +- crates/language/src/buffer.rs | 86 +- crates/language/src/diagnostic.rs | 1 + crates/language/src/highlight_map.rs | 98 - crates/language/src/language.rs | 1633 ++--------------- crates/language/src/language_registry.rs | 155 +- crates/language/src/manifest.rs | 39 +- crates/language/src/syntax_map.rs | 4 +- .../src/syntax_map/syntax_map_tests.rs | 2 +- crates/language/src/toolchain.rs | 126 +- crates/language_core/Cargo.toml | 29 + crates/language_core/LICENSE-GPL | 1 + crates/language_core/src/code_label.rs | 122 ++ crates/language_core/src/diagnostic.rs | 76 + crates/language_core/src/grammar.rs | 821 +++++++++ crates/language_core/src/highlight_map.rs | 52 + crates/language_core/src/language_config.rs | 539 ++++++ crates/language_core/src/language_core.rs | 39 + crates/language_core/src/language_name.rs | 109 ++ crates/language_core/src/lsp_adapter.rs | 44 + crates/language_core/src/manifest.rs | 36 + crates/language_core/src/queries.rs | 33 + crates/language_core/src/toolchain.rs | 124 ++ .../src/highlights_tree_view.rs | 7 +- crates/languages/Cargo.toml | 38 +- crates/languages/src/c.rs | 2 +- crates/languages/src/cpp.rs | 4 +- crates/languages/src/go.rs | 4 +- crates/languages/src/lib.rs | 87 +- crates/languages/src/python.rs | 20 +- crates/languages/src/rust.rs | 12 +- crates/markdown/src/markdown.rs | 4 +- .../markdown_preview/src/markdown_elements.rs | 3 +- .../markdown_preview/src/markdown_renderer.rs | 6 +- crates/outline_panel/src/outline_panel.rs | 4 +- crates/project/src/lsp_store.rs | 39 +- crates/project/src/project.rs | 1 - crates/project/src/toolchain_store.rs | 22 +- .../tests/integration/project_tests.rs | 2 - crates/remote_server/src/headless_project.rs | 1 - crates/theme/src/styles/syntax.rs | 7 +- crates/vim/src/state.rs | 5 +- 219 files changed, 2556 insertions(+), 2172 deletions(-) create mode 100644 crates/grammars/Cargo.toml create mode 120000 crates/grammars/LICENSE-GPL rename crates/{languages => grammars}/src/bash/brackets.scm (100%) rename crates/{languages => grammars}/src/bash/config.toml (100%) rename crates/{languages => grammars}/src/bash/highlights.scm (100%) rename crates/{languages => grammars}/src/bash/indents.scm (100%) rename crates/{languages => grammars}/src/bash/injections.scm (100%) rename crates/{languages => grammars}/src/bash/overrides.scm (100%) rename crates/{languages => grammars}/src/bash/redactions.scm (100%) rename crates/{languages => grammars}/src/bash/runnables.scm (100%) rename crates/{languages => grammars}/src/bash/textobjects.scm (100%) rename crates/{languages => grammars}/src/c/brackets.scm (100%) rename crates/{languages => grammars}/src/c/config.toml (100%) rename crates/{languages => grammars}/src/c/highlights.scm (100%) rename crates/{languages => grammars}/src/c/imports.scm (100%) rename crates/{languages => grammars}/src/c/indents.scm (100%) rename crates/{languages => grammars}/src/c/injections.scm (100%) rename crates/{languages => grammars}/src/c/outline.scm (100%) rename crates/{languages => grammars}/src/c/overrides.scm (100%) rename crates/{languages => grammars}/src/c/runnables.scm (100%) rename crates/{languages => grammars}/src/c/textobjects.scm (100%) rename crates/{languages => grammars}/src/cpp/brackets.scm (100%) rename crates/{languages => grammars}/src/cpp/config.toml (100%) rename crates/{languages => grammars}/src/cpp/highlights.scm (100%) rename crates/{languages => grammars}/src/cpp/imports.scm (100%) rename crates/{languages => grammars}/src/cpp/indents.scm (100%) rename crates/{languages => grammars}/src/cpp/injections.scm (100%) rename crates/{languages => grammars}/src/cpp/outline.scm (100%) rename crates/{languages => grammars}/src/cpp/overrides.scm (100%) rename crates/{languages => grammars}/src/cpp/semantic_token_rules.json (100%) rename crates/{languages => grammars}/src/cpp/textobjects.scm (100%) rename crates/{languages => grammars}/src/css/brackets.scm (100%) rename crates/{languages => grammars}/src/css/config.toml (100%) rename crates/{languages => grammars}/src/css/highlights.scm (100%) rename crates/{languages => grammars}/src/css/indents.scm (100%) rename crates/{languages => grammars}/src/css/injections.scm (100%) rename crates/{languages => grammars}/src/css/outline.scm (100%) rename crates/{languages => grammars}/src/css/overrides.scm (100%) rename crates/{languages => grammars}/src/css/textobjects.scm (100%) rename crates/{languages => grammars}/src/diff/config.toml (100%) rename crates/{languages => grammars}/src/diff/highlights.scm (100%) rename crates/{languages => grammars}/src/diff/injections.scm (100%) rename crates/{languages => grammars}/src/gitcommit/config.toml (100%) rename crates/{languages => grammars}/src/gitcommit/highlights.scm (100%) rename crates/{languages => grammars}/src/gitcommit/injections.scm (100%) rename crates/{languages => grammars}/src/go/brackets.scm (100%) rename crates/{languages => grammars}/src/go/config.toml (100%) rename crates/{languages => grammars}/src/go/debugger.scm (100%) rename crates/{languages => grammars}/src/go/highlights.scm (100%) rename crates/{languages => grammars}/src/go/imports.scm (100%) rename crates/{languages => grammars}/src/go/indents.scm (100%) rename crates/{languages => grammars}/src/go/injections.scm (100%) rename crates/{languages => grammars}/src/go/outline.scm (100%) rename crates/{languages => grammars}/src/go/overrides.scm (100%) rename crates/{languages => grammars}/src/go/runnables.scm (100%) rename crates/{languages => grammars}/src/go/semantic_token_rules.json (100%) rename crates/{languages => grammars}/src/go/textobjects.scm (100%) rename crates/{languages => grammars}/src/gomod/config.toml (100%) rename crates/{languages => grammars}/src/gomod/highlights.scm (100%) rename crates/{languages => grammars}/src/gomod/injections.scm (100%) rename crates/{languages => grammars}/src/gomod/structure.scm (100%) rename crates/{languages => grammars}/src/gowork/config.toml (100%) rename crates/{languages => grammars}/src/gowork/highlights.scm (100%) rename crates/{languages => grammars}/src/gowork/injections.scm (100%) create mode 100644 crates/grammars/src/grammars.rs rename crates/{languages => grammars}/src/javascript/brackets.scm (100%) rename crates/{languages => grammars}/src/javascript/config.toml (100%) rename crates/{languages => grammars}/src/javascript/debugger.scm (100%) rename crates/{languages => grammars}/src/javascript/highlights.scm (100%) rename crates/{languages => grammars}/src/javascript/imports.scm (100%) rename crates/{languages => grammars}/src/javascript/indents.scm (100%) rename crates/{languages => grammars}/src/javascript/injections.scm (100%) rename crates/{languages => grammars}/src/javascript/outline.scm (100%) rename crates/{languages => grammars}/src/javascript/overrides.scm (100%) rename crates/{languages => grammars}/src/javascript/runnables.scm (100%) rename crates/{languages => grammars}/src/javascript/textobjects.scm (100%) rename crates/{languages => grammars}/src/jsdoc/brackets.scm (100%) rename crates/{languages => grammars}/src/jsdoc/config.toml (100%) rename crates/{languages => grammars}/src/jsdoc/highlights.scm (100%) rename crates/{languages => grammars}/src/json/brackets.scm (100%) rename crates/{languages => grammars}/src/json/config.toml (100%) rename crates/{languages => grammars}/src/json/highlights.scm (100%) rename crates/{languages => grammars}/src/json/indents.scm (100%) rename crates/{languages => grammars}/src/json/outline.scm (100%) rename crates/{languages => grammars}/src/json/overrides.scm (100%) rename crates/{languages => grammars}/src/json/redactions.scm (100%) rename crates/{languages => grammars}/src/json/runnables.scm (100%) rename crates/{languages => grammars}/src/json/textobjects.scm (100%) rename crates/{languages => grammars}/src/jsonc/brackets.scm (100%) rename crates/{languages => grammars}/src/jsonc/config.toml (100%) rename crates/{languages => grammars}/src/jsonc/highlights.scm (100%) rename crates/{languages => grammars}/src/jsonc/indents.scm (100%) rename crates/{languages => grammars}/src/jsonc/injections.scm (100%) rename crates/{languages => grammars}/src/jsonc/outline.scm (100%) rename crates/{languages => grammars}/src/jsonc/overrides.scm (100%) rename crates/{languages => grammars}/src/jsonc/redactions.scm (100%) rename crates/{languages => grammars}/src/jsonc/textobjects.scm (100%) rename crates/{languages => grammars}/src/markdown-inline/config.toml (100%) rename crates/{languages => grammars}/src/markdown-inline/highlights.scm (100%) rename crates/{languages => grammars}/src/markdown-inline/injections.scm (100%) rename crates/{languages => grammars}/src/markdown/brackets.scm (100%) rename crates/{languages => grammars}/src/markdown/config.toml (100%) rename crates/{languages => grammars}/src/markdown/highlights.scm (100%) rename crates/{languages => grammars}/src/markdown/indents.scm (100%) rename crates/{languages => grammars}/src/markdown/injections.scm (100%) rename crates/{languages => grammars}/src/markdown/outline.scm (100%) rename crates/{languages => grammars}/src/markdown/textobjects.scm (100%) rename crates/{languages => grammars}/src/python/brackets.scm (100%) rename crates/{languages => grammars}/src/python/config.toml (100%) rename crates/{languages => grammars}/src/python/debugger.scm (100%) rename crates/{languages => grammars}/src/python/highlights.scm (100%) rename crates/{languages => grammars}/src/python/imports.scm (100%) rename crates/{languages => grammars}/src/python/indents.scm (100%) rename crates/{languages => grammars}/src/python/injections.scm (100%) rename crates/{languages => grammars}/src/python/outline.scm (100%) rename crates/{languages => grammars}/src/python/overrides.scm (100%) rename crates/{languages => grammars}/src/python/runnables.scm (100%) rename crates/{languages => grammars}/src/python/semantic_token_rules.json (100%) rename crates/{languages => grammars}/src/python/textobjects.scm (100%) rename crates/{languages => grammars}/src/regex/brackets.scm (100%) rename crates/{languages => grammars}/src/regex/config.toml (100%) rename crates/{languages => grammars}/src/regex/highlights.scm (100%) rename crates/{languages => grammars}/src/rust/brackets.scm (100%) rename crates/{languages => grammars}/src/rust/config.toml (100%) rename crates/{languages => grammars}/src/rust/debugger.scm (100%) rename crates/{languages => grammars}/src/rust/highlights.scm (100%) rename crates/{languages => grammars}/src/rust/imports.scm (100%) rename crates/{languages => grammars}/src/rust/indents.scm (100%) rename crates/{languages => grammars}/src/rust/injections.scm (100%) rename crates/{languages => grammars}/src/rust/outline.scm (100%) rename crates/{languages => grammars}/src/rust/overrides.scm (100%) rename crates/{languages => grammars}/src/rust/runnables.scm (100%) rename crates/{languages => grammars}/src/rust/semantic_token_rules.json (100%) rename crates/{languages => grammars}/src/rust/textobjects.scm (100%) rename crates/{languages => grammars}/src/tsx/brackets.scm (100%) rename crates/{languages => grammars}/src/tsx/config.toml (100%) rename crates/{languages => grammars}/src/tsx/debugger.scm (100%) rename crates/{languages => grammars}/src/tsx/highlights.scm (100%) rename crates/{languages => grammars}/src/tsx/imports.scm (100%) rename crates/{languages => grammars}/src/tsx/indents.scm (100%) rename crates/{languages => grammars}/src/tsx/injections.scm (100%) rename crates/{languages => grammars}/src/tsx/outline.scm (100%) rename crates/{languages => grammars}/src/tsx/overrides.scm (100%) rename crates/{languages => grammars}/src/tsx/runnables.scm (100%) rename crates/{languages => grammars}/src/tsx/textobjects.scm (100%) rename crates/{languages => grammars}/src/typescript/brackets.scm (100%) rename crates/{languages => grammars}/src/typescript/config.toml (100%) rename crates/{languages => grammars}/src/typescript/debugger.scm (100%) rename crates/{languages => grammars}/src/typescript/highlights.scm (100%) rename crates/{languages => grammars}/src/typescript/imports.scm (100%) rename crates/{languages => grammars}/src/typescript/indents.scm (100%) rename crates/{languages => grammars}/src/typescript/injections.scm (100%) rename crates/{languages => grammars}/src/typescript/outline.scm (100%) rename crates/{languages => grammars}/src/typescript/overrides.scm (100%) rename crates/{languages => grammars}/src/typescript/runnables.scm (100%) rename crates/{languages => grammars}/src/typescript/textobjects.scm (100%) rename crates/{languages => grammars}/src/yaml/brackets.scm (100%) rename crates/{languages => grammars}/src/yaml/config.toml (100%) rename crates/{languages => grammars}/src/yaml/highlights.scm (100%) rename crates/{languages => grammars}/src/yaml/injections.scm (100%) rename crates/{languages => grammars}/src/yaml/outline.scm (100%) rename crates/{languages => grammars}/src/yaml/overrides.scm (100%) rename crates/{languages => grammars}/src/yaml/redactions.scm (100%) rename crates/{languages => grammars}/src/yaml/textobjects.scm (100%) rename crates/{languages => grammars}/src/zed-keybind-context/brackets.scm (100%) rename crates/{languages => grammars}/src/zed-keybind-context/config.toml (100%) rename crates/{languages => grammars}/src/zed-keybind-context/highlights.scm (100%) create mode 100644 crates/language/src/diagnostic.rs delete mode 100644 crates/language/src/highlight_map.rs create mode 100644 crates/language_core/Cargo.toml create mode 120000 crates/language_core/LICENSE-GPL create mode 100644 crates/language_core/src/code_label.rs create mode 100644 crates/language_core/src/diagnostic.rs create mode 100644 crates/language_core/src/grammar.rs create mode 100644 crates/language_core/src/highlight_map.rs create mode 100644 crates/language_core/src/language_config.rs create mode 100644 crates/language_core/src/language_core.rs create mode 100644 crates/language_core/src/language_name.rs create mode 100644 crates/language_core/src/lsp_adapter.rs create mode 100644 crates/language_core/src/manifest.rs create mode 100644 crates/language_core/src/queries.rs create mode 100644 crates/language_core/src/toolchain.rs diff --git a/Cargo.lock b/Cargo.lock index 1d36cd7198bab5a0f529d71904ef28ae1c915d5e..dd9e9399d00845bfe2382183e6359653466cff4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7884,6 +7884,35 @@ dependencies = [ "zed-scap", ] +[[package]] +name = "grammars" +version = "0.1.0" +dependencies = [ + "anyhow", + "language_core", + "rust-embed", + "toml 0.8.23", + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-gitcommit", + "tree-sitter-go", + "tree-sitter-gomod", + "tree-sitter-gowork", + "tree-sitter-jsdoc", + "tree-sitter-json", + "tree-sitter-md", + "tree-sitter-python", + "tree-sitter-regex", + "tree-sitter-rust", + "tree-sitter-typescript", + "tree-sitter-yaml", + "util", +] + [[package]] name = "grid" version = "0.18.0" @@ -9345,6 +9374,7 @@ dependencies = [ "imara-diff", "indoc", "itertools 0.14.0", + "language_core", "log", "lsp", "parking_lot", @@ -9353,7 +9383,6 @@ dependencies = [ "rand 0.9.2", "regex", "rpc", - "schemars", "semver", "serde", "serde_json", @@ -9388,6 +9417,25 @@ dependencies = [ "ztracing", ] +[[package]] +name = "language_core" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "log", + "lsp", + "parking_lot", + "regex", + "schemars", + "serde", + "serde_json", + "toml 0.8.23", + "tree-sitter", + "util", +] + [[package]] name = "language_extension" version = "0.1.0" @@ -9580,9 +9628,11 @@ dependencies = [ "async-trait", "chrono", "collections", + "fs", "futures 0.3.31", "globset", "gpui", + "grammars", "http_client", "itertools 0.14.0", "json_schema_store", @@ -9602,7 +9652,6 @@ dependencies = [ "project", "regex", "rope", - "rust-embed", "semver", "serde", "serde_json", @@ -9614,25 +9663,16 @@ dependencies = [ "task", "terminal", "theme", - "toml 0.8.23", "tree-sitter", "tree-sitter-bash", "tree-sitter-c", "tree-sitter-cpp", "tree-sitter-css", - "tree-sitter-diff", "tree-sitter-gitcommit", "tree-sitter-go", - "tree-sitter-gomod", - "tree-sitter-gowork", - "tree-sitter-jsdoc", - "tree-sitter-json", - "tree-sitter-md", "tree-sitter-python", - "tree-sitter-regex", "tree-sitter-rust", "tree-sitter-typescript", - "tree-sitter-yaml", "unindent", "url", "util", diff --git a/Cargo.toml b/Cargo.toml index 17efe21800962cf5c7cd1b21b2e7c7a0c8df4c12..e9993d821888a2107427026f742aaca0cec220bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ members = [ "crates/git_ui", "crates/go_to_line", "crates/google_ai", + "crates/grammars", "crates/gpui", "crates/gpui_linux", "crates/gpui_macos", @@ -108,6 +109,7 @@ members = [ "crates/json_schema_store", "crates/keymap_editor", "crates/language", + "crates/language_core", "crates/language_extension", "crates/language_model", "crates/language_models", @@ -330,6 +332,7 @@ git_hosting_providers = { path = "crates/git_hosting_providers" } git_ui = { path = "crates/git_ui" } go_to_line = { path = "crates/go_to_line" } google_ai = { path = "crates/google_ai" } +grammars = { path = "crates/grammars" } gpui = { path = "crates/gpui", default-features = false } gpui_linux = { path = "crates/gpui_linux", default-features = false } gpui_macos = { path = "crates/gpui_macos", default-features = false } @@ -354,6 +357,7 @@ journal = { path = "crates/journal" } json_schema_store = { path = "crates/json_schema_store" } keymap_editor = { path = "crates/keymap_editor" } language = { path = "crates/language" } +language_core = { path = "crates/language_core" } language_extension = { path = "crates/language_extension" } language_model = { path = "crates/language_model" } language_models = { path = "crates/language_models" } diff --git a/crates/debugger_ui/src/tests/inline_values.rs b/crates/debugger_ui/src/tests/inline_values.rs index 379bc4c98f5341b089b5936ed8571da5a6280723..3ca29f7cc3e99514d8a664d9218593c3640b27dc 100644 --- a/crates/debugger_ui/src/tests/inline_values.rs +++ b/crates/debugger_ui/src/tests/inline_values.rs @@ -1826,7 +1826,7 @@ def process_data(untyped_param, typed_param: int, another_typed: str): } fn python_lang() -> Language { - let debug_variables_query = include_str!("../../../languages/src/python/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/python/debugger.scm"); Language::new( LanguageConfig { name: "Python".into(), @@ -1843,7 +1843,7 @@ fn python_lang() -> Language { } fn go_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/go/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/go/debugger.scm"); Arc::new( Language::new( LanguageConfig { @@ -2262,7 +2262,7 @@ fn main() { } fn javascript_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/javascript/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/javascript/debugger.scm"); Arc::new( Language::new( LanguageConfig { @@ -2281,7 +2281,7 @@ fn javascript_lang() -> Arc { } fn typescript_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/typescript/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/typescript/debugger.scm"); Arc::new( Language::new( LanguageConfig { @@ -2300,7 +2300,7 @@ fn typescript_lang() -> Arc { } fn tsx_lang() -> Arc { - let debug_variables_query = include_str!("../../../languages/src/tsx/debugger.scm"); + let debug_variables_query = include_str!("../../../grammars/src/tsx/debugger.scm"); Arc::new( Language::new( LanguageConfig { diff --git a/crates/edit_prediction_cli/src/filter_languages.rs b/crates/edit_prediction_cli/src/filter_languages.rs index 355b5708d43c35c74bf62608726309389a1bfe32..989a112a50aa2dd2d922df6895be275a58ff6336 100644 --- a/crates/edit_prediction_cli/src/filter_languages.rs +++ b/crates/edit_prediction_cli/src/filter_languages.rs @@ -13,7 +13,7 @@ //! //! Language is detected based on file extension of the `cursor_path` field. //! The extension-to-language mapping is built from the embedded language -//! config files in the `languages` crate. +//! config files in the `grammars` crate. use anyhow::{Context as _, Result, bail}; use clap::Args; @@ -29,7 +29,7 @@ mod language_configs_embedded { use rust_embed::RustEmbed; #[derive(RustEmbed)] - #[folder = "../languages/src/"] + #[folder = "../grammars/src/"] #[include = "*/config.toml"] pub struct LanguageConfigs; } @@ -123,7 +123,7 @@ fn build_extension_to_language_map() -> HashMap { #[cfg(feature = "dynamic_prompts")] fn build_extension_to_language_map() -> HashMap { - const LANGUAGES_SRC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../languages/src"); + const LANGUAGES_SRC_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../grammars/src"); let mut map = HashMap::default(); diff --git a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs index 78ded78b7eb558c9bb5d1839a8c8c82290a13d9a..32dc37b953207e4d034287642f0e91fa400867dd 100644 --- a/crates/edit_prediction_context/src/edit_prediction_context_tests.rs +++ b/crates/edit_prediction_context/src/edit_prediction_context_tests.rs @@ -160,7 +160,7 @@ async fn test_edit_prediction_context(cx: &mut TestAppContext) { } #[gpui::test] -fn test_assemble_excerpts(cx: &mut TestAppContext) { +async fn test_assemble_excerpts(cx: &mut TestAppContext) { let table = [ ( indoc! {r#" @@ -289,6 +289,9 @@ fn test_assemble_excerpts(cx: &mut TestAppContext) { for (input, expected_output) in table { let (input, ranges) = marked_text_ranges(&input, false); let buffer = cx.new(|cx| Buffer::local(input, cx).with_language(rust_lang(), cx)); + buffer + .read_with(cx, |buffer, _| buffer.parsing_idle()) + .await; buffer.read_with(cx, |buffer, _cx| { let ranges: Vec<(Range, usize)> = ranges .into_iter() diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index e22cafb00bd446b94d3b8eda4d7e3afd20c449ae..b9fa0a49b1b77f9e5fcf4ace7d83155628afba20 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -101,6 +101,7 @@ use language::{ Point, Subscription as BufferSubscription, language_settings::{AllLanguageSettings, LanguageSettings}, }; + use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptId, MultiBuffer, MultiBufferOffset, MultiBufferOffsetUtf16, MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset, ToPoint, @@ -1905,7 +1906,7 @@ impl DisplaySnapshot { .flat_map(|chunk| { let syntax_highlight_style = chunk .syntax_highlight_id - .and_then(|id| id.style(&editor_style.syntax)); + .and_then(|id| editor_style.syntax.get(id).cloned()); let chunk_highlight = chunk.highlight_style.map(|chunk_highlight| { HighlightStyle { @@ -1999,7 +2000,8 @@ impl DisplaySnapshot { let syntax_style = chunk .syntax_highlight_id - .and_then(|id| id.style(syntax_theme)); + .and_then(|id| syntax_theme.get(id).cloned()); + let overlay_style = chunk.highlight_style; let combined = match (syntax_style, overlay_style) { @@ -4015,7 +4017,8 @@ pub mod tests { for chunk in snapshot.chunks(rows, true, HighlightStyles::default()) { let syntax_color = chunk .syntax_highlight_id - .and_then(|id| id.style(theme)?.color); + .and_then(|id| theme.get(id)?.color); + let highlight_color = chunk.highlight_style.and_then(|style| style.color); if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut() && syntax_color == *last_syntax_color diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b633b1d77359c6f7888e69ffc21615210f075e13..1984c2180d1c5434b02a1623510fc2caa30177c4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -19160,7 +19160,7 @@ impl Editor { move |cx: &mut BlockContext| { let mut text_style = cx.editor_style.text.clone(); if let Some(highlight_style) = old_highlight_id - .and_then(|h| h.style(&cx.editor_style.syntax)) + .and_then(|h| cx.editor_style.syntax.get(h).cloned()) { text_style = text_style.highlight(highlight_style); } @@ -25039,7 +25039,8 @@ impl Editor { for chunk in chunks { let highlight = chunk .syntax_highlight_id - .and_then(|id| id.name(&style.syntax)); + .and_then(|id| style.syntax.get_capture_name(id)); + let mut chunk_lines = chunk.text.split('\n').peekable(); while let Some(text) = chunk_lines.next() { let mut merged_with_last_token = false; @@ -28863,7 +28864,7 @@ pub fn styled_runs_for_code_label<'a>( background_color: Some(local_player.selection), ..Default::default() } - } else if let Some(style) = highlight_id.style(syntax_theme) { + } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { style } else { return Default::default(); diff --git a/crates/editor/src/signature_help.rs b/crates/editor/src/signature_help.rs index 8f246089299f6f35bca14867c298e1f159765c6e..67f482339f501f46a4475bb9e9534437d9f9f1cf 100644 --- a/crates/editor/src/signature_help.rs +++ b/crates/editor/src/signature_help.rs @@ -6,6 +6,7 @@ use gpui::{ TextStyle, Window, combine_highlights, }; use language::BufferSnapshot; + use markdown::{Markdown, MarkdownElement}; use multi_buffer::{Anchor, MultiBufferOffset, ToOffset}; use settings::Settings; @@ -236,7 +237,7 @@ impl Editor { .highlight_text(&text, 0..signature.label.len()) .into_iter() .flat_map(|(range, highlight_id)| { - Some((range, highlight_id.style(cx.theme().syntax())?)) + Some((range, *cx.theme().syntax().get(highlight_id)?)) }); signature.highlights = combine_highlights(signature.highlights.clone(), highlights) diff --git a/crates/grammars/Cargo.toml b/crates/grammars/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..13b3bf5c94bb459e49e5b1f337fe95b1b216829a --- /dev/null +++ b/crates/grammars/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "grammars" +version = "0.1.0" +edition = "2024" +publish = false + +[lints] +workspace = true + +[lib] +path = "src/grammars.rs" + +[dependencies] +language_core.workspace = true +rust-embed.workspace = true +anyhow.workspace = true +toml.workspace = true +util.workspace = true + +tree-sitter = { workspace = true, optional = true } +tree-sitter-bash = { workspace = true, optional = true } +tree-sitter-c = { workspace = true, optional = true } +tree-sitter-cpp = { workspace = true, optional = true } +tree-sitter-css = { workspace = true, optional = true } +tree-sitter-diff = { workspace = true, optional = true } +tree-sitter-gitcommit = { workspace = true, optional = true } +tree-sitter-go = { workspace = true, optional = true } +tree-sitter-go-mod = { workspace = true, optional = true } +tree-sitter-gowork = { workspace = true, optional = true } +tree-sitter-jsdoc = { workspace = true, optional = true } +tree-sitter-json = { workspace = true, optional = true } +tree-sitter-md = { workspace = true, optional = true } +tree-sitter-python = { workspace = true, optional = true } +tree-sitter-regex = { workspace = true, optional = true } +tree-sitter-rust = { workspace = true, optional = true } +tree-sitter-typescript = { workspace = true, optional = true } +tree-sitter-yaml = { workspace = true, optional = true } + +[features] +load-grammars = [ + "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-gitcommit", + "tree-sitter-go", + "tree-sitter-go-mod", + "tree-sitter-gowork", + "tree-sitter-jsdoc", + "tree-sitter-json", + "tree-sitter-md", + "tree-sitter-python", + "tree-sitter-regex", + "tree-sitter-rust", + "tree-sitter-typescript", + "tree-sitter-yaml", +] +test-support = ["load-grammars"] diff --git a/crates/grammars/LICENSE-GPL b/crates/grammars/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/grammars/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/languages/src/bash/brackets.scm b/crates/grammars/src/bash/brackets.scm similarity index 100% rename from crates/languages/src/bash/brackets.scm rename to crates/grammars/src/bash/brackets.scm diff --git a/crates/languages/src/bash/config.toml b/crates/grammars/src/bash/config.toml similarity index 100% rename from crates/languages/src/bash/config.toml rename to crates/grammars/src/bash/config.toml diff --git a/crates/languages/src/bash/highlights.scm b/crates/grammars/src/bash/highlights.scm similarity index 100% rename from crates/languages/src/bash/highlights.scm rename to crates/grammars/src/bash/highlights.scm diff --git a/crates/languages/src/bash/indents.scm b/crates/grammars/src/bash/indents.scm similarity index 100% rename from crates/languages/src/bash/indents.scm rename to crates/grammars/src/bash/indents.scm diff --git a/crates/languages/src/bash/injections.scm b/crates/grammars/src/bash/injections.scm similarity index 100% rename from crates/languages/src/bash/injections.scm rename to crates/grammars/src/bash/injections.scm diff --git a/crates/languages/src/bash/overrides.scm b/crates/grammars/src/bash/overrides.scm similarity index 100% rename from crates/languages/src/bash/overrides.scm rename to crates/grammars/src/bash/overrides.scm diff --git a/crates/languages/src/bash/redactions.scm b/crates/grammars/src/bash/redactions.scm similarity index 100% rename from crates/languages/src/bash/redactions.scm rename to crates/grammars/src/bash/redactions.scm diff --git a/crates/languages/src/bash/runnables.scm b/crates/grammars/src/bash/runnables.scm similarity index 100% rename from crates/languages/src/bash/runnables.scm rename to crates/grammars/src/bash/runnables.scm diff --git a/crates/languages/src/bash/textobjects.scm b/crates/grammars/src/bash/textobjects.scm similarity index 100% rename from crates/languages/src/bash/textobjects.scm rename to crates/grammars/src/bash/textobjects.scm diff --git a/crates/languages/src/c/brackets.scm b/crates/grammars/src/c/brackets.scm similarity index 100% rename from crates/languages/src/c/brackets.scm rename to crates/grammars/src/c/brackets.scm diff --git a/crates/languages/src/c/config.toml b/crates/grammars/src/c/config.toml similarity index 100% rename from crates/languages/src/c/config.toml rename to crates/grammars/src/c/config.toml diff --git a/crates/languages/src/c/highlights.scm b/crates/grammars/src/c/highlights.scm similarity index 100% rename from crates/languages/src/c/highlights.scm rename to crates/grammars/src/c/highlights.scm diff --git a/crates/languages/src/c/imports.scm b/crates/grammars/src/c/imports.scm similarity index 100% rename from crates/languages/src/c/imports.scm rename to crates/grammars/src/c/imports.scm diff --git a/crates/languages/src/c/indents.scm b/crates/grammars/src/c/indents.scm similarity index 100% rename from crates/languages/src/c/indents.scm rename to crates/grammars/src/c/indents.scm diff --git a/crates/languages/src/c/injections.scm b/crates/grammars/src/c/injections.scm similarity index 100% rename from crates/languages/src/c/injections.scm rename to crates/grammars/src/c/injections.scm diff --git a/crates/languages/src/c/outline.scm b/crates/grammars/src/c/outline.scm similarity index 100% rename from crates/languages/src/c/outline.scm rename to crates/grammars/src/c/outline.scm diff --git a/crates/languages/src/c/overrides.scm b/crates/grammars/src/c/overrides.scm similarity index 100% rename from crates/languages/src/c/overrides.scm rename to crates/grammars/src/c/overrides.scm diff --git a/crates/languages/src/c/runnables.scm b/crates/grammars/src/c/runnables.scm similarity index 100% rename from crates/languages/src/c/runnables.scm rename to crates/grammars/src/c/runnables.scm diff --git a/crates/languages/src/c/textobjects.scm b/crates/grammars/src/c/textobjects.scm similarity index 100% rename from crates/languages/src/c/textobjects.scm rename to crates/grammars/src/c/textobjects.scm diff --git a/crates/languages/src/cpp/brackets.scm b/crates/grammars/src/cpp/brackets.scm similarity index 100% rename from crates/languages/src/cpp/brackets.scm rename to crates/grammars/src/cpp/brackets.scm diff --git a/crates/languages/src/cpp/config.toml b/crates/grammars/src/cpp/config.toml similarity index 100% rename from crates/languages/src/cpp/config.toml rename to crates/grammars/src/cpp/config.toml diff --git a/crates/languages/src/cpp/highlights.scm b/crates/grammars/src/cpp/highlights.scm similarity index 100% rename from crates/languages/src/cpp/highlights.scm rename to crates/grammars/src/cpp/highlights.scm diff --git a/crates/languages/src/cpp/imports.scm b/crates/grammars/src/cpp/imports.scm similarity index 100% rename from crates/languages/src/cpp/imports.scm rename to crates/grammars/src/cpp/imports.scm diff --git a/crates/languages/src/cpp/indents.scm b/crates/grammars/src/cpp/indents.scm similarity index 100% rename from crates/languages/src/cpp/indents.scm rename to crates/grammars/src/cpp/indents.scm diff --git a/crates/languages/src/cpp/injections.scm b/crates/grammars/src/cpp/injections.scm similarity index 100% rename from crates/languages/src/cpp/injections.scm rename to crates/grammars/src/cpp/injections.scm diff --git a/crates/languages/src/cpp/outline.scm b/crates/grammars/src/cpp/outline.scm similarity index 100% rename from crates/languages/src/cpp/outline.scm rename to crates/grammars/src/cpp/outline.scm diff --git a/crates/languages/src/cpp/overrides.scm b/crates/grammars/src/cpp/overrides.scm similarity index 100% rename from crates/languages/src/cpp/overrides.scm rename to crates/grammars/src/cpp/overrides.scm diff --git a/crates/languages/src/cpp/semantic_token_rules.json b/crates/grammars/src/cpp/semantic_token_rules.json similarity index 100% rename from crates/languages/src/cpp/semantic_token_rules.json rename to crates/grammars/src/cpp/semantic_token_rules.json diff --git a/crates/languages/src/cpp/textobjects.scm b/crates/grammars/src/cpp/textobjects.scm similarity index 100% rename from crates/languages/src/cpp/textobjects.scm rename to crates/grammars/src/cpp/textobjects.scm diff --git a/crates/languages/src/css/brackets.scm b/crates/grammars/src/css/brackets.scm similarity index 100% rename from crates/languages/src/css/brackets.scm rename to crates/grammars/src/css/brackets.scm diff --git a/crates/languages/src/css/config.toml b/crates/grammars/src/css/config.toml similarity index 100% rename from crates/languages/src/css/config.toml rename to crates/grammars/src/css/config.toml diff --git a/crates/languages/src/css/highlights.scm b/crates/grammars/src/css/highlights.scm similarity index 100% rename from crates/languages/src/css/highlights.scm rename to crates/grammars/src/css/highlights.scm diff --git a/crates/languages/src/css/indents.scm b/crates/grammars/src/css/indents.scm similarity index 100% rename from crates/languages/src/css/indents.scm rename to crates/grammars/src/css/indents.scm diff --git a/crates/languages/src/css/injections.scm b/crates/grammars/src/css/injections.scm similarity index 100% rename from crates/languages/src/css/injections.scm rename to crates/grammars/src/css/injections.scm diff --git a/crates/languages/src/css/outline.scm b/crates/grammars/src/css/outline.scm similarity index 100% rename from crates/languages/src/css/outline.scm rename to crates/grammars/src/css/outline.scm diff --git a/crates/languages/src/css/overrides.scm b/crates/grammars/src/css/overrides.scm similarity index 100% rename from crates/languages/src/css/overrides.scm rename to crates/grammars/src/css/overrides.scm diff --git a/crates/languages/src/css/textobjects.scm b/crates/grammars/src/css/textobjects.scm similarity index 100% rename from crates/languages/src/css/textobjects.scm rename to crates/grammars/src/css/textobjects.scm diff --git a/crates/languages/src/diff/config.toml b/crates/grammars/src/diff/config.toml similarity index 100% rename from crates/languages/src/diff/config.toml rename to crates/grammars/src/diff/config.toml diff --git a/crates/languages/src/diff/highlights.scm b/crates/grammars/src/diff/highlights.scm similarity index 100% rename from crates/languages/src/diff/highlights.scm rename to crates/grammars/src/diff/highlights.scm diff --git a/crates/languages/src/diff/injections.scm b/crates/grammars/src/diff/injections.scm similarity index 100% rename from crates/languages/src/diff/injections.scm rename to crates/grammars/src/diff/injections.scm diff --git a/crates/languages/src/gitcommit/config.toml b/crates/grammars/src/gitcommit/config.toml similarity index 100% rename from crates/languages/src/gitcommit/config.toml rename to crates/grammars/src/gitcommit/config.toml diff --git a/crates/languages/src/gitcommit/highlights.scm b/crates/grammars/src/gitcommit/highlights.scm similarity index 100% rename from crates/languages/src/gitcommit/highlights.scm rename to crates/grammars/src/gitcommit/highlights.scm diff --git a/crates/languages/src/gitcommit/injections.scm b/crates/grammars/src/gitcommit/injections.scm similarity index 100% rename from crates/languages/src/gitcommit/injections.scm rename to crates/grammars/src/gitcommit/injections.scm diff --git a/crates/languages/src/go/brackets.scm b/crates/grammars/src/go/brackets.scm similarity index 100% rename from crates/languages/src/go/brackets.scm rename to crates/grammars/src/go/brackets.scm diff --git a/crates/languages/src/go/config.toml b/crates/grammars/src/go/config.toml similarity index 100% rename from crates/languages/src/go/config.toml rename to crates/grammars/src/go/config.toml diff --git a/crates/languages/src/go/debugger.scm b/crates/grammars/src/go/debugger.scm similarity index 100% rename from crates/languages/src/go/debugger.scm rename to crates/grammars/src/go/debugger.scm diff --git a/crates/languages/src/go/highlights.scm b/crates/grammars/src/go/highlights.scm similarity index 100% rename from crates/languages/src/go/highlights.scm rename to crates/grammars/src/go/highlights.scm diff --git a/crates/languages/src/go/imports.scm b/crates/grammars/src/go/imports.scm similarity index 100% rename from crates/languages/src/go/imports.scm rename to crates/grammars/src/go/imports.scm diff --git a/crates/languages/src/go/indents.scm b/crates/grammars/src/go/indents.scm similarity index 100% rename from crates/languages/src/go/indents.scm rename to crates/grammars/src/go/indents.scm diff --git a/crates/languages/src/go/injections.scm b/crates/grammars/src/go/injections.scm similarity index 100% rename from crates/languages/src/go/injections.scm rename to crates/grammars/src/go/injections.scm diff --git a/crates/languages/src/go/outline.scm b/crates/grammars/src/go/outline.scm similarity index 100% rename from crates/languages/src/go/outline.scm rename to crates/grammars/src/go/outline.scm diff --git a/crates/languages/src/go/overrides.scm b/crates/grammars/src/go/overrides.scm similarity index 100% rename from crates/languages/src/go/overrides.scm rename to crates/grammars/src/go/overrides.scm diff --git a/crates/languages/src/go/runnables.scm b/crates/grammars/src/go/runnables.scm similarity index 100% rename from crates/languages/src/go/runnables.scm rename to crates/grammars/src/go/runnables.scm diff --git a/crates/languages/src/go/semantic_token_rules.json b/crates/grammars/src/go/semantic_token_rules.json similarity index 100% rename from crates/languages/src/go/semantic_token_rules.json rename to crates/grammars/src/go/semantic_token_rules.json diff --git a/crates/languages/src/go/textobjects.scm b/crates/grammars/src/go/textobjects.scm similarity index 100% rename from crates/languages/src/go/textobjects.scm rename to crates/grammars/src/go/textobjects.scm diff --git a/crates/languages/src/gomod/config.toml b/crates/grammars/src/gomod/config.toml similarity index 100% rename from crates/languages/src/gomod/config.toml rename to crates/grammars/src/gomod/config.toml diff --git a/crates/languages/src/gomod/highlights.scm b/crates/grammars/src/gomod/highlights.scm similarity index 100% rename from crates/languages/src/gomod/highlights.scm rename to crates/grammars/src/gomod/highlights.scm diff --git a/crates/languages/src/gomod/injections.scm b/crates/grammars/src/gomod/injections.scm similarity index 100% rename from crates/languages/src/gomod/injections.scm rename to crates/grammars/src/gomod/injections.scm diff --git a/crates/languages/src/gomod/structure.scm b/crates/grammars/src/gomod/structure.scm similarity index 100% rename from crates/languages/src/gomod/structure.scm rename to crates/grammars/src/gomod/structure.scm diff --git a/crates/languages/src/gowork/config.toml b/crates/grammars/src/gowork/config.toml similarity index 100% rename from crates/languages/src/gowork/config.toml rename to crates/grammars/src/gowork/config.toml diff --git a/crates/languages/src/gowork/highlights.scm b/crates/grammars/src/gowork/highlights.scm similarity index 100% rename from crates/languages/src/gowork/highlights.scm rename to crates/grammars/src/gowork/highlights.scm diff --git a/crates/languages/src/gowork/injections.scm b/crates/grammars/src/gowork/injections.scm similarity index 100% rename from crates/languages/src/gowork/injections.scm rename to crates/grammars/src/gowork/injections.scm diff --git a/crates/grammars/src/grammars.rs b/crates/grammars/src/grammars.rs new file mode 100644 index 0000000000000000000000000000000000000000..00d6e6281c45b10a5dcfbd188b5848c63cc0cd75 --- /dev/null +++ b/crates/grammars/src/grammars.rs @@ -0,0 +1,108 @@ +use anyhow::Context as _; +use language_core::{LanguageConfig, LanguageQueries, QUERY_FILENAME_PREFIXES}; +use rust_embed::RustEmbed; +use util::asset_str; + +#[derive(RustEmbed)] +#[folder = "src/"] +#[exclude = "*.rs"] +struct GrammarDir; + +/// Register all built-in native tree-sitter grammars with the provided registration function. +/// +/// Each grammar is registered as a `(&str, tree_sitter_language::LanguageFn)` pair. +/// This must be called before loading language configs/queries. +#[cfg(feature = "load-grammars")] +pub fn native_grammars() -> Vec<(&'static str, tree_sitter::Language)> { + vec![ + ("bash", tree_sitter_bash::LANGUAGE.into()), + ("c", tree_sitter_c::LANGUAGE.into()), + ("cpp", tree_sitter_cpp::LANGUAGE.into()), + ("css", tree_sitter_css::LANGUAGE.into()), + ("diff", tree_sitter_diff::LANGUAGE.into()), + ("go", tree_sitter_go::LANGUAGE.into()), + ("gomod", tree_sitter_go_mod::LANGUAGE.into()), + ("gowork", tree_sitter_gowork::LANGUAGE.into()), + ("jsdoc", tree_sitter_jsdoc::LANGUAGE.into()), + ("json", tree_sitter_json::LANGUAGE.into()), + ("jsonc", tree_sitter_json::LANGUAGE.into()), + ("markdown", tree_sitter_md::LANGUAGE.into()), + ("markdown-inline", tree_sitter_md::INLINE_LANGUAGE.into()), + ("python", tree_sitter_python::LANGUAGE.into()), + ("regex", tree_sitter_regex::LANGUAGE.into()), + ("rust", tree_sitter_rust::LANGUAGE.into()), + ("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()), + ( + "typescript", + tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + ), + ("yaml", tree_sitter_yaml::LANGUAGE.into()), + ("gitcommit", tree_sitter_gitcommit::LANGUAGE.into()), + ] +} + +/// Load and parse the `config.toml` for a given language name. +pub fn load_config(name: &str) -> LanguageConfig { + let config_toml = String::from_utf8( + GrammarDir::get(&format!("{}/config.toml", name)) + .unwrap_or_else(|| panic!("missing config for language {:?}", name)) + .data + .to_vec(), + ) + .unwrap(); + + let config: LanguageConfig = ::toml::from_str(&config_toml) + .with_context(|| format!("failed to load config.toml for language {name:?}")) + .unwrap(); + + config +} + +/// Load and parse the `config.toml` for a given language name, stripping fields +/// that require grammar support when grammars are not loaded. +pub fn load_config_for_feature(name: &str, grammars_loaded: bool) -> LanguageConfig { + let config = load_config(name); + + if grammars_loaded { + config + } else { + LanguageConfig { + name: config.name, + matcher: config.matcher, + jsx_tag_auto_close: config.jsx_tag_auto_close, + ..Default::default() + } + } +} + +/// Get a raw embedded file by path (relative to `src/`). +/// +/// Returns the file data as bytes, or `None` if the file does not exist. +pub fn get_file(path: &str) -> Option { + GrammarDir::get(path) +} + +/// Load all `.scm` query files for a given language name into a `LanguageQueries`. +/// +/// Multiple `.scm` files with the same prefix (e.g. `highlights.scm` and +/// `highlights_extra.scm`) are concatenated together with their contents appended. +pub fn load_queries(name: &str) -> LanguageQueries { + let mut result = LanguageQueries::default(); + for path in GrammarDir::iter() { + if let Some(remainder) = path.strip_prefix(name).and_then(|p| p.strip_prefix('/')) { + if !remainder.ends_with(".scm") { + continue; + } + for (prefix, query) in QUERY_FILENAME_PREFIXES { + if remainder.starts_with(prefix) { + let contents = asset_str::(path.as_ref()); + match query(&mut result) { + None => *query(&mut result) = Some(contents), + Some(existing) => existing.to_mut().push_str(contents.as_ref()), + } + } + } + } + } + result +} diff --git a/crates/languages/src/javascript/brackets.scm b/crates/grammars/src/javascript/brackets.scm similarity index 100% rename from crates/languages/src/javascript/brackets.scm rename to crates/grammars/src/javascript/brackets.scm diff --git a/crates/languages/src/javascript/config.toml b/crates/grammars/src/javascript/config.toml similarity index 100% rename from crates/languages/src/javascript/config.toml rename to crates/grammars/src/javascript/config.toml diff --git a/crates/languages/src/javascript/debugger.scm b/crates/grammars/src/javascript/debugger.scm similarity index 100% rename from crates/languages/src/javascript/debugger.scm rename to crates/grammars/src/javascript/debugger.scm diff --git a/crates/languages/src/javascript/highlights.scm b/crates/grammars/src/javascript/highlights.scm similarity index 100% rename from crates/languages/src/javascript/highlights.scm rename to crates/grammars/src/javascript/highlights.scm diff --git a/crates/languages/src/javascript/imports.scm b/crates/grammars/src/javascript/imports.scm similarity index 100% rename from crates/languages/src/javascript/imports.scm rename to crates/grammars/src/javascript/imports.scm diff --git a/crates/languages/src/javascript/indents.scm b/crates/grammars/src/javascript/indents.scm similarity index 100% rename from crates/languages/src/javascript/indents.scm rename to crates/grammars/src/javascript/indents.scm diff --git a/crates/languages/src/javascript/injections.scm b/crates/grammars/src/javascript/injections.scm similarity index 100% rename from crates/languages/src/javascript/injections.scm rename to crates/grammars/src/javascript/injections.scm diff --git a/crates/languages/src/javascript/outline.scm b/crates/grammars/src/javascript/outline.scm similarity index 100% rename from crates/languages/src/javascript/outline.scm rename to crates/grammars/src/javascript/outline.scm diff --git a/crates/languages/src/javascript/overrides.scm b/crates/grammars/src/javascript/overrides.scm similarity index 100% rename from crates/languages/src/javascript/overrides.scm rename to crates/grammars/src/javascript/overrides.scm diff --git a/crates/languages/src/javascript/runnables.scm b/crates/grammars/src/javascript/runnables.scm similarity index 100% rename from crates/languages/src/javascript/runnables.scm rename to crates/grammars/src/javascript/runnables.scm diff --git a/crates/languages/src/javascript/textobjects.scm b/crates/grammars/src/javascript/textobjects.scm similarity index 100% rename from crates/languages/src/javascript/textobjects.scm rename to crates/grammars/src/javascript/textobjects.scm diff --git a/crates/languages/src/jsdoc/brackets.scm b/crates/grammars/src/jsdoc/brackets.scm similarity index 100% rename from crates/languages/src/jsdoc/brackets.scm rename to crates/grammars/src/jsdoc/brackets.scm diff --git a/crates/languages/src/jsdoc/config.toml b/crates/grammars/src/jsdoc/config.toml similarity index 100% rename from crates/languages/src/jsdoc/config.toml rename to crates/grammars/src/jsdoc/config.toml diff --git a/crates/languages/src/jsdoc/highlights.scm b/crates/grammars/src/jsdoc/highlights.scm similarity index 100% rename from crates/languages/src/jsdoc/highlights.scm rename to crates/grammars/src/jsdoc/highlights.scm diff --git a/crates/languages/src/json/brackets.scm b/crates/grammars/src/json/brackets.scm similarity index 100% rename from crates/languages/src/json/brackets.scm rename to crates/grammars/src/json/brackets.scm diff --git a/crates/languages/src/json/config.toml b/crates/grammars/src/json/config.toml similarity index 100% rename from crates/languages/src/json/config.toml rename to crates/grammars/src/json/config.toml diff --git a/crates/languages/src/json/highlights.scm b/crates/grammars/src/json/highlights.scm similarity index 100% rename from crates/languages/src/json/highlights.scm rename to crates/grammars/src/json/highlights.scm diff --git a/crates/languages/src/json/indents.scm b/crates/grammars/src/json/indents.scm similarity index 100% rename from crates/languages/src/json/indents.scm rename to crates/grammars/src/json/indents.scm diff --git a/crates/languages/src/json/outline.scm b/crates/grammars/src/json/outline.scm similarity index 100% rename from crates/languages/src/json/outline.scm rename to crates/grammars/src/json/outline.scm diff --git a/crates/languages/src/json/overrides.scm b/crates/grammars/src/json/overrides.scm similarity index 100% rename from crates/languages/src/json/overrides.scm rename to crates/grammars/src/json/overrides.scm diff --git a/crates/languages/src/json/redactions.scm b/crates/grammars/src/json/redactions.scm similarity index 100% rename from crates/languages/src/json/redactions.scm rename to crates/grammars/src/json/redactions.scm diff --git a/crates/languages/src/json/runnables.scm b/crates/grammars/src/json/runnables.scm similarity index 100% rename from crates/languages/src/json/runnables.scm rename to crates/grammars/src/json/runnables.scm diff --git a/crates/languages/src/json/textobjects.scm b/crates/grammars/src/json/textobjects.scm similarity index 100% rename from crates/languages/src/json/textobjects.scm rename to crates/grammars/src/json/textobjects.scm diff --git a/crates/languages/src/jsonc/brackets.scm b/crates/grammars/src/jsonc/brackets.scm similarity index 100% rename from crates/languages/src/jsonc/brackets.scm rename to crates/grammars/src/jsonc/brackets.scm diff --git a/crates/languages/src/jsonc/config.toml b/crates/grammars/src/jsonc/config.toml similarity index 100% rename from crates/languages/src/jsonc/config.toml rename to crates/grammars/src/jsonc/config.toml diff --git a/crates/languages/src/jsonc/highlights.scm b/crates/grammars/src/jsonc/highlights.scm similarity index 100% rename from crates/languages/src/jsonc/highlights.scm rename to crates/grammars/src/jsonc/highlights.scm diff --git a/crates/languages/src/jsonc/indents.scm b/crates/grammars/src/jsonc/indents.scm similarity index 100% rename from crates/languages/src/jsonc/indents.scm rename to crates/grammars/src/jsonc/indents.scm diff --git a/crates/languages/src/jsonc/injections.scm b/crates/grammars/src/jsonc/injections.scm similarity index 100% rename from crates/languages/src/jsonc/injections.scm rename to crates/grammars/src/jsonc/injections.scm diff --git a/crates/languages/src/jsonc/outline.scm b/crates/grammars/src/jsonc/outline.scm similarity index 100% rename from crates/languages/src/jsonc/outline.scm rename to crates/grammars/src/jsonc/outline.scm diff --git a/crates/languages/src/jsonc/overrides.scm b/crates/grammars/src/jsonc/overrides.scm similarity index 100% rename from crates/languages/src/jsonc/overrides.scm rename to crates/grammars/src/jsonc/overrides.scm diff --git a/crates/languages/src/jsonc/redactions.scm b/crates/grammars/src/jsonc/redactions.scm similarity index 100% rename from crates/languages/src/jsonc/redactions.scm rename to crates/grammars/src/jsonc/redactions.scm diff --git a/crates/languages/src/jsonc/textobjects.scm b/crates/grammars/src/jsonc/textobjects.scm similarity index 100% rename from crates/languages/src/jsonc/textobjects.scm rename to crates/grammars/src/jsonc/textobjects.scm diff --git a/crates/languages/src/markdown-inline/config.toml b/crates/grammars/src/markdown-inline/config.toml similarity index 100% rename from crates/languages/src/markdown-inline/config.toml rename to crates/grammars/src/markdown-inline/config.toml diff --git a/crates/languages/src/markdown-inline/highlights.scm b/crates/grammars/src/markdown-inline/highlights.scm similarity index 100% rename from crates/languages/src/markdown-inline/highlights.scm rename to crates/grammars/src/markdown-inline/highlights.scm diff --git a/crates/languages/src/markdown-inline/injections.scm b/crates/grammars/src/markdown-inline/injections.scm similarity index 100% rename from crates/languages/src/markdown-inline/injections.scm rename to crates/grammars/src/markdown-inline/injections.scm diff --git a/crates/languages/src/markdown/brackets.scm b/crates/grammars/src/markdown/brackets.scm similarity index 100% rename from crates/languages/src/markdown/brackets.scm rename to crates/grammars/src/markdown/brackets.scm diff --git a/crates/languages/src/markdown/config.toml b/crates/grammars/src/markdown/config.toml similarity index 100% rename from crates/languages/src/markdown/config.toml rename to crates/grammars/src/markdown/config.toml diff --git a/crates/languages/src/markdown/highlights.scm b/crates/grammars/src/markdown/highlights.scm similarity index 100% rename from crates/languages/src/markdown/highlights.scm rename to crates/grammars/src/markdown/highlights.scm diff --git a/crates/languages/src/markdown/indents.scm b/crates/grammars/src/markdown/indents.scm similarity index 100% rename from crates/languages/src/markdown/indents.scm rename to crates/grammars/src/markdown/indents.scm diff --git a/crates/languages/src/markdown/injections.scm b/crates/grammars/src/markdown/injections.scm similarity index 100% rename from crates/languages/src/markdown/injections.scm rename to crates/grammars/src/markdown/injections.scm diff --git a/crates/languages/src/markdown/outline.scm b/crates/grammars/src/markdown/outline.scm similarity index 100% rename from crates/languages/src/markdown/outline.scm rename to crates/grammars/src/markdown/outline.scm diff --git a/crates/languages/src/markdown/textobjects.scm b/crates/grammars/src/markdown/textobjects.scm similarity index 100% rename from crates/languages/src/markdown/textobjects.scm rename to crates/grammars/src/markdown/textobjects.scm diff --git a/crates/languages/src/python/brackets.scm b/crates/grammars/src/python/brackets.scm similarity index 100% rename from crates/languages/src/python/brackets.scm rename to crates/grammars/src/python/brackets.scm diff --git a/crates/languages/src/python/config.toml b/crates/grammars/src/python/config.toml similarity index 100% rename from crates/languages/src/python/config.toml rename to crates/grammars/src/python/config.toml diff --git a/crates/languages/src/python/debugger.scm b/crates/grammars/src/python/debugger.scm similarity index 100% rename from crates/languages/src/python/debugger.scm rename to crates/grammars/src/python/debugger.scm diff --git a/crates/languages/src/python/highlights.scm b/crates/grammars/src/python/highlights.scm similarity index 100% rename from crates/languages/src/python/highlights.scm rename to crates/grammars/src/python/highlights.scm diff --git a/crates/languages/src/python/imports.scm b/crates/grammars/src/python/imports.scm similarity index 100% rename from crates/languages/src/python/imports.scm rename to crates/grammars/src/python/imports.scm diff --git a/crates/languages/src/python/indents.scm b/crates/grammars/src/python/indents.scm similarity index 100% rename from crates/languages/src/python/indents.scm rename to crates/grammars/src/python/indents.scm diff --git a/crates/languages/src/python/injections.scm b/crates/grammars/src/python/injections.scm similarity index 100% rename from crates/languages/src/python/injections.scm rename to crates/grammars/src/python/injections.scm diff --git a/crates/languages/src/python/outline.scm b/crates/grammars/src/python/outline.scm similarity index 100% rename from crates/languages/src/python/outline.scm rename to crates/grammars/src/python/outline.scm diff --git a/crates/languages/src/python/overrides.scm b/crates/grammars/src/python/overrides.scm similarity index 100% rename from crates/languages/src/python/overrides.scm rename to crates/grammars/src/python/overrides.scm diff --git a/crates/languages/src/python/runnables.scm b/crates/grammars/src/python/runnables.scm similarity index 100% rename from crates/languages/src/python/runnables.scm rename to crates/grammars/src/python/runnables.scm diff --git a/crates/languages/src/python/semantic_token_rules.json b/crates/grammars/src/python/semantic_token_rules.json similarity index 100% rename from crates/languages/src/python/semantic_token_rules.json rename to crates/grammars/src/python/semantic_token_rules.json diff --git a/crates/languages/src/python/textobjects.scm b/crates/grammars/src/python/textobjects.scm similarity index 100% rename from crates/languages/src/python/textobjects.scm rename to crates/grammars/src/python/textobjects.scm diff --git a/crates/languages/src/regex/brackets.scm b/crates/grammars/src/regex/brackets.scm similarity index 100% rename from crates/languages/src/regex/brackets.scm rename to crates/grammars/src/regex/brackets.scm diff --git a/crates/languages/src/regex/config.toml b/crates/grammars/src/regex/config.toml similarity index 100% rename from crates/languages/src/regex/config.toml rename to crates/grammars/src/regex/config.toml diff --git a/crates/languages/src/regex/highlights.scm b/crates/grammars/src/regex/highlights.scm similarity index 100% rename from crates/languages/src/regex/highlights.scm rename to crates/grammars/src/regex/highlights.scm diff --git a/crates/languages/src/rust/brackets.scm b/crates/grammars/src/rust/brackets.scm similarity index 100% rename from crates/languages/src/rust/brackets.scm rename to crates/grammars/src/rust/brackets.scm diff --git a/crates/languages/src/rust/config.toml b/crates/grammars/src/rust/config.toml similarity index 100% rename from crates/languages/src/rust/config.toml rename to crates/grammars/src/rust/config.toml diff --git a/crates/languages/src/rust/debugger.scm b/crates/grammars/src/rust/debugger.scm similarity index 100% rename from crates/languages/src/rust/debugger.scm rename to crates/grammars/src/rust/debugger.scm diff --git a/crates/languages/src/rust/highlights.scm b/crates/grammars/src/rust/highlights.scm similarity index 100% rename from crates/languages/src/rust/highlights.scm rename to crates/grammars/src/rust/highlights.scm diff --git a/crates/languages/src/rust/imports.scm b/crates/grammars/src/rust/imports.scm similarity index 100% rename from crates/languages/src/rust/imports.scm rename to crates/grammars/src/rust/imports.scm diff --git a/crates/languages/src/rust/indents.scm b/crates/grammars/src/rust/indents.scm similarity index 100% rename from crates/languages/src/rust/indents.scm rename to crates/grammars/src/rust/indents.scm diff --git a/crates/languages/src/rust/injections.scm b/crates/grammars/src/rust/injections.scm similarity index 100% rename from crates/languages/src/rust/injections.scm rename to crates/grammars/src/rust/injections.scm diff --git a/crates/languages/src/rust/outline.scm b/crates/grammars/src/rust/outline.scm similarity index 100% rename from crates/languages/src/rust/outline.scm rename to crates/grammars/src/rust/outline.scm diff --git a/crates/languages/src/rust/overrides.scm b/crates/grammars/src/rust/overrides.scm similarity index 100% rename from crates/languages/src/rust/overrides.scm rename to crates/grammars/src/rust/overrides.scm diff --git a/crates/languages/src/rust/runnables.scm b/crates/grammars/src/rust/runnables.scm similarity index 100% rename from crates/languages/src/rust/runnables.scm rename to crates/grammars/src/rust/runnables.scm diff --git a/crates/languages/src/rust/semantic_token_rules.json b/crates/grammars/src/rust/semantic_token_rules.json similarity index 100% rename from crates/languages/src/rust/semantic_token_rules.json rename to crates/grammars/src/rust/semantic_token_rules.json diff --git a/crates/languages/src/rust/textobjects.scm b/crates/grammars/src/rust/textobjects.scm similarity index 100% rename from crates/languages/src/rust/textobjects.scm rename to crates/grammars/src/rust/textobjects.scm diff --git a/crates/languages/src/tsx/brackets.scm b/crates/grammars/src/tsx/brackets.scm similarity index 100% rename from crates/languages/src/tsx/brackets.scm rename to crates/grammars/src/tsx/brackets.scm diff --git a/crates/languages/src/tsx/config.toml b/crates/grammars/src/tsx/config.toml similarity index 100% rename from crates/languages/src/tsx/config.toml rename to crates/grammars/src/tsx/config.toml diff --git a/crates/languages/src/tsx/debugger.scm b/crates/grammars/src/tsx/debugger.scm similarity index 100% rename from crates/languages/src/tsx/debugger.scm rename to crates/grammars/src/tsx/debugger.scm diff --git a/crates/languages/src/tsx/highlights.scm b/crates/grammars/src/tsx/highlights.scm similarity index 100% rename from crates/languages/src/tsx/highlights.scm rename to crates/grammars/src/tsx/highlights.scm diff --git a/crates/languages/src/tsx/imports.scm b/crates/grammars/src/tsx/imports.scm similarity index 100% rename from crates/languages/src/tsx/imports.scm rename to crates/grammars/src/tsx/imports.scm diff --git a/crates/languages/src/tsx/indents.scm b/crates/grammars/src/tsx/indents.scm similarity index 100% rename from crates/languages/src/tsx/indents.scm rename to crates/grammars/src/tsx/indents.scm diff --git a/crates/languages/src/tsx/injections.scm b/crates/grammars/src/tsx/injections.scm similarity index 100% rename from crates/languages/src/tsx/injections.scm rename to crates/grammars/src/tsx/injections.scm diff --git a/crates/languages/src/tsx/outline.scm b/crates/grammars/src/tsx/outline.scm similarity index 100% rename from crates/languages/src/tsx/outline.scm rename to crates/grammars/src/tsx/outline.scm diff --git a/crates/languages/src/tsx/overrides.scm b/crates/grammars/src/tsx/overrides.scm similarity index 100% rename from crates/languages/src/tsx/overrides.scm rename to crates/grammars/src/tsx/overrides.scm diff --git a/crates/languages/src/tsx/runnables.scm b/crates/grammars/src/tsx/runnables.scm similarity index 100% rename from crates/languages/src/tsx/runnables.scm rename to crates/grammars/src/tsx/runnables.scm diff --git a/crates/languages/src/tsx/textobjects.scm b/crates/grammars/src/tsx/textobjects.scm similarity index 100% rename from crates/languages/src/tsx/textobjects.scm rename to crates/grammars/src/tsx/textobjects.scm diff --git a/crates/languages/src/typescript/brackets.scm b/crates/grammars/src/typescript/brackets.scm similarity index 100% rename from crates/languages/src/typescript/brackets.scm rename to crates/grammars/src/typescript/brackets.scm diff --git a/crates/languages/src/typescript/config.toml b/crates/grammars/src/typescript/config.toml similarity index 100% rename from crates/languages/src/typescript/config.toml rename to crates/grammars/src/typescript/config.toml diff --git a/crates/languages/src/typescript/debugger.scm b/crates/grammars/src/typescript/debugger.scm similarity index 100% rename from crates/languages/src/typescript/debugger.scm rename to crates/grammars/src/typescript/debugger.scm diff --git a/crates/languages/src/typescript/highlights.scm b/crates/grammars/src/typescript/highlights.scm similarity index 100% rename from crates/languages/src/typescript/highlights.scm rename to crates/grammars/src/typescript/highlights.scm diff --git a/crates/languages/src/typescript/imports.scm b/crates/grammars/src/typescript/imports.scm similarity index 100% rename from crates/languages/src/typescript/imports.scm rename to crates/grammars/src/typescript/imports.scm diff --git a/crates/languages/src/typescript/indents.scm b/crates/grammars/src/typescript/indents.scm similarity index 100% rename from crates/languages/src/typescript/indents.scm rename to crates/grammars/src/typescript/indents.scm diff --git a/crates/languages/src/typescript/injections.scm b/crates/grammars/src/typescript/injections.scm similarity index 100% rename from crates/languages/src/typescript/injections.scm rename to crates/grammars/src/typescript/injections.scm diff --git a/crates/languages/src/typescript/outline.scm b/crates/grammars/src/typescript/outline.scm similarity index 100% rename from crates/languages/src/typescript/outline.scm rename to crates/grammars/src/typescript/outline.scm diff --git a/crates/languages/src/typescript/overrides.scm b/crates/grammars/src/typescript/overrides.scm similarity index 100% rename from crates/languages/src/typescript/overrides.scm rename to crates/grammars/src/typescript/overrides.scm diff --git a/crates/languages/src/typescript/runnables.scm b/crates/grammars/src/typescript/runnables.scm similarity index 100% rename from crates/languages/src/typescript/runnables.scm rename to crates/grammars/src/typescript/runnables.scm diff --git a/crates/languages/src/typescript/textobjects.scm b/crates/grammars/src/typescript/textobjects.scm similarity index 100% rename from crates/languages/src/typescript/textobjects.scm rename to crates/grammars/src/typescript/textobjects.scm diff --git a/crates/languages/src/yaml/brackets.scm b/crates/grammars/src/yaml/brackets.scm similarity index 100% rename from crates/languages/src/yaml/brackets.scm rename to crates/grammars/src/yaml/brackets.scm diff --git a/crates/languages/src/yaml/config.toml b/crates/grammars/src/yaml/config.toml similarity index 100% rename from crates/languages/src/yaml/config.toml rename to crates/grammars/src/yaml/config.toml diff --git a/crates/languages/src/yaml/highlights.scm b/crates/grammars/src/yaml/highlights.scm similarity index 100% rename from crates/languages/src/yaml/highlights.scm rename to crates/grammars/src/yaml/highlights.scm diff --git a/crates/languages/src/yaml/injections.scm b/crates/grammars/src/yaml/injections.scm similarity index 100% rename from crates/languages/src/yaml/injections.scm rename to crates/grammars/src/yaml/injections.scm diff --git a/crates/languages/src/yaml/outline.scm b/crates/grammars/src/yaml/outline.scm similarity index 100% rename from crates/languages/src/yaml/outline.scm rename to crates/grammars/src/yaml/outline.scm diff --git a/crates/languages/src/yaml/overrides.scm b/crates/grammars/src/yaml/overrides.scm similarity index 100% rename from crates/languages/src/yaml/overrides.scm rename to crates/grammars/src/yaml/overrides.scm diff --git a/crates/languages/src/yaml/redactions.scm b/crates/grammars/src/yaml/redactions.scm similarity index 100% rename from crates/languages/src/yaml/redactions.scm rename to crates/grammars/src/yaml/redactions.scm diff --git a/crates/languages/src/yaml/textobjects.scm b/crates/grammars/src/yaml/textobjects.scm similarity index 100% rename from crates/languages/src/yaml/textobjects.scm rename to crates/grammars/src/yaml/textobjects.scm diff --git a/crates/languages/src/zed-keybind-context/brackets.scm b/crates/grammars/src/zed-keybind-context/brackets.scm similarity index 100% rename from crates/languages/src/zed-keybind-context/brackets.scm rename to crates/grammars/src/zed-keybind-context/brackets.scm diff --git a/crates/languages/src/zed-keybind-context/config.toml b/crates/grammars/src/zed-keybind-context/config.toml similarity index 100% rename from crates/languages/src/zed-keybind-context/config.toml rename to crates/grammars/src/zed-keybind-context/config.toml diff --git a/crates/languages/src/zed-keybind-context/highlights.scm b/crates/grammars/src/zed-keybind-context/highlights.scm similarity index 100% rename from crates/languages/src/zed-keybind-context/highlights.scm rename to crates/grammars/src/zed-keybind-context/highlights.scm diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index a6aece93d519fbb85cee61b75cad5ffbff77cbc6..9f10967e72a6dbde8c97b42c465945386709d3ed 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -24,6 +24,7 @@ use gpui::{ actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; + use notifications::status_toast::{StatusToast, ToastIcon}; use project::{CompletionDisplayOptions, Project}; use settings::{ @@ -2405,9 +2406,10 @@ impl RenderOnce for SyntaxHighlightedText { } let mut run_style = text_style.clone(); - if let Some(highlight_style) = highlight_id.style(syntax_theme) { + if let Some(highlight_style) = syntax_theme.get(highlight_id).cloned() { run_style = run_style.highlight(highlight_style); } + // add the highlighted range runs.push(run_style.to_run(highlight_range.len())); offset = highlight_range.end; diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 5cc2550d471750e4b1cc4744a5a91af748a0de91..cec6421c059335c62db9f8db4485eb939d46db01 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -40,6 +40,7 @@ globset.workspace = true gpui.workspace = true http_client.workspace = true imara-diff.workspace = true +language_core.workspace = true itertools.workspace = true log.workspace = true lsp.workspace = true @@ -48,7 +49,6 @@ postage.workspace = true rand = { workspace = true, optional = true } regex.workspace = true rpc.workspace = true -schemars.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/language/benches/highlight_map.rs b/crates/language/benches/highlight_map.rs index 97c7204deec8088575fc79f3c4507d9143a70666..678bd08a8db40b588c6f2716a14b04048fb46d23 100644 --- a/crates/language/benches/highlight_map.rs +++ b/crates/language/benches/highlight_map.rs @@ -1,6 +1,6 @@ use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; use gpui::rgba; -use language::HighlightMap; +use language::build_highlight_map; use theme::SyntaxTheme; fn syntax_theme(highlight_names: &[&str]) -> SyntaxTheme { @@ -115,8 +115,8 @@ static LARGE_CAPTURE_NAMES: &[&str] = &[ "variable.parameter", ]; -fn bench_highlight_map_new(c: &mut Criterion) { - let mut group = c.benchmark_group("HighlightMap::new"); +fn bench_build_highlight_map(c: &mut Criterion) { + let mut group = c.benchmark_group("build_highlight_map"); for (capture_label, capture_names) in [ ("small_captures", SMALL_CAPTURE_NAMES as &[&str]), @@ -131,7 +131,7 @@ fn bench_highlight_map_new(c: &mut Criterion) { BenchmarkId::new(capture_label, theme_label), &(capture_names, &theme), |b, (capture_names, theme)| { - b.iter(|| HighlightMap::new(black_box(capture_names), black_box(theme))); + b.iter(|| build_highlight_map(black_box(capture_names), black_box(theme))); }, ); } @@ -140,5 +140,5 @@ fn bench_highlight_map_new(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_highlight_map_new); +criterion_group!(benches, bench_build_highlight_map); criterion_main!(benches); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 41aaf5b162d9fc231d5a5f37d20b9b79cf23564b..013025de87ad3957f9ac8d8c58f638baeac1448c 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -16,11 +16,10 @@ use crate::{ unified_diff_with_offsets, }; pub use crate::{ - Grammar, Language, LanguageRegistry, - diagnostic_set::DiagnosticSet, - highlight_map::{HighlightId, HighlightMap}, + Grammar, HighlightId, HighlightMap, Language, LanguageRegistry, diagnostic_set::DiagnosticSet, proto, }; + use anyhow::{Context as _, Result}; use clock::Lamport; pub use clock::ReplicaId; @@ -33,10 +32,8 @@ use gpui::{ Task, TextStyle, }; -use lsp::{LanguageServerId, NumberOrString}; +use lsp::LanguageServerId; use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; -use serde_json::Value; use settings::WorktreeId; use smallvec::SmallVec; use smol::future::yield_now; @@ -252,57 +249,6 @@ struct SelectionSet { lamport_timestamp: clock::Lamport, } -/// A diagnostic associated with a certain range of a buffer. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Diagnostic { - /// The name of the service that produced this diagnostic. - pub source: Option, - /// The ID provided by the dynamic registration that produced this diagnostic. - pub registration_id: Option, - /// A machine-readable code that identifies this diagnostic. - pub code: Option, - pub code_description: Option, - /// Whether this diagnostic is a hint, warning, or error. - pub severity: DiagnosticSeverity, - /// The human-readable message associated with this diagnostic. - pub message: String, - /// The human-readable message (in markdown format) - pub markdown: Option, - /// An id that identifies the group to which this diagnostic belongs. - /// - /// When a language server produces a diagnostic with - /// one or more associated diagnostics, those diagnostics are all - /// assigned a single group ID. - pub group_id: usize, - /// Whether this diagnostic is the primary diagnostic for its group. - /// - /// In a given group, the primary diagnostic is the top-level diagnostic - /// returned by the language server. The non-primary diagnostics are the - /// associated diagnostics. - pub is_primary: bool, - /// Whether this diagnostic is considered to originate from an analysis of - /// files on disk, as opposed to any unsaved buffer contents. This is a - /// property of a given diagnostic source, and is configured for a given - /// language server via the [`LspAdapter::disk_based_diagnostic_sources`](crate::LspAdapter::disk_based_diagnostic_sources) method - /// for the language server. - pub is_disk_based: bool, - /// Whether this diagnostic marks unnecessary code. - pub is_unnecessary: bool, - /// Quick separation of diagnostics groups based by their source. - pub source_kind: DiagnosticSourceKind, - /// Data from language server that produced this diagnostic. Passed back to the LS when we request code actions for this diagnostic. - pub data: Option, - /// Whether to underline the corresponding text range in the editor. - pub underline: bool, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum DiagnosticSourceKind { - Pulled, - Pushed, - Other, -} - /// An operation used to synchronize this buffer with its other replicas. #[derive(Clone, Debug, PartialEq)] pub enum Operation { @@ -749,7 +695,7 @@ impl HighlightedTextBuilder { if let Some(highlight_style) = chunk .syntax_highlight_id - .and_then(|id| id.style(syntax_theme)) + .and_then(|id| syntax_theme.get(id).cloned()) { let highlight_style = override_style.map_or(highlight_style, |override_style| { highlight_style.highlight(override_style) @@ -4551,7 +4497,8 @@ impl BufferSnapshot { let style = chunk .syntax_highlight_id .zip(theme) - .and_then(|(highlight, theme)| highlight.style(theme)); + .and_then(|(highlight, theme)| theme.get(highlight).cloned()); + if let Some(style) = style { let start = text.len(); let end = start + chunk.text.len(); @@ -5836,27 +5783,6 @@ impl operation_queue::Operation for Operation { } } -impl Default for Diagnostic { - fn default() -> Self { - Self { - source: Default::default(), - source_kind: DiagnosticSourceKind::Other, - code: None, - code_description: None, - severity: DiagnosticSeverity::ERROR, - message: Default::default(), - markdown: None, - group_id: 0, - is_primary: false, - is_disk_based: false, - is_unnecessary: false, - underline: true, - data: None, - registration_id: None, - } - } -} - impl IndentSize { /// Returns an [`IndentSize`] representing the given spaces. pub fn spaces(len: u32) -> Self { diff --git a/crates/language/src/diagnostic.rs b/crates/language/src/diagnostic.rs new file mode 100644 index 0000000000000000000000000000000000000000..951feec0da18582b56b361797efc0b346e7b2a04 --- /dev/null +++ b/crates/language/src/diagnostic.rs @@ -0,0 +1 @@ +pub use language_core::diagnostic::{Diagnostic, DiagnosticSourceKind}; diff --git a/crates/language/src/highlight_map.rs b/crates/language/src/highlight_map.rs deleted file mode 100644 index caab0e47f1e10e565aaeed541d99ed0d729b70a8..0000000000000000000000000000000000000000 --- a/crates/language/src/highlight_map.rs +++ /dev/null @@ -1,98 +0,0 @@ -use gpui::HighlightStyle; -use std::sync::Arc; -use theme::SyntaxTheme; - -#[derive(Clone, Debug)] -pub struct HighlightMap(Arc<[HighlightId]>); - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HighlightId(pub u32); - -const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX); - -impl HighlightMap { - pub fn new(capture_names: &[&str], theme: &SyntaxTheme) -> Self { - // For each capture name in the highlight query, find the longest - // key in the theme's syntax styles that matches all of the - // dot-separated components of the capture name. - HighlightMap( - capture_names - .iter() - .map(|capture_name| { - theme - .highlight_id(capture_name) - .map_or(DEFAULT_SYNTAX_HIGHLIGHT_ID, HighlightId) - }) - .collect(), - ) - } - - pub fn get(&self, capture_id: u32) -> HighlightId { - self.0 - .get(capture_id as usize) - .copied() - .unwrap_or(DEFAULT_SYNTAX_HIGHLIGHT_ID) - } -} - -impl HighlightId { - pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1); - pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2); - - pub(crate) fn is_default(&self) -> bool { - *self == DEFAULT_SYNTAX_HIGHLIGHT_ID - } - - pub fn style(&self, theme: &SyntaxTheme) -> Option { - theme.get(self.0 as usize).cloned() - } - - pub fn name<'a>(&self, theme: &'a SyntaxTheme) -> Option<&'a str> { - theme.get_capture_name(self.0 as usize) - } -} - -impl Default for HighlightMap { - fn default() -> Self { - Self(Arc::new([])) - } -} - -impl Default for HighlightId { - fn default() -> Self { - DEFAULT_SYNTAX_HIGHLIGHT_ID - } -} - -#[cfg(test)] -mod tests { - use super::*; - use gpui::rgba; - - #[test] - fn test_highlight_map() { - let theme = SyntaxTheme::new( - [ - ("function", rgba(0x100000ff)), - ("function.method", rgba(0x200000ff)), - ("function.async", rgba(0x300000ff)), - ("variable.builtin.self.rust", rgba(0x400000ff)), - ("variable.builtin", rgba(0x500000ff)), - ("variable", rgba(0x600000ff)), - ] - .iter() - .map(|(name, color)| (name.to_string(), (*color).into())), - ); - - let capture_names = &[ - "function.special", - "function.async.rust", - "variable.builtin.self", - ]; - - let map = HighlightMap::new(capture_names, &theme); - assert_eq!(map.get(0).name(&theme), Some("function")); - assert_eq!(map.get(1).name(&theme), Some("function.async")); - assert_eq!(map.get(2).name(&theme), Some("variable.builtin")); - } -} diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index d9c1256290a0b47a198b700c2a5710c0b1df1795..469759a24c74b8d1349fbc3a66d5037d8ef8587d 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -7,9 +7,10 @@ //! //! Notably we do *not* assign a single language to a single file; in real world a single file can consist of multiple programming languages - HTML is a good example of that - and `language` crate tends to reflect that status quo in its API. mod buffer; +mod diagnostic; mod diagnostic_set; -mod highlight_map; mod language_registry; + pub mod language_settings; mod manifest; pub mod modeline; @@ -23,17 +24,30 @@ mod toolchain; #[cfg(test)] pub mod buffer_tests; -use crate::language_settings::SoftWrap; pub use crate::language_settings::{AutoIndentMode, EditPredictionsMode, IndentGuideSettings}; use anyhow::{Context as _, Result}; use async_trait::async_trait; -use collections::{HashMap, HashSet, IndexSet}; +use collections::{HashMap, HashSet}; use futures::Future; use futures::future::LocalBoxFuture; use futures::lock::OwnedMutexGuard; -use gpui::{App, AsyncApp, Entity, SharedString}; -pub use highlight_map::HighlightMap; +use gpui::{App, AsyncApp, Entity}; use http_client::HttpClient; + +pub use language_core::highlight_map::{HighlightId, HighlightMap}; + +pub use language_core::{ + BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, BracketsConfig, + BracketsPatternConfig, CodeLabel, CodeLabelBuilder, DebugVariablesConfig, DebuggerTextObject, + DecreaseIndentConfig, Grammar, GrammarId, HighlightsConfig, ImportsConfig, IndentConfig, + InjectionConfig, InjectionPatternConfig, JsxTagAutoCloseConfig, LanguageConfig, + LanguageConfigOverride, LanguageId, LanguageMatcher, OrderedListConfig, OutlineConfig, + Override, OverrideConfig, OverrideEntry, PromptResponseContext, RedactionConfig, + RunnableCapture, RunnableConfig, SoftWrap, Symbol, TaskListConfig, TextObject, + TextObjectConfig, ToLspPosition, WrapCharactersConfig, + auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec, + regex_json_schema, regex_vec_json_schema, serialize_regex, +}; pub use language_registry::{ LanguageName, LanguageServerStatusUpdate, LoadedLanguage, ServerHealth, }; @@ -44,13 +58,10 @@ pub use manifest::{ManifestDelegate, ManifestName, ManifestProvider, ManifestQue pub use modeline::{ModelineSettings, parse_modeline}; use parking_lot::Mutex; use regex::Regex; -use schemars::{JsonSchema, SchemaGenerator, json_schema}; use semver::Version; -use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde_json::Value; use settings::WorktreeId; use smol::future::FutureExt as _; -use std::num::NonZeroU32; use std::{ ffi::OsStr, fmt::Debug, @@ -59,10 +70,7 @@ use std::{ ops::{DerefMut, Range}, path::{Path, PathBuf}, str, - sync::{ - Arc, LazyLock, - atomic::{AtomicUsize, Ordering::SeqCst}, - }, + sync::{Arc, LazyLock}, }; use syntax_map::{QueryCursorHandle, SyntaxSnapshot}; use task::RunnableTag; @@ -77,12 +85,12 @@ pub use toolchain::{ LanguageToolchainStore, LocalLanguageToolchainStore, Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata, ToolchainScope, }; -use tree_sitter::{self, Query, QueryCursor, WasmStore, wasmtime}; +use tree_sitter::{self, QueryCursor, WasmStore, wasmtime}; use util::rel_path::RelPath; -use util::serde::default_true; pub use buffer::Operation; pub use buffer::*; +pub use diagnostic::{Diagnostic, DiagnosticSourceKind}; pub use diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup}; pub use language_registry::{ AvailableLanguage, BinaryStatus, LanguageNotFound, LanguageQueries, LanguageRegistry, @@ -96,6 +104,16 @@ pub use syntax_map::{ pub use text::{AnchorRangeExt, LineEnding}; pub use tree_sitter::{Node, Parser, Tree, TreeCursor}; +pub(crate) fn to_settings_soft_wrap(value: language_core::SoftWrap) -> settings::SoftWrap { + match value { + language_core::SoftWrap::None => settings::SoftWrap::None, + language_core::SoftWrap::PreferLine => settings::SoftWrap::PreferLine, + language_core::SoftWrap::EditorWidth => settings::SoftWrap::EditorWidth, + language_core::SoftWrap::PreferredLineLength => settings::SoftWrap::PreferredLineLength, + language_core::SoftWrap::Bounded => settings::SoftWrap::Bounded, + } +} + static QUERY_CURSORS: Mutex> = Mutex::new(vec![]); static PARSERS: Mutex> = Mutex::new(vec![]); @@ -125,8 +143,6 @@ where func(cursor.deref_mut()) } -static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0); -static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0); static WASM_ENGINE: LazyLock = LazyLock::new(|| { wasmtime::Engine::new(&wasmtime::Config::new()).expect("Failed to create Wasmtime engine") }); @@ -188,26 +204,12 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { )) }); -/// Types that represent a position in a buffer, and can be converted into -/// an LSP position, to send to a language server. -pub trait ToLspPosition { - /// Converts the value into an LSP position. - fn to_lsp_position(self) -> lsp::Position; -} - #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Location { pub buffer: Entity, pub range: Range, } -#[derive(Debug, Clone)] -pub struct Symbol { - pub name: String, - pub kind: lsp::SymbolKind, - pub container_name: Option, -} - type ServerBinaryCache = futures::lock::Mutex>; type DownloadableLanguageServerBinary = LocalBoxFuture<'static, Result>; pub type LanguageServerBinaryLocations = LocalBoxFuture< @@ -292,14 +294,12 @@ impl CachedLspAdapter { &self, params: &mut lsp::PublishDiagnosticsParams, server_id: LanguageServerId, - existing_diagnostics: Option<&'_ Buffer>, ) { - self.adapter - .process_diagnostics(params, server_id, existing_diagnostics) + self.adapter.process_diagnostics(params, server_id) } - pub fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic, cx: &App) -> bool { - self.adapter.retain_old_diagnostic(previous_diagnostic, cx) + pub fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic) -> bool { + self.adapter.retain_old_diagnostic(previous_diagnostic) } pub fn underline_diagnostic(&self, diagnostic: &lsp::Diagnostic) -> bool { @@ -397,31 +397,14 @@ pub trait LspAdapterDelegate: Send + Sync { async fn try_exec(&self, binary: LanguageServerBinary) -> Result<()>; } -/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt. -/// This allows adapters to intercept preference selections (like "Always" or "Never") -/// and potentially persist them to Zed's settings. -#[derive(Debug, Clone)] -pub struct PromptResponseContext { - /// The original message shown to the user - pub message: String, - /// The action (button) the user selected - pub selected_action: lsp::MessageActionItem, -} - #[async_trait(?Send)] pub trait LspAdapter: 'static + Send + Sync + DynLspInstaller { fn name(&self) -> LanguageServerName; - fn process_diagnostics( - &self, - _: &mut lsp::PublishDiagnosticsParams, - _: LanguageServerId, - _: Option<&'_ Buffer>, - ) { - } + fn process_diagnostics(&self, _: &mut lsp::PublishDiagnosticsParams, _: LanguageServerId) {} /// When processing new `lsp::PublishDiagnosticsParams` diagnostics, whether to retain previous one(s) or not. - fn retain_old_diagnostic(&self, _previous_diagnostic: &Diagnostic, _cx: &App) -> bool { + fn retain_old_diagnostic(&self, _previous_diagnostic: &Diagnostic) -> bool { false } @@ -812,300 +795,6 @@ where } } -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CodeLabel { - /// The text to display. - pub text: String, - /// Syntax highlighting runs. - pub runs: Vec<(Range, HighlightId)>, - /// The portion of the text that should be used in fuzzy filtering. - pub filter_range: Range, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct CodeLabelBuilder { - /// The text to display. - text: String, - /// Syntax highlighting runs. - runs: Vec<(Range, HighlightId)>, - /// The portion of the text that should be used in fuzzy filtering. - filter_range: Range, -} - -#[derive(Clone, Deserialize, JsonSchema, Debug)] -pub struct LanguageConfig { - /// Human-readable name of the language. - pub name: LanguageName, - /// The name of this language for a Markdown code fence block - pub code_fence_block_name: Option>, - /// Alternative language names that Jupyter kernels may report for this language. - /// Used when a kernel's `language` field differs from Zed's language name. - /// For example, the Nu extension would set this to `["nushell"]`. - #[serde(default)] - pub kernel_language_names: Vec>, - // The name of the grammar in a WASM bundle (experimental). - pub grammar: Option>, - /// The criteria for matching this language to a given file. - #[serde(flatten)] - pub matcher: LanguageMatcher, - /// List of bracket types in a language. - #[serde(default)] - pub brackets: BracketPairConfig, - /// If set to true, auto indentation uses last non empty line to determine - /// the indentation level for a new line. - #[serde(default = "auto_indent_using_last_non_empty_line_default")] - pub auto_indent_using_last_non_empty_line: bool, - // Whether indentation of pasted content should be adjusted based on the context. - #[serde(default)] - pub auto_indent_on_paste: Option, - /// A regex that is used to determine whether the indentation level should be - /// increased in the following line. - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub increase_indent_pattern: Option, - /// A regex that is used to determine whether the indentation level should be - /// decreased in the following line. - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub decrease_indent_pattern: Option, - /// A list of rules for decreasing indentation. Each rule pairs a regex with a set of valid - /// "block-starting" tokens. When a line matches a pattern, its indentation is aligned with - /// the most recent line that began with a corresponding token. This enables context-aware - /// outdenting, like aligning an `else` with its `if`. - #[serde(default)] - pub decrease_indent_patterns: Vec, - /// A list of characters that trigger the automatic insertion of a closing - /// bracket when they immediately precede the point where an opening - /// bracket is inserted. - #[serde(default)] - pub autoclose_before: String, - /// A placeholder used internally by Semantic Index. - #[serde(default)] - pub collapsed_placeholder: String, - /// A line comment string that is inserted in e.g. `toggle comments` action. - /// A language can have multiple flavours of line comments. All of the provided line comments are - /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments. - #[serde(default)] - pub line_comments: Vec>, - /// Delimiters and configuration for recognizing and formatting block comments. - #[serde(default)] - pub block_comment: Option, - /// Delimiters and configuration for recognizing and formatting documentation comments. - #[serde(default, alias = "documentation")] - pub documentation_comment: Option, - /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). - #[serde(default)] - pub unordered_list: Vec>, - /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). - #[serde(default)] - pub ordered_list: Vec, - /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). - #[serde(default)] - pub task_list: Option, - /// A list of additional regex patterns that should be treated as prefixes - /// for creating boundaries during rewrapping, ensuring content from one - /// prefixed section doesn't merge with another (e.g., markdown list items). - /// By default, Zed treats as paragraph and comment prefixes as boundaries. - #[serde(default, deserialize_with = "deserialize_regex_vec")] - #[schemars(schema_with = "regex_vec_json_schema")] - pub rewrap_prefixes: Vec, - /// A list of language servers that are allowed to run on subranges of a given language. - #[serde(default)] - pub scope_opt_in_language_servers: Vec, - #[serde(default)] - pub overrides: HashMap, - /// A list of characters that Zed should treat as word characters for the - /// purpose of features that operate on word boundaries, like 'move to next word end' - /// or a whole-word search in buffer search. - #[serde(default)] - pub word_characters: HashSet, - /// Whether to indent lines using tab characters, as opposed to multiple - /// spaces. - #[serde(default)] - pub hard_tabs: Option, - /// How many columns a tab should occupy. - #[serde(default)] - #[schemars(range(min = 1, max = 128))] - pub tab_size: Option, - /// How to soft-wrap long lines of text. - #[serde(default)] - pub soft_wrap: Option, - /// When set, selections can be wrapped using prefix/suffix pairs on both sides. - #[serde(default)] - pub wrap_characters: Option, - /// The name of a Prettier parser that will be used for this language when no file path is available. - /// If there's a parser name in the language settings, that will be used instead. - #[serde(default)] - pub prettier_parser_name: Option, - /// If true, this language is only for syntax highlighting via an injection into other - /// languages, but should not appear to the user as a distinct language. - #[serde(default)] - pub hidden: bool, - /// If configured, this language contains JSX style tags, and should support auto-closing of those tags. - #[serde(default)] - pub jsx_tag_auto_close: Option, - /// A list of characters that Zed should treat as word characters for completion queries. - #[serde(default)] - pub completion_query_characters: HashSet, - /// A list of characters that Zed should treat as word characters for linked edit operations. - #[serde(default)] - pub linked_edit_characters: HashSet, - /// A list of preferred debuggers for this language. - #[serde(default)] - pub debuggers: IndexSet, - /// A list of import namespace segments that aren't expected to appear in file paths. For - /// example, "super" and "crate" in Rust. - #[serde(default)] - pub ignored_import_segments: HashSet>, - /// Regular expression that matches substrings to omit from import paths, to make the paths more - /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$". - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub import_path_strip_regex: Option, -} - -impl LanguageConfig { - pub const FILE_NAME: &str = "config.toml"; - - pub fn load(config_path: impl AsRef) -> Result { - let config = std::fs::read_to_string(config_path.as_ref())?; - toml::from_str(&config).map_err(Into::into) - } -} - -#[derive(Clone, Debug, Deserialize, Default, JsonSchema)] -pub struct DecreaseIndentConfig { - #[serde(default, deserialize_with = "deserialize_regex")] - #[schemars(schema_with = "regex_json_schema")] - pub pattern: Option, - #[serde(default)] - pub valid_after: Vec, -} - -/// Configuration for continuing ordered lists with auto-incrementing numbers. -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct OrderedListConfig { - /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `). - pub pattern: String, - /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `). - pub format: String, -} - -/// Configuration for continuing task lists on newline. -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct TaskListConfig { - /// The list markers to match (e.g., `- [ ] `, `- [x] `). - pub prefixes: Vec>, - /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). - pub continuation: Arc, -} - -#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] -pub struct LanguageMatcher { - /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`. - #[serde(default)] - pub path_suffixes: Vec, - /// A regex pattern that determines whether the language should be assigned to a file or not. - #[serde( - default, - serialize_with = "serialize_regex", - deserialize_with = "deserialize_regex" - )] - #[schemars(schema_with = "regex_json_schema")] - pub first_line_pattern: Option, - /// Alternative names for this language used in vim/emacs modelines. - /// These are matched case-insensitively against the `mode` (emacs) or - /// `filetype`/`ft` (vim) specified in the modeline. - #[serde(default)] - pub modeline_aliases: Vec, -} - -/// The configuration for JSX tag auto-closing. -#[derive(Clone, Deserialize, JsonSchema, Debug)] -pub struct JsxTagAutoCloseConfig { - /// The name of the node for a opening tag - pub open_tag_node_name: String, - /// The name of the node for an closing tag - pub close_tag_node_name: String, - /// The name of the node for a complete element with children for open and close tags - pub jsx_element_node_name: String, - /// The name of the node found within both opening and closing - /// tags that describes the tag name - pub tag_name_node_name: String, - /// Alternate Node names for tag names. - /// Specifically needed as TSX represents the name in `` - /// as `member_expression` rather than `identifier` as usual - #[serde(default)] - pub tag_name_node_name_alternates: Vec, - /// Some grammars are smart enough to detect a closing tag - /// that is not valid i.e. doesn't match it's corresponding - /// opening tag or does not have a corresponding opening tag - /// This should be set to the name of the node for invalid - /// closing tags if the grammar contains such a node, otherwise - /// detecting already closed tags will not work properly - #[serde(default)] - pub erroneous_close_tag_node_name: Option, - /// See above for erroneous_close_tag_node_name for details - /// This should be set if the node used for the tag name - /// within erroneous closing tags is different from the - /// normal tag name node name - #[serde(default)] - pub erroneous_close_tag_name_node_name: Option, -} - -/// The configuration for block comments for this language. -#[derive(Clone, Debug, JsonSchema, PartialEq)] -pub struct BlockCommentConfig { - /// A start tag of block comment. - pub start: Arc, - /// A end tag of block comment. - pub end: Arc, - /// A character to add as a prefix when a new line is added to a block comment. - pub prefix: Arc, - /// A indent to add for prefix and end line upon new line. - #[schemars(range(min = 1, max = 128))] - pub tab_size: u32, -} - -impl<'de> Deserialize<'de> for BlockCommentConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum BlockCommentConfigHelper { - New { - start: Arc, - end: Arc, - prefix: Arc, - tab_size: u32, - }, - Old([Arc; 2]), - } - - match BlockCommentConfigHelper::deserialize(deserializer)? { - BlockCommentConfigHelper::New { - start, - end, - prefix, - tab_size, - } => Ok(BlockCommentConfig { - start, - end, - prefix, - tab_size, - }), - BlockCommentConfigHelper::Old([start, end]) => Ok(BlockCommentConfig { - start, - end, - prefix: "".into(), - tab_size: 0, - }), - } - } -} - /// Represents a language for the given range. Some languages (e.g. HTML) /// interleave several languages together, thus a single buffer might actually contain /// several nested scopes. @@ -1115,148 +804,6 @@ pub struct LanguageScope { override_id: Option, } -#[derive(Clone, Deserialize, Default, Debug, JsonSchema)] -pub struct LanguageConfigOverride { - #[serde(default)] - pub line_comments: Override>>, - #[serde(default)] - pub block_comment: Override, - #[serde(skip)] - pub disabled_bracket_ixs: Vec, - #[serde(default)] - pub word_characters: Override>, - #[serde(default)] - pub completion_query_characters: Override>, - #[serde(default)] - pub linked_edit_characters: Override>, - #[serde(default)] - pub opt_into_language_servers: Vec, - #[serde(default)] - pub prefer_label_for_snippet: Option, -} - -#[derive(Clone, Deserialize, Debug, Serialize, JsonSchema)] -#[serde(untagged)] -pub enum Override { - Remove { remove: bool }, - Set(T), -} - -impl Default for Override { - fn default() -> Self { - Override::Remove { remove: false } - } -} - -impl Override { - fn as_option<'a>(this: Option<&'a Self>, original: Option<&'a T>) -> Option<&'a T> { - match this { - Some(Self::Set(value)) => Some(value), - Some(Self::Remove { remove: true }) => None, - Some(Self::Remove { remove: false }) | None => original, - } - } -} - -impl Default for LanguageConfig { - fn default() -> Self { - Self { - name: LanguageName::new_static(""), - code_fence_block_name: None, - kernel_language_names: Default::default(), - grammar: None, - matcher: LanguageMatcher::default(), - brackets: Default::default(), - auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(), - auto_indent_on_paste: None, - increase_indent_pattern: Default::default(), - decrease_indent_pattern: Default::default(), - decrease_indent_patterns: Default::default(), - autoclose_before: Default::default(), - line_comments: Default::default(), - block_comment: Default::default(), - documentation_comment: Default::default(), - unordered_list: Default::default(), - ordered_list: Default::default(), - task_list: Default::default(), - rewrap_prefixes: Default::default(), - scope_opt_in_language_servers: Default::default(), - overrides: Default::default(), - word_characters: Default::default(), - collapsed_placeholder: Default::default(), - hard_tabs: None, - tab_size: None, - soft_wrap: None, - wrap_characters: None, - prettier_parser_name: None, - hidden: false, - jsx_tag_auto_close: None, - completion_query_characters: Default::default(), - linked_edit_characters: Default::default(), - debuggers: Default::default(), - ignored_import_segments: Default::default(), - import_path_strip_regex: None, - } - } -} - -#[derive(Clone, Debug, Deserialize, JsonSchema)] -pub struct WrapCharactersConfig { - /// Opening token split into a prefix and suffix. The first caret goes - /// after the prefix (i.e., between prefix and suffix). - pub start_prefix: String, - pub start_suffix: String, - /// Closing token split into a prefix and suffix. The second caret goes - /// after the prefix (i.e., between prefix and suffix). - pub end_prefix: String, - pub end_suffix: String, -} - -fn auto_indent_using_last_non_empty_line_default() -> bool { - true -} - -fn deserialize_regex<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let source = Option::::deserialize(d)?; - if let Some(source) = source { - Ok(Some(regex::Regex::new(&source).map_err(de::Error::custom)?)) - } else { - Ok(None) - } -} - -fn regex_json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { - json_schema!({ - "type": "string" - }) -} - -fn serialize_regex(regex: &Option, serializer: S) -> Result -where - S: Serializer, -{ - match regex { - Some(regex) => serializer.serialize_str(regex.as_str()), - None => serializer.serialize_none(), - } -} - -fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let sources = Vec::::deserialize(d)?; - sources - .into_iter() - .map(|source| regex::Regex::new(&source)) - .collect::>() - .map_err(de::Error::custom) -} - -fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema { - json_schema!({ - "type": "array", - "items": { "type": "string" } - }) -} - #[doc(hidden)] #[cfg(any(test, feature = "test-support"))] pub struct FakeLspAdapter { @@ -1279,79 +826,6 @@ pub struct FakeLspAdapter { >, } -/// Configuration of handling bracket pairs for a given language. -/// -/// This struct includes settings for defining which pairs of characters are considered brackets and -/// also specifies any language-specific scopes where these pairs should be ignored for bracket matching purposes. -#[derive(Clone, Debug, Default, JsonSchema)] -#[schemars(with = "Vec::")] -pub struct BracketPairConfig { - /// A list of character pairs that should be treated as brackets in the context of a given language. - pub pairs: Vec, - /// A list of tree-sitter scopes for which a given bracket should not be active. - /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]` - pub disabled_scopes_by_bracket_ix: Vec>, -} - -impl BracketPairConfig { - pub fn is_closing_brace(&self, c: char) -> bool { - self.pairs.iter().any(|pair| pair.end.starts_with(c)) - } -} - -#[derive(Deserialize, JsonSchema)] -pub struct BracketPairContent { - #[serde(flatten)] - pub bracket_pair: BracketPair, - #[serde(default)] - pub not_in: Vec, -} - -impl<'de> Deserialize<'de> for BracketPairConfig { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - let result = Vec::::deserialize(deserializer)?; - let (brackets, disabled_scopes_by_bracket_ix) = result - .into_iter() - .map(|entry| (entry.bracket_pair, entry.not_in)) - .unzip(); - - Ok(BracketPairConfig { - pairs: brackets, - disabled_scopes_by_bracket_ix, - }) - } -} - -/// Describes a single bracket pair and how an editor should react to e.g. inserting -/// an opening bracket or to a newline character insertion in between `start` and `end` characters. -#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] -pub struct BracketPair { - /// Starting substring for a bracket. - pub start: String, - /// Ending substring for a bracket. - pub end: String, - /// True if `end` should be automatically inserted right after `start` characters. - pub close: bool, - /// True if selected text should be surrounded by `start` and `end` characters. - #[serde(default = "default_true")] - pub surround: bool, - /// True if an extra newline should be inserted while the cursor is in the middle - /// of that bracket pair. - pub newline: bool, -} - -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -pub struct LanguageId(usize); - -impl LanguageId { - pub(crate) fn new() -> Self { - Self(NEXT_LANGUAGE_ID.fetch_add(1, SeqCst)) - } -} - pub struct Language { pub(crate) id: LanguageId, pub(crate) config: LanguageConfig, @@ -1361,184 +835,6 @@ pub struct Language { pub(crate) manifest_name: Option, } -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] -pub struct GrammarId(pub usize); - -impl GrammarId { - pub(crate) fn new() -> Self { - Self(NEXT_GRAMMAR_ID.fetch_add(1, SeqCst)) - } -} - -pub struct Grammar { - id: GrammarId, - pub ts_language: tree_sitter::Language, - pub(crate) error_query: Option, - pub highlights_config: Option, - pub(crate) brackets_config: Option, - pub(crate) redactions_config: Option, - pub(crate) runnable_config: Option, - pub(crate) indents_config: Option, - pub outline_config: Option, - pub text_object_config: Option, - pub(crate) injection_config: Option, - pub(crate) override_config: Option, - pub(crate) debug_variables_config: Option, - pub(crate) imports_config: Option, - pub(crate) highlight_map: Mutex, -} - -pub struct HighlightsConfig { - pub query: Query, - pub identifier_capture_indices: Vec, -} - -struct IndentConfig { - query: Query, - indent_capture_ix: u32, - start_capture_ix: Option, - end_capture_ix: Option, - outdent_capture_ix: Option, - suffixed_start_captures: HashMap, -} - -pub struct OutlineConfig { - pub query: Query, - pub item_capture_ix: u32, - pub name_capture_ix: u32, - pub context_capture_ix: Option, - pub extra_context_capture_ix: Option, - pub open_capture_ix: Option, - pub close_capture_ix: Option, - pub annotation_capture_ix: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum DebuggerTextObject { - Variable, - Scope, -} - -impl DebuggerTextObject { - pub fn from_capture_name(name: &str) -> Option { - match name { - "debug-variable" => Some(DebuggerTextObject::Variable), - "debug-scope" => Some(DebuggerTextObject::Scope), - _ => None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum TextObject { - InsideFunction, - AroundFunction, - InsideClass, - AroundClass, - InsideComment, - AroundComment, -} - -impl TextObject { - pub fn from_capture_name(name: &str) -> Option { - match name { - "function.inside" => Some(TextObject::InsideFunction), - "function.around" => Some(TextObject::AroundFunction), - "class.inside" => Some(TextObject::InsideClass), - "class.around" => Some(TextObject::AroundClass), - "comment.inside" => Some(TextObject::InsideComment), - "comment.around" => Some(TextObject::AroundComment), - _ => None, - } - } - - pub fn around(&self) -> Option { - match self { - TextObject::InsideFunction => Some(TextObject::AroundFunction), - TextObject::InsideClass => Some(TextObject::AroundClass), - TextObject::InsideComment => Some(TextObject::AroundComment), - _ => None, - } - } -} - -pub struct TextObjectConfig { - pub query: Query, - pub text_objects_by_capture_ix: Vec<(u32, TextObject)>, -} - -struct InjectionConfig { - query: Query, - content_capture_ix: u32, - language_capture_ix: Option, - patterns: Vec, -} - -struct RedactionConfig { - pub query: Query, - pub redaction_capture_ix: u32, -} - -#[derive(Clone, Debug, PartialEq)] -enum RunnableCapture { - Named(SharedString), - Run, -} - -struct RunnableConfig { - pub query: Query, - /// A mapping from capture indice to capture kind - pub extra_captures: Vec, -} - -struct OverrideConfig { - query: Query, - values: HashMap, -} - -#[derive(Debug)] -struct OverrideEntry { - name: String, - range_is_inclusive: bool, - value: LanguageConfigOverride, -} - -#[derive(Default, Clone)] -struct InjectionPatternConfig { - language: Option>, - combined: bool, -} - -#[derive(Debug)] -struct BracketsConfig { - query: Query, - open_capture_ix: u32, - close_capture_ix: u32, - patterns: Vec, -} - -#[derive(Clone, Debug, Default)] -struct BracketsPatternConfig { - newline_only: bool, - rainbow_exclude: bool, -} - -pub struct DebugVariablesConfig { - pub query: Query, - pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>, -} - -pub struct ImportsConfig { - pub query: Query, - pub import_ix: u32, - pub name_ix: Option, - pub namespace_ix: Option, - pub source_ix: Option, - pub list_ix: Option, - pub wildcard_ix: Option, - pub alias_ix: Option, -} - impl Language { pub fn new(config: LanguageConfig, ts_language: Option) -> Self { Self::new_with_id(LanguageId::new(), config, ts_language) @@ -1556,25 +852,7 @@ impl Language { Self { id, config, - grammar: ts_language.map(|ts_language| { - Arc::new(Grammar { - id: GrammarId::new(), - highlights_config: None, - brackets_config: None, - outline_config: None, - text_object_config: None, - indents_config: None, - injection_config: None, - override_config: None, - redactions_config: None, - runnable_config: None, - error_query: Query::new(&ts_language, "(ERROR) @error").ok(), - debug_variables_config: None, - imports_config: None, - ts_language, - highlight_map: Default::default(), - }) - }), + grammar: ts_language.map(|ts_language| Arc::new(Grammar::new(ts_language))), context_provider: None, toolchain: None, manifest_name: None, @@ -1597,493 +875,99 @@ impl Language { } pub fn with_queries(mut self, queries: LanguageQueries) -> Result { - if let Some(query) = queries.highlights { - self = self - .with_highlights_query(query.as_ref()) - .context("Error loading highlights query")?; - } - if let Some(query) = queries.brackets { - self = self - .with_brackets_query(query.as_ref()) - .context("Error loading brackets query")?; - } - if let Some(query) = queries.indents { - self = self - .with_indents_query(query.as_ref()) - .context("Error loading indents query")?; - } - if let Some(query) = queries.outline { - self = self - .with_outline_query(query.as_ref()) - .context("Error loading outline query")?; - } - if let Some(query) = queries.injections { - self = self - .with_injection_query(query.as_ref()) - .context("Error loading injection query")?; - } - if let Some(query) = queries.overrides { - self = self - .with_override_query(query.as_ref()) - .context("Error loading override query")?; - } - if let Some(query) = queries.redactions { - self = self - .with_redaction_query(query.as_ref()) - .context("Error loading redaction query")?; - } - if let Some(query) = queries.runnables { - self = self - .with_runnable_query(query.as_ref()) - .context("Error loading runnables query")?; - } - if let Some(query) = queries.text_objects { - self = self - .with_text_object_query(query.as_ref()) - .context("Error loading textobject query")?; - } - if let Some(query) = queries.debugger { - self = self - .with_debug_variables_query(query.as_ref()) - .context("Error loading debug variables query")?; - } - if let Some(query) = queries.imports { - self = self - .with_imports_query(query.as_ref()) - .context("Error loading imports query")?; + if let Some(grammar) = self.grammar.take() { + let grammar = + Arc::try_unwrap(grammar).map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + let grammar = grammar.with_queries(queries, &mut self.config)?; + self.grammar = Some(Arc::new(grammar)); } Ok(self) } - pub fn with_highlights_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut()?; - let query = Query::new(&grammar.ts_language, source)?; - - let mut identifier_capture_indices = Vec::new(); - for name in [ - "variable", - "constant", - "constructor", - "function", - "function.method", - "function.method.call", - "function.special", - "property", - "type", - "type.interface", - ] { - identifier_capture_indices.extend(query.capture_index_for_name(name)); - } - - grammar.highlights_config = Some(HighlightsConfig { - query, - identifier_capture_indices, - }); - - Ok(self) + pub fn with_highlights_query(self, source: &str) -> Result { + self.with_grammar_query(|grammar| grammar.with_highlights_query(source)) } - pub fn with_runnable_query(mut self, source: &str) -> Result { - let grammar = self.grammar_mut()?; - - let query = Query::new(&grammar.ts_language, source)?; - let extra_captures: Vec<_> = query - .capture_names() - .iter() - .map(|&name| match name { - "run" => RunnableCapture::Run, - name => RunnableCapture::Named(name.to_string().into()), - }) - .collect(); - - grammar.runnable_config = Some(RunnableConfig { - extra_captures, - query, - }); - - Ok(self) + pub fn with_runnable_query(self, source: &str) -> Result { + self.with_grammar_query(|grammar| grammar.with_runnable_query(source)) } - pub fn with_outline_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut item_capture_ix = 0; - let mut name_capture_ix = 0; - let mut context_capture_ix = None; - let mut extra_context_capture_ix = None; - let mut open_capture_ix = None; - let mut close_capture_ix = None; - let mut annotation_capture_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "outline", - &[], - &mut [ - Capture::Required("item", &mut item_capture_ix), - Capture::Required("name", &mut name_capture_ix), - Capture::Optional("context", &mut context_capture_ix), - Capture::Optional("context.extra", &mut extra_context_capture_ix), - Capture::Optional("open", &mut open_capture_ix), - Capture::Optional("close", &mut close_capture_ix), - Capture::Optional("annotation", &mut annotation_capture_ix), - ], - ) { - self.grammar_mut()?.outline_config = Some(OutlineConfig { - query, - item_capture_ix, - name_capture_ix, - context_capture_ix, - extra_context_capture_ix, - open_capture_ix, - close_capture_ix, - annotation_capture_ix, - }); - } - Ok(self) + pub fn with_outline_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_outline_query(source, name)) } - pub fn with_text_object_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut text_objects_by_capture_ix = Vec::new(); - for (ix, name) in query.capture_names().iter().enumerate() { - if let Some(text_object) = TextObject::from_capture_name(name) { - text_objects_by_capture_ix.push((ix as u32, text_object)); - } else { - log::warn!( - "unrecognized capture name '{}' in {} textobjects TreeSitter query", - name, - self.config.name, - ); - } - } - - self.grammar_mut()?.text_object_config = Some(TextObjectConfig { - query, - text_objects_by_capture_ix, - }); - Ok(self) + pub fn with_text_object_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| { + grammar.with_text_object_query(source, name) + }) } - pub fn with_debug_variables_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut objects_by_capture_ix = Vec::new(); - for (ix, name) in query.capture_names().iter().enumerate() { - if let Some(text_object) = DebuggerTextObject::from_capture_name(name) { - objects_by_capture_ix.push((ix as u32, text_object)); - } else { - log::warn!( - "unrecognized capture name '{}' in {} debugger TreeSitter query", - name, - self.config.name, - ); - } - } + pub fn with_debug_variables_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| { + grammar.with_debug_variables_query(source, name) + }) + } - self.grammar_mut()?.debug_variables_config = Some(DebugVariablesConfig { - query, - objects_by_capture_ix, - }); - Ok(self) + pub fn with_imports_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_imports_query(source, name)) } - pub fn with_imports_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut import_ix = 0; - let mut name_ix = None; - let mut namespace_ix = None; - let mut source_ix = None; - let mut list_ix = None; - let mut wildcard_ix = None; - let mut alias_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "imports", - &[], - &mut [ - Capture::Required("import", &mut import_ix), - Capture::Optional("name", &mut name_ix), - Capture::Optional("namespace", &mut namespace_ix), - Capture::Optional("source", &mut source_ix), - Capture::Optional("list", &mut list_ix), - Capture::Optional("wildcard", &mut wildcard_ix), - Capture::Optional("alias", &mut alias_ix), - ], - ) { - self.grammar_mut()?.imports_config = Some(ImportsConfig { - query, - import_ix, - name_ix, - namespace_ix, - source_ix, - list_ix, - wildcard_ix, - alias_ix, - }); - } - return Ok(self); - } - - pub fn with_brackets_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut open_capture_ix = 0; - let mut close_capture_ix = 0; - if populate_capture_indices( - &query, - &self.config.name, - "brackets", - &[], - &mut [ - Capture::Required("open", &mut open_capture_ix), - Capture::Required("close", &mut close_capture_ix), - ], - ) { - let patterns = (0..query.pattern_count()) - .map(|ix| { - let mut config = BracketsPatternConfig::default(); - for setting in query.property_settings(ix) { - let setting_key = setting.key.as_ref(); - if setting_key == "newline.only" { - config.newline_only = true - } - if setting_key == "rainbow.exclude" { - config.rainbow_exclude = true - } - } - config - }) - .collect(); - self.grammar_mut()?.brackets_config = Some(BracketsConfig { - query, - open_capture_ix, - close_capture_ix, - patterns, - }); - } - Ok(self) + pub fn with_brackets_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_brackets_query(source, name)) } - pub fn with_indents_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut indent_capture_ix = 0; - let mut start_capture_ix = None; - let mut end_capture_ix = None; - let mut outdent_capture_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "indents", - &["start."], - &mut [ - Capture::Required("indent", &mut indent_capture_ix), - Capture::Optional("start", &mut start_capture_ix), - Capture::Optional("end", &mut end_capture_ix), - Capture::Optional("outdent", &mut outdent_capture_ix), - ], - ) { - let mut suffixed_start_captures = HashMap::default(); - for (ix, name) in query.capture_names().iter().enumerate() { - if let Some(suffix) = name.strip_prefix("start.") { - suffixed_start_captures.insert(ix as u32, suffix.to_owned().into()); - } - } + pub fn with_indents_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_indents_query(source, name)) + } - self.grammar_mut()?.indents_config = Some(IndentConfig { - query, - indent_capture_ix, - start_capture_ix, - end_capture_ix, - outdent_capture_ix, - suffixed_start_captures, - }); - } - Ok(self) + pub fn with_injection_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_injection_query(source, name)) } - pub fn with_injection_query(mut self, source: &str) -> Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut language_capture_ix = None; - let mut injection_language_capture_ix = None; - let mut content_capture_ix = None; - let mut injection_content_capture_ix = None; - if populate_capture_indices( - &query, - &self.config.name, - "injections", - &[], - &mut [ - Capture::Optional("language", &mut language_capture_ix), - Capture::Optional("injection.language", &mut injection_language_capture_ix), - Capture::Optional("content", &mut content_capture_ix), - Capture::Optional("injection.content", &mut injection_content_capture_ix), - ], - ) { - language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { - (None, Some(ix)) => Some(ix), - (Some(_), Some(_)) => { - anyhow::bail!("both language and injection.language captures are present"); - } - _ => language_capture_ix, - }; - content_capture_ix = match (content_capture_ix, injection_content_capture_ix) { - (None, Some(ix)) => Some(ix), - (Some(_), Some(_)) => { - anyhow::bail!("both content and injection.content captures are present") - } - _ => content_capture_ix, - }; - let patterns = (0..query.pattern_count()) - .map(|ix| { - let mut config = InjectionPatternConfig::default(); - for setting in query.property_settings(ix) { - match setting.key.as_ref() { - "language" | "injection.language" => { - config.language.clone_from(&setting.value); - } - "combined" | "injection.combined" => { - config.combined = true; - } - _ => {} - } - } - config - }) - .collect(); - if let Some(content_capture_ix) = content_capture_ix { - self.grammar_mut()?.injection_config = Some(InjectionConfig { - query, - language_capture_ix, - content_capture_ix, - patterns, - }); - } else { - log::error!( - "missing required capture in injections {} TreeSitter query: \ - content or injection.content", - &self.config.name, - ); - } + pub fn with_override_query(mut self, source: &str) -> Result { + if let Some(grammar_arc) = self.grammar.take() { + let grammar = Arc::try_unwrap(grammar_arc) + .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + let grammar = grammar.with_override_query( + source, + &self.config.name, + &self.config.overrides, + &mut self.config.brackets, + &self.config.scope_opt_in_language_servers, + )?; + self.grammar = Some(Arc::new(grammar)); } Ok(self) } - pub fn with_override_query(mut self, source: &str) -> anyhow::Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - - let mut override_configs_by_id = HashMap::default(); - for (ix, mut name) in query.capture_names().iter().copied().enumerate() { - let mut range_is_inclusive = false; - if name.starts_with('_') { - continue; - } - if let Some(prefix) = name.strip_suffix(".inclusive") { - name = prefix; - range_is_inclusive = true; - } - - let value = self.config.overrides.get(name).cloned().unwrap_or_default(); - for server_name in &value.opt_into_language_servers { - if !self - .config - .scope_opt_in_language_servers - .contains(server_name) - { - util::debug_panic!( - "Server {server_name:?} has been opted-in by scope {name:?} but has not been marked as an opt-in server" - ); - } - } - - override_configs_by_id.insert( - ix as u32, - OverrideEntry { - name: name.to_string(), - range_is_inclusive, - value, - }, - ); - } - - let referenced_override_names = self.config.overrides.keys().chain( - self.config - .brackets - .disabled_scopes_by_bracket_ix - .iter() - .flatten(), - ); - - for referenced_name in referenced_override_names { - if !override_configs_by_id - .values() - .any(|entry| entry.name == *referenced_name) - { - anyhow::bail!( - "language {:?} has overrides in config not in query: {referenced_name:?}", - self.config.name - ); - } - } + pub fn with_redaction_query(self, source: &str) -> Result { + self.with_grammar_query_and_name(|grammar, name| grammar.with_redaction_query(source, name)) + } - for entry in override_configs_by_id.values_mut() { - entry.value.disabled_bracket_ixs = self - .config - .brackets - .disabled_scopes_by_bracket_ix - .iter() - .enumerate() - .filter_map(|(ix, disabled_scope_names)| { - if disabled_scope_names.contains(&entry.name) { - Some(ix as u16) - } else { - None - } - }) - .collect(); + fn with_grammar_query( + mut self, + build: impl FnOnce(Grammar) -> Result, + ) -> Result { + if let Some(grammar_arc) = self.grammar.take() { + let grammar = Arc::try_unwrap(grammar_arc) + .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + self.grammar = Some(Arc::new(build(grammar)?)); } - - self.config.brackets.disabled_scopes_by_bracket_ix.clear(); - - let grammar = self.grammar_mut()?; - grammar.override_config = Some(OverrideConfig { - query, - values: override_configs_by_id, - }); Ok(self) } - pub fn with_redaction_query(mut self, source: &str) -> anyhow::Result { - let query = Query::new(&self.expect_grammar()?.ts_language, source)?; - let mut redaction_capture_ix = 0; - if populate_capture_indices( - &query, - &self.config.name, - "redactions", - &[], - &mut [Capture::Required("redact", &mut redaction_capture_ix)], - ) { - self.grammar_mut()?.redactions_config = Some(RedactionConfig { - query, - redaction_capture_ix, - }); + fn with_grammar_query_and_name( + mut self, + build: impl FnOnce(Grammar, &LanguageName) -> Result, + ) -> Result { + if let Some(grammar_arc) = self.grammar.take() { + let grammar = Arc::try_unwrap(grammar_arc) + .map_err(|_| anyhow::anyhow!("cannot mutate grammar"))?; + self.grammar = Some(Arc::new(build(grammar, &self.config.name)?)); } Ok(self) } - fn expect_grammar(&self) -> Result<&Grammar> { - self.grammar - .as_ref() - .map(|grammar| grammar.as_ref()) - .context("no grammar for language") - } - - fn grammar_mut(&mut self) -> Result<&mut Grammar> { - Arc::get_mut(self.grammar.as_mut().context("no grammar for language")?) - .context("cannot mutate grammar") - } - pub fn name(&self) -> LanguageName { self.config.name.clone() } @@ -2130,7 +1014,7 @@ impl Language { ) -> Vec<(Range, HighlightId)> { let mut result = Vec::new(); if let Some(grammar) = &self.grammar { - let tree = grammar.parse_text(text, None); + let tree = parse_text(grammar, text, None); let captures = SyntaxSnapshot::single_tree_captures(range.clone(), text, &tree, self, |grammar| { grammar @@ -2168,7 +1052,7 @@ impl Language { && let Some(highlights_config) = &grammar.highlights_config { *grammar.highlight_map.lock() = - HighlightMap::new(highlights_config.query.capture_names(), theme); + build_highlight_map(highlights_config.query.capture_names(), theme); } } @@ -2196,6 +1080,15 @@ impl Language { } } +#[inline] +pub fn build_highlight_map(capture_names: &[&str], theme: &SyntaxTheme) -> HighlightMap { + HighlightMap::from_ids(capture_names.iter().map(|capture_name| { + theme + .highlight_id(capture_name) + .map_or(HighlightId::default(), HighlightId) + })) +} + impl LanguageScope { pub fn path_suffixes(&self) -> &[String] { self.language.path_suffixes() @@ -2377,85 +1270,37 @@ impl Debug for Language { } } -impl Grammar { - pub fn id(&self) -> GrammarId { - self.id - } - - fn parse_text(&self, text: &Rope, old_tree: Option) -> Tree { - with_parser(|parser| { - parser - .set_language(&self.ts_language) - .expect("incompatible grammar"); - let mut chunks = text.chunks_in_range(0..text.len()); - parser - .parse_with_options( - &mut move |offset, _| { - chunks.seek(offset); - chunks.next().unwrap_or("").as_bytes() - }, - old_tree.as_ref(), - None, - ) - .unwrap() - }) - } - - pub fn highlight_map(&self) -> HighlightMap { - self.highlight_map.lock().clone() - } - - pub fn highlight_id_for_name(&self, name: &str) -> Option { - let capture_id = self - .highlights_config - .as_ref()? - .query - .capture_index_for_name(name)?; - Some(self.highlight_map.lock().get(capture_id)) - } - - pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { - self.debug_variables_config.as_ref() - } - - pub fn imports_config(&self) -> Option<&ImportsConfig> { - self.imports_config.as_ref() - } +pub(crate) fn parse_text(grammar: &Grammar, text: &Rope, old_tree: Option) -> Tree { + with_parser(|parser| { + parser + .set_language(&grammar.ts_language) + .expect("incompatible grammar"); + let mut chunks = text.chunks_in_range(0..text.len()); + parser + .parse_with_options( + &mut move |offset, _| { + chunks.seek(offset); + chunks.next().unwrap_or("").as_bytes() + }, + old_tree.as_ref(), + None, + ) + .unwrap() + }) } -impl CodeLabelBuilder { - pub fn respan_filter_range(&mut self, filter_text: Option<&str>) { - self.filter_range = filter_text - .and_then(|filter| self.text.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..self.text.len()); - } - - pub fn push_str(&mut self, text: &str, highlight: Option) { - let start_ix = self.text.len(); - self.text.push_str(text); - if let Some(highlight) = highlight { - let end_ix = self.text.len(); - self.runs.push((start_ix..end_ix, highlight)); - } - } - - pub fn build(mut self) -> CodeLabel { - if self.filter_range.end == 0 { - self.respan_filter_range(None); - } - CodeLabel { - text: self.text, - runs: self.runs, - filter_range: self.filter_range, - } - } +pub trait CodeLabelExt { + fn fallback_for_completion( + item: &lsp::CompletionItem, + language: Option<&Language>, + ) -> CodeLabel; } -impl CodeLabel { - pub fn fallback_for_completion( +impl CodeLabelExt for CodeLabel { + fn fallback_for_completion( item: &lsp::CompletionItem, language: Option<&Language>, - ) -> Self { + ) -> CodeLabel { let highlight_id = item.kind.and_then(|kind| { let grammar = language?.grammar()?; use lsp::CompletionItemKind as Kind; @@ -2506,98 +1351,12 @@ impl CodeLabel { .as_deref() .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len())) .unwrap_or(0..label_length); - Self { + CodeLabel { text, runs, filter_range, } } - - pub fn plain(text: String, filter_text: Option<&str>) -> Self { - Self::filtered(text.clone(), text.len(), filter_text, Vec::new()) - } - - pub fn filtered( - text: String, - label_len: usize, - filter_text: Option<&str>, - runs: Vec<(Range, HighlightId)>, - ) -> Self { - assert!(label_len <= text.len()); - let filter_range = filter_text - .and_then(|filter| text.find(filter).map(|ix| ix..ix + filter.len())) - .unwrap_or(0..label_len); - Self::new(text, filter_range, runs) - } - - pub fn new( - text: String, - filter_range: Range, - runs: Vec<(Range, HighlightId)>, - ) -> Self { - assert!( - text.get(filter_range.clone()).is_some(), - "invalid filter range" - ); - runs.iter().for_each(|(range, _)| { - assert!( - text.get(range.clone()).is_some(), - "invalid run range with inputs. Requested range {range:?} in text '{text}'", - ); - }); - Self { - runs, - filter_range, - text, - } - } - - pub fn text(&self) -> &str { - self.text.as_str() - } - - pub fn filter_text(&self) -> &str { - &self.text[self.filter_range.clone()] - } -} - -impl From for CodeLabel { - fn from(value: String) -> Self { - Self::plain(value, None) - } -} - -impl From<&str> for CodeLabel { - fn from(value: &str) -> Self { - Self::plain(value.to_string(), None) - } -} - -impl Ord for LanguageMatcher { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.path_suffixes.cmp(&other.path_suffixes).then_with(|| { - self.first_line_pattern - .as_ref() - .map(Regex::as_str) - .cmp(&other.first_line_pattern.as_ref().map(Regex::as_str)) - }) - } -} - -impl PartialOrd for LanguageMatcher { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Eq for LanguageMatcher {} - -impl PartialEq for LanguageMatcher { - fn eq(&self, other: &Self) -> bool { - self.path_suffixes == other.path_suffixes - && self.first_line_pattern.as_ref().map(Regex::as_str) - == other.first_line_pattern.as_ref().map(Regex::as_str) - } } #[cfg(any(test, feature = "test-support"))] @@ -2698,68 +1457,6 @@ impl LspAdapter for FakeLspAdapter { } } -enum Capture<'a> { - Required(&'static str, &'a mut u32), - Optional(&'static str, &'a mut Option), -} - -fn populate_capture_indices( - query: &Query, - language_name: &LanguageName, - query_type: &str, - expected_prefixes: &[&str], - captures: &mut [Capture<'_>], -) -> bool { - let mut found_required_indices = Vec::new(); - 'outer: for (ix, name) in query.capture_names().iter().enumerate() { - for (required_ix, capture) in captures.iter_mut().enumerate() { - match capture { - Capture::Required(capture_name, index) if capture_name == name => { - **index = ix as u32; - found_required_indices.push(required_ix); - continue 'outer; - } - Capture::Optional(capture_name, index) if capture_name == name => { - **index = Some(ix as u32); - continue 'outer; - } - _ => {} - } - } - if !name.starts_with("_") - && !expected_prefixes - .iter() - .any(|&prefix| name.starts_with(prefix)) - { - log::warn!( - "unrecognized capture name '{}' in {} {} TreeSitter query \ - (suppress this warning by prefixing with '_')", - name, - language_name, - query_type - ); - } - } - let mut missing_required_captures = Vec::new(); - for (capture_ix, capture) in captures.iter().enumerate() { - if let Capture::Required(capture_name, _) = capture - && !found_required_indices.contains(&capture_ix) - { - missing_required_captures.push(*capture_name); - } - } - let success = missing_required_captures.is_empty(); - if !success { - log::error!( - "missing required capture(s) in {} {} TreeSitter query: {}", - language_name, - query_type, - missing_required_captures.join(", ") - ); - } - success -} - pub fn point_to_lsp(point: PointUtf16) -> lsp::Position { lsp::Position::new(point.row, point.column) } @@ -2855,35 +1552,35 @@ pub fn rust_lang() -> Arc { ) .with_queries(LanguageQueries { outline: Some(Cow::from(include_str!( - "../../languages/src/rust/outline.scm" + "../../grammars/src/rust/outline.scm" ))), indents: Some(Cow::from(include_str!( - "../../languages/src/rust/indents.scm" + "../../grammars/src/rust/indents.scm" ))), brackets: Some(Cow::from(include_str!( - "../../languages/src/rust/brackets.scm" + "../../grammars/src/rust/brackets.scm" ))), text_objects: Some(Cow::from(include_str!( - "../../languages/src/rust/textobjects.scm" + "../../grammars/src/rust/textobjects.scm" ))), highlights: Some(Cow::from(include_str!( - "../../languages/src/rust/highlights.scm" + "../../grammars/src/rust/highlights.scm" ))), injections: Some(Cow::from(include_str!( - "../../languages/src/rust/injections.scm" + "../../grammars/src/rust/injections.scm" ))), overrides: Some(Cow::from(include_str!( - "../../languages/src/rust/overrides.scm" + "../../grammars/src/rust/overrides.scm" ))), redactions: None, runnables: Some(Cow::from(include_str!( - "../../languages/src/rust/runnables.scm" + "../../grammars/src/rust/runnables.scm" ))), debugger: Some(Cow::from(include_str!( - "../../languages/src/rust/debugger.scm" + "../../grammars/src/rust/debugger.scm" ))), imports: Some(Cow::from(include_str!( - "../../languages/src/rust/imports.scm" + "../../grammars/src/rust/imports.scm" ))), }) .expect("Could not parse queries"); @@ -2908,19 +1605,19 @@ pub fn markdown_lang() -> Arc { ) .with_queries(LanguageQueries { brackets: Some(Cow::from(include_str!( - "../../languages/src/markdown/brackets.scm" + "../../grammars/src/markdown/brackets.scm" ))), injections: Some(Cow::from(include_str!( - "../../languages/src/markdown/injections.scm" + "../../grammars/src/markdown/injections.scm" ))), highlights: Some(Cow::from(include_str!( - "../../languages/src/markdown/highlights.scm" + "../../grammars/src/markdown/highlights.scm" ))), indents: Some(Cow::from(include_str!( - "../../languages/src/markdown/indents.scm" + "../../grammars/src/markdown/indents.scm" ))), outline: Some(Cow::from(include_str!( - "../../languages/src/markdown/outline.scm" + "../../grammars/src/markdown/outline.scm" ))), ..LanguageQueries::default() }) @@ -2931,10 +1628,38 @@ pub fn markdown_lang() -> Arc { #[cfg(test)] mod tests { use super::*; - use gpui::TestAppContext; + use gpui::{TestAppContext, rgba}; use pretty_assertions::assert_matches; + #[test] + fn test_highlight_map() { + let theme = SyntaxTheme::new( + [ + ("function", rgba(0x100000ff)), + ("function.method", rgba(0x200000ff)), + ("function.async", rgba(0x300000ff)), + ("variable.builtin.self.rust", rgba(0x400000ff)), + ("variable.builtin", rgba(0x500000ff)), + ("variable", rgba(0x600000ff)), + ] + .iter() + .map(|(name, color)| (name.to_string(), (*color).into())), + ); + + let capture_names = &[ + "function.special", + "function.async.rust", + "variable.builtin.self", + ]; + + let map = build_highlight_map(capture_names, &theme); + assert_eq!(theme.get_capture_name(map.get(0)), Some("function")); + assert_eq!(theme.get_capture_name(map.get(1)), Some("function.async")); + assert_eq!(theme.get_capture_name(map.get(2)), Some("variable.builtin")); + } + #[gpui::test(iterations = 10)] + async fn test_language_loading(cx: &mut TestAppContext) { let languages = LanguageRegistry::test(cx.executor()); let languages = Arc::new(languages); diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index 0e13935aa42ac93d284b4606e65337158f18e1d0..2ac6ef456d2ee17c8710ec1c37f22ff34a648e4d 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -5,6 +5,10 @@ use crate::{ }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, HashMap, HashSet, hash_map}; +pub use language_core::{ + BinaryStatus, LanguageName, LanguageQueries, LanguageServerStatusUpdate, + QUERY_FILENAME_PREFIXES, ServerHealth, +}; use settings::{AllLanguageSettingsContent, LanguageSettingsContent}; use futures::{ @@ -12,15 +16,13 @@ use futures::{ channel::{mpsc, oneshot}, }; use globset::GlobSet; -use gpui::{App, BackgroundExecutor, SharedString}; +use gpui::{App, BackgroundExecutor}; use lsp::LanguageServerId; use parking_lot::{Mutex, RwLock}; use postage::watch; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; + use smallvec::SmallVec; use std::{ - borrow::{Borrow, Cow}, cell::LazyCell, ffi::OsStr, ops::Not, @@ -33,91 +35,6 @@ use theme::Theme; use unicase::UniCase; use util::{ResultExt, maybe, post_inc}; -#[derive( - Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, -)] -pub struct LanguageName(pub SharedString); - -impl LanguageName { - pub fn new(s: &str) -> Self { - Self(SharedString::new(s)) - } - - pub fn new_static(s: &'static str) -> Self { - Self(SharedString::new_static(s)) - } - - pub fn from_proto(s: String) -> Self { - Self(SharedString::from(s)) - } - - pub fn to_proto(&self) -> String { - self.0.to_string() - } - - pub fn lsp_id(&self) -> String { - match self.0.as_ref() { - "Plain Text" => "plaintext".to_string(), - language_name => language_name.to_lowercase(), - } - } -} - -impl From for SharedString { - fn from(value: LanguageName) -> Self { - value.0 - } -} - -impl From for LanguageName { - fn from(value: SharedString) -> Self { - LanguageName(value) - } -} - -impl AsRef for LanguageName { - fn as_ref(&self) -> &str { - self.0.as_ref() - } -} - -impl Borrow for LanguageName { - fn borrow(&self) -> &str { - self.0.as_ref() - } -} - -impl PartialEq for LanguageName { - fn eq(&self, other: &str) -> bool { - self.0.as_ref() == other - } -} - -impl PartialEq<&str> for LanguageName { - fn eq(&self, other: &&str) -> bool { - self.0.as_ref() == *other - } -} - -impl std::fmt::Display for LanguageName { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From<&'static str> for LanguageName { - fn from(str: &'static str) -> Self { - Self(SharedString::new_static(str)) - } -} - -impl From for String { - fn from(value: LanguageName) -> Self { - let value: &str = &value.0; - Self::from(value) - } -} - pub struct LanguageRegistry { state: RwLock, language_server_download_dir: Option>, @@ -153,31 +70,6 @@ pub struct FakeLanguageServerEntry { pub _server: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum LanguageServerStatusUpdate { - Binary(BinaryStatus), - Health(ServerHealth, Option), -} - -#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, Copy)] -#[serde(rename_all = "camelCase")] -pub enum ServerHealth { - Ok, - Warning, - Error, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum BinaryStatus { - None, - CheckingForUpdate, - Downloading, - Starting, - Stopping, - Stopped, - Failed { error: String }, -} - #[derive(Clone)] pub struct AvailableLanguage { id: LanguageId, @@ -232,39 +124,6 @@ impl std::fmt::Display for LanguageNotFound { } } -pub const QUERY_FILENAME_PREFIXES: &[( - &str, - fn(&mut LanguageQueries) -> &mut Option>, -)] = &[ - ("highlights", |q| &mut q.highlights), - ("brackets", |q| &mut q.brackets), - ("outline", |q| &mut q.outline), - ("indents", |q| &mut q.indents), - ("injections", |q| &mut q.injections), - ("overrides", |q| &mut q.overrides), - ("redactions", |q| &mut q.redactions), - ("runnables", |q| &mut q.runnables), - ("debugger", |q| &mut q.debugger), - ("textobjects", |q| &mut q.text_objects), - ("imports", |q| &mut q.imports), -]; - -/// Tree-sitter language queries for a given language. -#[derive(Debug, Default)] -pub struct LanguageQueries { - pub highlights: Option>, - pub brackets: Option>, - pub indents: Option>, - pub outline: Option>, - pub injections: Option>, - pub overrides: Option>, - pub redactions: Option>, - pub runnables: Option>, - pub text_objects: Option>, - pub debugger: Option>, - pub imports: Option>, -} - #[derive(Clone, Default)] struct ServerStatusSender { txs: Arc>>>, @@ -1261,7 +1120,7 @@ impl LanguageRegistryState { LanguageSettingsContent { tab_size: language.config.tab_size, hard_tabs: language.config.hard_tabs, - soft_wrap: language.config.soft_wrap, + soft_wrap: language.config.soft_wrap.map(crate::to_settings_soft_wrap), auto_indent_on_paste: language.config.auto_indent_on_paste, ..Default::default() }, diff --git a/crates/language/src/manifest.rs b/crates/language/src/manifest.rs index 82ed164a032cb18d2d011f59938a0cd1410ba60f..a155ac28332e8b1d4f5a2c238e3622169787789c 100644 --- a/crates/language/src/manifest.rs +++ b/crates/language/src/manifest.rs @@ -1,43 +1,12 @@ -use std::{borrow::Borrow, sync::Arc}; +use std::sync::Arc; -use gpui::SharedString; use settings::WorktreeId; use util::rel_path::RelPath; -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ManifestName(SharedString); +// Re-export ManifestName from language_core. +pub use language_core::ManifestName; -impl Borrow for ManifestName { - fn borrow(&self) -> &SharedString { - &self.0 - } -} - -impl Borrow for ManifestName { - fn borrow(&self) -> &str { - &self.0 - } -} - -impl From for ManifestName { - fn from(value: SharedString) -> Self { - Self(value) - } -} - -impl From for SharedString { - fn from(value: ManifestName) -> Self { - value.0 - } -} - -impl AsRef for ManifestName { - fn as_ref(&self) -> &SharedString { - &self.0 - } -} - -/// Represents a manifest query; given a path to a file, [ManifestSearcher] is tasked with finding a path to the directory containing the manifest for that file. +/// Represents a manifest query; given a path to a file, the manifest provider is tasked with finding a path to the directory containing the manifest for that file. /// /// Since parts of the path might have already been explored, there's an additional `depth` parameter that indicates to what ancestry level a given path should be explored. /// For example, given a path like `foo/bar/baz`, a depth of 2 would explore `foo/bar/baz` and `foo/bar`, but not `foo`. diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index c5931c474d2962fc7ceb66954f2f00d3bf14b4f8..f2f79b9a793f303fef66fb4266d67f1fbd2ed52d 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -1121,7 +1121,7 @@ impl<'a> SyntaxMapCaptures<'a> { let grammar_index = result .grammars .iter() - .position(|g| g.id == grammar.id()) + .position(|g| g.id() == grammar.id()) .unwrap_or_else(|| { result.grammars.push(grammar); result.grammars.len() - 1 @@ -1265,7 +1265,7 @@ impl<'a> SyntaxMapMatches<'a> { let grammar_index = result .grammars .iter() - .position(|g| g.id == grammar.id()) + .position(|g| g.id() == grammar.id()) .unwrap_or_else(|| { result.grammars.push(grammar); result.grammars.len() - 1 diff --git a/crates/language/src/syntax_map/syntax_map_tests.rs b/crates/language/src/syntax_map/syntax_map_tests.rs index b7fec897b98aed7902cd25de65e008ba58ee55f9..247076b6f25e3cf62913c93d65ae352109effafa 100644 --- a/crates/language/src/syntax_map/syntax_map_tests.rs +++ b/crates/language/src/syntax_map/syntax_map_tests.rs @@ -1492,7 +1492,7 @@ fn python_lang() -> Language { ) .with_queries(LanguageQueries { injections: Some(Cow::from(include_str!( - "../../../languages/src/python/injections.scm" + "../../../grammars/src/python/injections.scm" ))), ..Default::default() }) diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 0d80f84e7ec1dc330db823a0938421a1f5ad85c9..d33700b1724f964597c66d9df0bc792210c96e42 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -4,95 +4,21 @@ //! which is a set of tools used to interact with the projects written in said language. //! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override. -use std::{ - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{path::PathBuf, sync::Arc}; use async_trait::async_trait; use collections::HashMap; -use fs::Fs; + use futures::future::BoxFuture; -use gpui::{App, AsyncApp, SharedString}; +use gpui::{App, AsyncApp}; use settings::WorktreeId; use task::ShellKind; use util::rel_path::RelPath; -use crate::{LanguageName, ManifestName}; - -/// Represents a single toolchain. -#[derive(Clone, Eq, Debug)] -pub struct Toolchain { - /// User-facing label - pub name: SharedString, - /// Absolute path - pub path: SharedString, - pub language_name: LanguageName, - /// Full toolchain data (including language-specific details) - pub as_json: serde_json::Value, -} - -/// Declares a scope of a toolchain added by user. -/// -/// When the user adds a toolchain, we give them an option to see that toolchain in: -/// - All of their projects -/// - A project they're currently in. -/// - Only in the subproject they're currently in. -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] -pub enum ToolchainScope { - Subproject(Arc, Arc), - Project, - /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. - Global, -} - -impl ToolchainScope { - pub fn label(&self) -> &'static str { - match self { - ToolchainScope::Subproject(_, _) => "Subproject", - ToolchainScope::Project => "Project", - ToolchainScope::Global => "Global", - } - } - - pub fn description(&self) -> &'static str { - match self { - ToolchainScope::Subproject(_, _) => { - "Available only in the subproject you're currently in." - } - ToolchainScope::Project => "Available in all locations in your current project.", - ToolchainScope::Global => "Available in all of your projects on this machine.", - } - } -} - -impl std::hash::Hash for Toolchain { - fn hash(&self, state: &mut H) { - let Self { - name, - path, - language_name, - as_json: _, - } = self; - name.hash(state); - path.hash(state); - language_name.hash(state); - } -} +use crate::LanguageName; -impl PartialEq for Toolchain { - fn eq(&self, other: &Self) -> bool { - let Self { - name, - path, - language_name, - as_json: _, - } = self; - // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. - // Thus, there could be multiple entries that look the same in the UI. - (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name)) - } -} +// Re-export core data types from language_core. +pub use language_core::{Toolchain, ToolchainList, ToolchainMetadata, ToolchainScope}; #[async_trait] pub trait ToolchainLister: Send + Sync + 'static { @@ -102,7 +28,6 @@ pub trait ToolchainLister: Send + Sync + 'static { worktree_root: PathBuf, subroot_relative_path: Arc, project_env: Option>, - fs: &dyn Fs, ) -> ToolchainList; /// Given a user-created toolchain, resolve lister-specific details. @@ -111,7 +36,6 @@ pub trait ToolchainLister: Send + Sync + 'static { &self, path: PathBuf, project_env: Option>, - fs: &dyn Fs, ) -> anyhow::Result; fn activation_script( @@ -125,16 +49,6 @@ pub trait ToolchainLister: Send + Sync + 'static { fn meta(&self) -> ToolchainMetadata; } -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct ToolchainMetadata { - /// Returns a term which we should use in UI to refer to toolchains produced by a given `[ToolchainLister]`. - pub term: SharedString, - /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain. - pub new_toolchain_placeholder: SharedString, - /// The name of the manifest file for this toolchain. - pub manifest_name: ManifestName, -} - #[async_trait(?Send)] pub trait LanguageToolchainStore: Send + Sync + 'static { async fn active_toolchain( @@ -168,31 +82,3 @@ impl LanguageToolchainStore for T { self.active_toolchain(worktree_id, &relative_path, language_name, cx) } } - -type DefaultIndex = usize; -#[derive(Default, Clone, Debug)] -pub struct ToolchainList { - pub toolchains: Vec, - pub default: Option, - pub groups: Box<[(usize, SharedString)]>, -} - -impl ToolchainList { - pub fn toolchains(&self) -> &[Toolchain] { - &self.toolchains - } - pub fn default_toolchain(&self) -> Option { - self.default.and_then(|ix| self.toolchains.get(ix)).cloned() - } - pub fn group_for_index(&self, index: usize) -> Option<(usize, SharedString)> { - if index >= self.toolchains.len() { - return None; - } - let first_equal_or_greater = self - .groups - .partition_point(|(group_lower_bound, _)| group_lower_bound <= &index); - self.groups - .get(first_equal_or_greater.checked_sub(1)?) - .cloned() - } -} diff --git a/crates/language_core/Cargo.toml b/crates/language_core/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..4861632b4663c860706525c65cd8607133b3ec71 --- /dev/null +++ b/crates/language_core/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "language_core" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +path = "src/language_core.rs" + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +log.workspace = true +lsp.workspace = true +parking_lot.workspace = true +regex.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +tree-sitter.workspace = true +util.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + +[features] +test-support = [] diff --git a/crates/language_core/LICENSE-GPL b/crates/language_core/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/language_core/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/language_core/src/code_label.rs b/crates/language_core/src/code_label.rs new file mode 100644 index 0000000000000000000000000000000000000000..0a98743d02b3861d248498893eef3972422d4758 --- /dev/null +++ b/crates/language_core/src/code_label.rs @@ -0,0 +1,122 @@ +use crate::highlight_map::HighlightId; +use std::ops::Range; + +#[derive(Debug, Clone)] +pub struct Symbol { + pub name: String, + pub kind: lsp::SymbolKind, + pub container_name: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CodeLabel { + /// The text to display. + pub text: String, + /// Syntax highlighting runs. + pub runs: Vec<(Range, HighlightId)>, + /// The portion of the text that should be used in fuzzy filtering. + pub filter_range: Range, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CodeLabelBuilder { + /// The text to display. + text: String, + /// Syntax highlighting runs. + runs: Vec<(Range, HighlightId)>, + /// The portion of the text that should be used in fuzzy filtering. + filter_range: Range, +} + +impl CodeLabel { + pub fn plain(text: String, filter_text: Option<&str>) -> Self { + Self::filtered(text.clone(), text.len(), filter_text, Vec::new()) + } + + pub fn filtered( + text: String, + label_len: usize, + filter_text: Option<&str>, + runs: Vec<(Range, HighlightId)>, + ) -> Self { + assert!(label_len <= text.len()); + let filter_range = filter_text + .and_then(|filter| text.find(filter).map(|index| index..index + filter.len())) + .unwrap_or(0..label_len); + Self::new(text, filter_range, runs) + } + + pub fn new( + text: String, + filter_range: Range, + runs: Vec<(Range, HighlightId)>, + ) -> Self { + assert!( + text.get(filter_range.clone()).is_some(), + "invalid filter range" + ); + runs.iter().for_each(|(range, _)| { + assert!( + text.get(range.clone()).is_some(), + "invalid run range with inputs. Requested range {range:?} in text '{text}'", + ); + }); + Self { + runs, + filter_range, + text, + } + } + + pub fn text(&self) -> &str { + self.text.as_str() + } + + pub fn filter_text(&self) -> &str { + &self.text[self.filter_range.clone()] + } +} + +impl From for CodeLabel { + fn from(value: String) -> Self { + Self::plain(value, None) + } +} + +impl From<&str> for CodeLabel { + fn from(value: &str) -> Self { + Self::plain(value.to_string(), None) + } +} + +impl CodeLabelBuilder { + pub fn respan_filter_range(&mut self, filter_text: Option<&str>) { + self.filter_range = filter_text + .and_then(|filter| { + self.text + .find(filter) + .map(|index| index..index + filter.len()) + }) + .unwrap_or(0..self.text.len()); + } + + pub fn push_str(&mut self, text: &str, highlight: Option) { + let start_index = self.text.len(); + self.text.push_str(text); + if let Some(highlight) = highlight { + let end_index = self.text.len(); + self.runs.push((start_index..end_index, highlight)); + } + } + + pub fn build(mut self) -> CodeLabel { + if self.filter_range.end == 0 { + self.respan_filter_range(None); + } + CodeLabel { + text: self.text, + runs: self.runs, + filter_range: self.filter_range, + } + } +} diff --git a/crates/language_core/src/diagnostic.rs b/crates/language_core/src/diagnostic.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a468a14b863a94ef23e00c3e15edd9fa2d8b09a --- /dev/null +++ b/crates/language_core/src/diagnostic.rs @@ -0,0 +1,76 @@ +use gpui::SharedString; +use lsp::{DiagnosticSeverity, NumberOrString}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// A diagnostic associated with a certain range of a buffer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + /// The name of the service that produced this diagnostic. + pub source: Option, + /// The ID provided by the dynamic registration that produced this diagnostic. + pub registration_id: Option, + /// A machine-readable code that identifies this diagnostic. + pub code: Option, + pub code_description: Option, + /// Whether this diagnostic is a hint, warning, or error. + pub severity: DiagnosticSeverity, + /// The human-readable message associated with this diagnostic. + pub message: String, + /// The human-readable message (in markdown format) + pub markdown: Option, + /// An id that identifies the group to which this diagnostic belongs. + /// + /// When a language server produces a diagnostic with + /// one or more associated diagnostics, those diagnostics are all + /// assigned a single group ID. + pub group_id: usize, + /// Whether this diagnostic is the primary diagnostic for its group. + /// + /// In a given group, the primary diagnostic is the top-level diagnostic + /// returned by the language server. The non-primary diagnostics are the + /// associated diagnostics. + pub is_primary: bool, + /// Whether this diagnostic is considered to originate from an analysis of + /// files on disk, as opposed to any unsaved buffer contents. This is a + /// property of a given diagnostic source, and is configured for a given + /// language server via the `LspAdapter::disk_based_diagnostic_sources` method + /// for the language server. + pub is_disk_based: bool, + /// Whether this diagnostic marks unnecessary code. + pub is_unnecessary: bool, + /// Quick separation of diagnostics groups based by their source. + pub source_kind: DiagnosticSourceKind, + /// Data from language server that produced this diagnostic. Passed back to the LS when we request code actions for this diagnostic. + pub data: Option, + /// Whether to underline the corresponding text range in the editor. + pub underline: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DiagnosticSourceKind { + Pulled, + Pushed, + Other, +} + +impl Default for Diagnostic { + fn default() -> Self { + Self { + source: Default::default(), + source_kind: DiagnosticSourceKind::Other, + code: None, + code_description: None, + severity: DiagnosticSeverity::ERROR, + message: Default::default(), + markdown: None, + group_id: 0, + is_primary: false, + is_disk_based: false, + is_unnecessary: false, + underline: true, + data: None, + registration_id: None, + } + } +} diff --git a/crates/language_core/src/grammar.rs b/crates/language_core/src/grammar.rs new file mode 100644 index 0000000000000000000000000000000000000000..f3a4c3d7c993dfde14657a330999c383ff9f0994 --- /dev/null +++ b/crates/language_core/src/grammar.rs @@ -0,0 +1,821 @@ +use crate::{ + HighlightId, HighlightMap, LanguageConfig, LanguageConfigOverride, LanguageName, + LanguageQueries, language_config::BracketPairConfig, +}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use gpui::SharedString; +use lsp::LanguageServerName; +use parking_lot::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; +use tree_sitter::Query; + +pub static NEXT_GRAMMAR_ID: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub struct GrammarId(pub usize); + +impl GrammarId { + pub fn new() -> Self { + Self(NEXT_GRAMMAR_ID.fetch_add(1, SeqCst)) + } +} + +impl Default for GrammarId { + fn default() -> Self { + Self::new() + } +} + +pub struct Grammar { + id: GrammarId, + pub ts_language: tree_sitter::Language, + pub error_query: Option, + pub highlights_config: Option, + pub brackets_config: Option, + pub redactions_config: Option, + pub runnable_config: Option, + pub indents_config: Option, + pub outline_config: Option, + pub text_object_config: Option, + pub injection_config: Option, + pub override_config: Option, + pub debug_variables_config: Option, + pub imports_config: Option, + pub highlight_map: Mutex, +} + +pub struct HighlightsConfig { + pub query: Query, + pub identifier_capture_indices: Vec, +} + +pub struct IndentConfig { + pub query: Query, + pub indent_capture_ix: u32, + pub start_capture_ix: Option, + pub end_capture_ix: Option, + pub outdent_capture_ix: Option, + pub suffixed_start_captures: HashMap, +} + +pub struct OutlineConfig { + pub query: Query, + pub item_capture_ix: u32, + pub name_capture_ix: u32, + pub context_capture_ix: Option, + pub extra_context_capture_ix: Option, + pub open_capture_ix: Option, + pub close_capture_ix: Option, + pub annotation_capture_ix: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DebuggerTextObject { + Variable, + Scope, +} + +impl DebuggerTextObject { + pub fn from_capture_name(name: &str) -> Option { + match name { + "debug-variable" => Some(DebuggerTextObject::Variable), + "debug-scope" => Some(DebuggerTextObject::Scope), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TextObject { + InsideFunction, + AroundFunction, + InsideClass, + AroundClass, + InsideComment, + AroundComment, +} + +impl TextObject { + pub fn from_capture_name(name: &str) -> Option { + match name { + "function.inside" => Some(TextObject::InsideFunction), + "function.around" => Some(TextObject::AroundFunction), + "class.inside" => Some(TextObject::InsideClass), + "class.around" => Some(TextObject::AroundClass), + "comment.inside" => Some(TextObject::InsideComment), + "comment.around" => Some(TextObject::AroundComment), + _ => None, + } + } + + pub fn around(&self) -> Option { + match self { + TextObject::InsideFunction => Some(TextObject::AroundFunction), + TextObject::InsideClass => Some(TextObject::AroundClass), + TextObject::InsideComment => Some(TextObject::AroundComment), + _ => None, + } + } +} + +pub struct TextObjectConfig { + pub query: Query, + pub text_objects_by_capture_ix: Vec<(u32, TextObject)>, +} + +pub struct InjectionConfig { + pub query: Query, + pub content_capture_ix: u32, + pub language_capture_ix: Option, + pub patterns: Vec, +} + +pub struct RedactionConfig { + pub query: Query, + pub redaction_capture_ix: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum RunnableCapture { + Named(SharedString), + Run, +} + +pub struct RunnableConfig { + pub query: Query, + /// A mapping from capture index to capture kind + pub extra_captures: Vec, +} + +pub struct OverrideConfig { + pub query: Query, + pub values: HashMap, +} + +#[derive(Debug)] +pub struct OverrideEntry { + pub name: String, + pub range_is_inclusive: bool, + pub value: LanguageConfigOverride, +} + +#[derive(Default, Clone)] +pub struct InjectionPatternConfig { + pub language: Option>, + pub combined: bool, +} + +#[derive(Debug)] +pub struct BracketsConfig { + pub query: Query, + pub open_capture_ix: u32, + pub close_capture_ix: u32, + pub patterns: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct BracketsPatternConfig { + pub newline_only: bool, + pub rainbow_exclude: bool, +} + +pub struct DebugVariablesConfig { + pub query: Query, + pub objects_by_capture_ix: Vec<(u32, DebuggerTextObject)>, +} + +pub struct ImportsConfig { + pub query: Query, + pub import_ix: u32, + pub name_ix: Option, + pub namespace_ix: Option, + pub source_ix: Option, + pub list_ix: Option, + pub wildcard_ix: Option, + pub alias_ix: Option, +} + +enum Capture<'a> { + Required(&'static str, &'a mut u32), + Optional(&'static str, &'a mut Option), +} + +fn populate_capture_indices( + query: &Query, + language_name: &LanguageName, + query_type: &str, + expected_prefixes: &[&str], + captures: &mut [Capture<'_>], +) -> bool { + let mut found_required_indices = Vec::new(); + 'outer: for (ix, name) in query.capture_names().iter().enumerate() { + for (required_ix, capture) in captures.iter_mut().enumerate() { + match capture { + Capture::Required(capture_name, index) if capture_name == name => { + **index = ix as u32; + found_required_indices.push(required_ix); + continue 'outer; + } + Capture::Optional(capture_name, index) if capture_name == name => { + **index = Some(ix as u32); + continue 'outer; + } + _ => {} + } + } + if !name.starts_with("_") + && !expected_prefixes + .iter() + .any(|&prefix| name.starts_with(prefix)) + { + log::warn!( + "unrecognized capture name '{}' in {} {} TreeSitter query \ + (suppress this warning by prefixing with '_')", + name, + language_name, + query_type + ); + } + } + let mut missing_required_captures = Vec::new(); + for (capture_ix, capture) in captures.iter().enumerate() { + if let Capture::Required(capture_name, _) = capture + && !found_required_indices.contains(&capture_ix) + { + missing_required_captures.push(*capture_name); + } + } + let success = missing_required_captures.is_empty(); + if !success { + log::error!( + "missing required capture(s) in {} {} TreeSitter query: {}", + language_name, + query_type, + missing_required_captures.join(", ") + ); + } + success +} + +impl Grammar { + pub fn new(ts_language: tree_sitter::Language) -> Self { + Self { + id: GrammarId::new(), + highlights_config: None, + brackets_config: None, + outline_config: None, + text_object_config: None, + indents_config: None, + injection_config: None, + override_config: None, + redactions_config: None, + runnable_config: None, + error_query: Query::new(&ts_language, "(ERROR) @error").ok(), + debug_variables_config: None, + imports_config: None, + ts_language, + highlight_map: Default::default(), + } + } + + pub fn id(&self) -> GrammarId { + self.id + } + + pub fn highlight_map(&self) -> HighlightMap { + self.highlight_map.lock().clone() + } + + pub fn highlight_id_for_name(&self, name: &str) -> Option { + let capture_id = self + .highlights_config + .as_ref()? + .query + .capture_index_for_name(name)?; + Some(self.highlight_map.lock().get(capture_id)) + } + + pub fn debug_variables_config(&self) -> Option<&DebugVariablesConfig> { + self.debug_variables_config.as_ref() + } + + pub fn imports_config(&self) -> Option<&ImportsConfig> { + self.imports_config.as_ref() + } + + /// Load all queries from `LanguageQueries` into this grammar, mutating the + /// associated `LanguageConfig` (the override query clears + /// `brackets.disabled_scopes_by_bracket_ix`). + pub fn with_queries( + mut self, + queries: LanguageQueries, + config: &mut LanguageConfig, + ) -> Result { + let name = &config.name; + if let Some(query) = queries.highlights { + self = self + .with_highlights_query(query.as_ref()) + .context("Error loading highlights query")?; + } + if let Some(query) = queries.brackets { + self = self + .with_brackets_query(query.as_ref(), name) + .context("Error loading brackets query")?; + } + if let Some(query) = queries.indents { + self = self + .with_indents_query(query.as_ref(), name) + .context("Error loading indents query")?; + } + if let Some(query) = queries.outline { + self = self + .with_outline_query(query.as_ref(), name) + .context("Error loading outline query")?; + } + if let Some(query) = queries.injections { + self = self + .with_injection_query(query.as_ref(), name) + .context("Error loading injection query")?; + } + if let Some(query) = queries.overrides { + self = self + .with_override_query( + query.as_ref(), + name, + &config.overrides, + &mut config.brackets, + &config.scope_opt_in_language_servers, + ) + .context("Error loading override query")?; + } + if let Some(query) = queries.redactions { + self = self + .with_redaction_query(query.as_ref(), name) + .context("Error loading redaction query")?; + } + if let Some(query) = queries.runnables { + self = self + .with_runnable_query(query.as_ref()) + .context("Error loading runnables query")?; + } + if let Some(query) = queries.text_objects { + self = self + .with_text_object_query(query.as_ref(), name) + .context("Error loading textobject query")?; + } + if let Some(query) = queries.debugger { + self = self + .with_debug_variables_query(query.as_ref(), name) + .context("Error loading debug variables query")?; + } + if let Some(query) = queries.imports { + self = self + .with_imports_query(query.as_ref(), name) + .context("Error loading imports query")?; + } + Ok(self) + } + + pub fn with_highlights_query(mut self, source: &str) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut identifier_capture_indices = Vec::new(); + for name in [ + "variable", + "constant", + "constructor", + "function", + "function.method", + "function.method.call", + "function.special", + "property", + "type", + "type.interface", + ] { + identifier_capture_indices.extend(query.capture_index_for_name(name)); + } + + self.highlights_config = Some(HighlightsConfig { + query, + identifier_capture_indices, + }); + + Ok(self) + } + + pub fn with_runnable_query(mut self, source: &str) -> Result { + let query = Query::new(&self.ts_language, source)?; + let extra_captures: Vec<_> = query + .capture_names() + .iter() + .map(|&name| match name { + "run" => RunnableCapture::Run, + name => RunnableCapture::Named(name.to_string().into()), + }) + .collect(); + + self.runnable_config = Some(RunnableConfig { + extra_captures, + query, + }); + + Ok(self) + } + + pub fn with_outline_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut item_capture_ix = 0; + let mut name_capture_ix = 0; + let mut context_capture_ix = None; + let mut extra_context_capture_ix = None; + let mut open_capture_ix = None; + let mut close_capture_ix = None; + let mut annotation_capture_ix = None; + if populate_capture_indices( + &query, + language_name, + "outline", + &[], + &mut [ + Capture::Required("item", &mut item_capture_ix), + Capture::Required("name", &mut name_capture_ix), + Capture::Optional("context", &mut context_capture_ix), + Capture::Optional("context.extra", &mut extra_context_capture_ix), + Capture::Optional("open", &mut open_capture_ix), + Capture::Optional("close", &mut close_capture_ix), + Capture::Optional("annotation", &mut annotation_capture_ix), + ], + ) { + self.outline_config = Some(OutlineConfig { + query, + item_capture_ix, + name_capture_ix, + context_capture_ix, + extra_context_capture_ix, + open_capture_ix, + close_capture_ix, + annotation_capture_ix, + }); + } + Ok(self) + } + + pub fn with_text_object_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut text_objects_by_capture_ix = Vec::new(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(text_object) = TextObject::from_capture_name(name) { + text_objects_by_capture_ix.push((ix as u32, text_object)); + } else { + log::warn!( + "unrecognized capture name '{}' in {} textobjects TreeSitter query", + name, + language_name, + ); + } + } + + self.text_object_config = Some(TextObjectConfig { + query, + text_objects_by_capture_ix, + }); + Ok(self) + } + + pub fn with_debug_variables_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut objects_by_capture_ix = Vec::new(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(text_object) = DebuggerTextObject::from_capture_name(name) { + objects_by_capture_ix.push((ix as u32, text_object)); + } else { + log::warn!( + "unrecognized capture name '{}' in {} debugger TreeSitter query", + name, + language_name, + ); + } + } + + self.debug_variables_config = Some(DebugVariablesConfig { + query, + objects_by_capture_ix, + }); + Ok(self) + } + + pub fn with_imports_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut import_ix = 0; + let mut name_ix = None; + let mut namespace_ix = None; + let mut source_ix = None; + let mut list_ix = None; + let mut wildcard_ix = None; + let mut alias_ix = None; + if populate_capture_indices( + &query, + language_name, + "imports", + &[], + &mut [ + Capture::Required("import", &mut import_ix), + Capture::Optional("name", &mut name_ix), + Capture::Optional("namespace", &mut namespace_ix), + Capture::Optional("source", &mut source_ix), + Capture::Optional("list", &mut list_ix), + Capture::Optional("wildcard", &mut wildcard_ix), + Capture::Optional("alias", &mut alias_ix), + ], + ) { + self.imports_config = Some(ImportsConfig { + query, + import_ix, + name_ix, + namespace_ix, + source_ix, + list_ix, + wildcard_ix, + alias_ix, + }); + } + Ok(self) + } + + pub fn with_brackets_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut open_capture_ix = 0; + let mut close_capture_ix = 0; + if populate_capture_indices( + &query, + language_name, + "brackets", + &[], + &mut [ + Capture::Required("open", &mut open_capture_ix), + Capture::Required("close", &mut close_capture_ix), + ], + ) { + let patterns = (0..query.pattern_count()) + .map(|ix| { + let mut config = BracketsPatternConfig::default(); + for setting in query.property_settings(ix) { + let setting_key = setting.key.as_ref(); + if setting_key == "newline.only" { + config.newline_only = true + } + if setting_key == "rainbow.exclude" { + config.rainbow_exclude = true + } + } + config + }) + .collect(); + self.brackets_config = Some(BracketsConfig { + query, + open_capture_ix, + close_capture_ix, + patterns, + }); + } + Ok(self) + } + + pub fn with_indents_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut indent_capture_ix = 0; + let mut start_capture_ix = None; + let mut end_capture_ix = None; + let mut outdent_capture_ix = None; + if populate_capture_indices( + &query, + language_name, + "indents", + &["start."], + &mut [ + Capture::Required("indent", &mut indent_capture_ix), + Capture::Optional("start", &mut start_capture_ix), + Capture::Optional("end", &mut end_capture_ix), + Capture::Optional("outdent", &mut outdent_capture_ix), + ], + ) { + let mut suffixed_start_captures = HashMap::default(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(suffix) = name.strip_prefix("start.") { + suffixed_start_captures.insert(ix as u32, suffix.to_owned().into()); + } + } + + self.indents_config = Some(IndentConfig { + query, + indent_capture_ix, + start_capture_ix, + end_capture_ix, + outdent_capture_ix, + suffixed_start_captures, + }); + } + Ok(self) + } + + pub fn with_injection_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut language_capture_ix = None; + let mut injection_language_capture_ix = None; + let mut content_capture_ix = None; + let mut injection_content_capture_ix = None; + if populate_capture_indices( + &query, + language_name, + "injections", + &[], + &mut [ + Capture::Optional("language", &mut language_capture_ix), + Capture::Optional("injection.language", &mut injection_language_capture_ix), + Capture::Optional("content", &mut content_capture_ix), + Capture::Optional("injection.content", &mut injection_content_capture_ix), + ], + ) { + language_capture_ix = match (language_capture_ix, injection_language_capture_ix) { + (None, Some(ix)) => Some(ix), + (Some(_), Some(_)) => { + anyhow::bail!("both language and injection.language captures are present"); + } + _ => language_capture_ix, + }; + content_capture_ix = match (content_capture_ix, injection_content_capture_ix) { + (None, Some(ix)) => Some(ix), + (Some(_), Some(_)) => { + anyhow::bail!("both content and injection.content captures are present") + } + _ => content_capture_ix, + }; + let patterns = (0..query.pattern_count()) + .map(|ix| { + let mut config = InjectionPatternConfig::default(); + for setting in query.property_settings(ix) { + match setting.key.as_ref() { + "language" | "injection.language" => { + config.language.clone_from(&setting.value); + } + "combined" | "injection.combined" => { + config.combined = true; + } + _ => {} + } + } + config + }) + .collect(); + if let Some(content_capture_ix) = content_capture_ix { + self.injection_config = Some(InjectionConfig { + query, + language_capture_ix, + content_capture_ix, + patterns, + }); + } else { + log::error!( + "missing required capture in injections {} TreeSitter query: \ + content or injection.content", + language_name, + ); + } + } + Ok(self) + } + + pub fn with_override_query( + mut self, + source: &str, + language_name: &LanguageName, + overrides: &HashMap, + brackets: &mut BracketPairConfig, + scope_opt_in_language_servers: &[LanguageServerName], + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + + let mut override_configs_by_id = HashMap::default(); + for (ix, mut name) in query.capture_names().iter().copied().enumerate() { + let mut range_is_inclusive = false; + if name.starts_with('_') { + continue; + } + if let Some(prefix) = name.strip_suffix(".inclusive") { + name = prefix; + range_is_inclusive = true; + } + + let value = overrides.get(name).cloned().unwrap_or_default(); + for server_name in &value.opt_into_language_servers { + if !scope_opt_in_language_servers.contains(server_name) { + util::debug_panic!( + "Server {server_name:?} has been opted-in by scope {name:?} but has not been marked as an opt-in server" + ); + } + } + + override_configs_by_id.insert( + ix as u32, + OverrideEntry { + name: name.to_string(), + range_is_inclusive, + value, + }, + ); + } + + let referenced_override_names = overrides + .keys() + .chain(brackets.disabled_scopes_by_bracket_ix.iter().flatten()); + + for referenced_name in referenced_override_names { + if !override_configs_by_id + .values() + .any(|entry| entry.name == *referenced_name) + { + anyhow::bail!( + "language {:?} has overrides in config not in query: {referenced_name:?}", + language_name + ); + } + } + + for entry in override_configs_by_id.values_mut() { + entry.value.disabled_bracket_ixs = brackets + .disabled_scopes_by_bracket_ix + .iter() + .enumerate() + .filter_map(|(ix, disabled_scope_names)| { + if disabled_scope_names.contains(&entry.name) { + Some(ix as u16) + } else { + None + } + }) + .collect(); + } + + brackets.disabled_scopes_by_bracket_ix.clear(); + + self.override_config = Some(OverrideConfig { + query, + values: override_configs_by_id, + }); + Ok(self) + } + + pub fn with_redaction_query( + mut self, + source: &str, + language_name: &LanguageName, + ) -> Result { + let query = Query::new(&self.ts_language, source)?; + let mut redaction_capture_ix = 0; + if populate_capture_indices( + &query, + language_name, + "redactions", + &[], + &mut [Capture::Required("redact", &mut redaction_capture_ix)], + ) { + self.redactions_config = Some(RedactionConfig { + query, + redaction_capture_ix, + }); + } + Ok(self) + } +} diff --git a/crates/language_core/src/highlight_map.rs b/crates/language_core/src/highlight_map.rs new file mode 100644 index 0000000000000000000000000000000000000000..1235c7d62c72950f57de0cdad1363f49d8fbbd96 --- /dev/null +++ b/crates/language_core/src/highlight_map.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +#[derive(Clone, Debug)] +pub struct HighlightMap(Arc<[HighlightId]>); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HighlightId(pub u32); + +const DEFAULT_SYNTAX_HIGHLIGHT_ID: HighlightId = HighlightId(u32::MAX); + +impl HighlightMap { + #[inline] + pub fn from_ids(highlight_ids: impl IntoIterator) -> Self { + Self(highlight_ids.into_iter().collect()) + } + + #[inline] + pub fn get(&self, capture_id: u32) -> HighlightId { + self.0 + .get(capture_id as usize) + .copied() + .unwrap_or(DEFAULT_SYNTAX_HIGHLIGHT_ID) + } +} + +impl HighlightId { + pub const TABSTOP_INSERT_ID: HighlightId = HighlightId(u32::MAX - 1); + pub const TABSTOP_REPLACE_ID: HighlightId = HighlightId(u32::MAX - 2); + + #[inline] + pub fn is_default(&self) -> bool { + *self == DEFAULT_SYNTAX_HIGHLIGHT_ID + } +} + +impl Default for HighlightMap { + fn default() -> Self { + Self(Arc::new([])) + } +} + +impl Default for HighlightId { + fn default() -> Self { + DEFAULT_SYNTAX_HIGHLIGHT_ID + } +} + +impl From for usize { + fn from(value: HighlightId) -> Self { + value.0 as usize + } +} diff --git a/crates/language_core/src/language_config.rs b/crates/language_core/src/language_config.rs new file mode 100644 index 0000000000000000000000000000000000000000..e07c11d811cdaae3b540c57314cebf0d92d0023d --- /dev/null +++ b/crates/language_core/src/language_config.rs @@ -0,0 +1,539 @@ +use crate::LanguageName; +use collections::{HashMap, HashSet, IndexSet}; +use gpui::SharedString; +use lsp::LanguageServerName; +use regex::Regex; +use schemars::{JsonSchema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use std::{num::NonZeroU32, path::Path, sync::Arc}; +use util::serde::default_true; + +/// Controls the soft-wrapping behavior in the editor. +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SoftWrap { + /// Prefer a single line generally, unless an overly long line is encountered. + None, + /// Deprecated: use None instead. Left to avoid breaking existing users' configs. + /// Prefer a single line generally, unless an overly long line is encountered. + PreferLine, + /// Soft wrap lines that exceed the editor width. + EditorWidth, + /// Soft wrap lines at the preferred line length. + PreferredLineLength, + /// Soft wrap line at the preferred line length or the editor width (whichever is smaller). + Bounded, +} + +/// Top-level configuration for a language, typically loaded from a `config.toml` +/// shipped alongside the grammar. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct LanguageConfig { + /// Human-readable name of the language. + pub name: LanguageName, + /// The name of this language for a Markdown code fence block + pub code_fence_block_name: Option>, + /// Alternative language names that Jupyter kernels may report for this language. + /// Used when a kernel's `language` field differs from Zed's language name. + /// For example, the Nu extension would set this to `["nushell"]`. + #[serde(default)] + pub kernel_language_names: Vec>, + // The name of the grammar in a WASM bundle (experimental). + pub grammar: Option>, + /// The criteria for matching this language to a given file. + #[serde(flatten)] + pub matcher: LanguageMatcher, + /// List of bracket types in a language. + #[serde(default)] + pub brackets: BracketPairConfig, + /// If set to true, auto indentation uses last non empty line to determine + /// the indentation level for a new line. + #[serde(default = "auto_indent_using_last_non_empty_line_default")] + pub auto_indent_using_last_non_empty_line: bool, + // Whether indentation of pasted content should be adjusted based on the context. + #[serde(default)] + pub auto_indent_on_paste: Option, + /// A regex that is used to determine whether the indentation level should be + /// increased in the following line. + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub increase_indent_pattern: Option, + /// A regex that is used to determine whether the indentation level should be + /// decreased in the following line. + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub decrease_indent_pattern: Option, + /// A list of rules for decreasing indentation. Each rule pairs a regex with a set of valid + /// "block-starting" tokens. When a line matches a pattern, its indentation is aligned with + /// the most recent line that began with a corresponding token. This enables context-aware + /// outdenting, like aligning an `else` with its `if`. + #[serde(default)] + pub decrease_indent_patterns: Vec, + /// A list of characters that trigger the automatic insertion of a closing + /// bracket when they immediately precede the point where an opening + /// bracket is inserted. + #[serde(default)] + pub autoclose_before: String, + /// A placeholder used internally by Semantic Index. + #[serde(default)] + pub collapsed_placeholder: String, + /// A line comment string that is inserted in e.g. `toggle comments` action. + /// A language can have multiple flavours of line comments. All of the provided line comments are + /// used for comment continuations on the next line, but only the first one is used for Editor::ToggleComments. + #[serde(default)] + pub line_comments: Vec>, + /// Delimiters and configuration for recognizing and formatting block comments. + #[serde(default)] + pub block_comment: Option, + /// Delimiters and configuration for recognizing and formatting documentation comments. + #[serde(default, alias = "documentation")] + pub documentation_comment: Option, + /// List markers that are inserted unchanged on newline (e.g., `- `, `* `, `+ `). + #[serde(default)] + pub unordered_list: Vec>, + /// Configuration for ordered lists with auto-incrementing numbers on newline (e.g., `1. ` becomes `2. `). + #[serde(default)] + pub ordered_list: Vec, + /// Configuration for task lists where multiple markers map to a single continuation prefix (e.g., `- [x] ` continues as `- [ ] `). + #[serde(default)] + pub task_list: Option, + /// A list of additional regex patterns that should be treated as prefixes + /// for creating boundaries during rewrapping, ensuring content from one + /// prefixed section doesn't merge with another (e.g., markdown list items). + /// By default, Zed treats as paragraph and comment prefixes as boundaries. + #[serde(default, deserialize_with = "deserialize_regex_vec")] + #[schemars(schema_with = "regex_vec_json_schema")] + pub rewrap_prefixes: Vec, + /// A list of language servers that are allowed to run on subranges of a given language. + #[serde(default)] + pub scope_opt_in_language_servers: Vec, + #[serde(default)] + pub overrides: HashMap, + /// A list of characters that Zed should treat as word characters for the + /// purpose of features that operate on word boundaries, like 'move to next word end' + /// or a whole-word search in buffer search. + #[serde(default)] + pub word_characters: HashSet, + /// Whether to indent lines using tab characters, as opposed to multiple + /// spaces. + #[serde(default)] + pub hard_tabs: Option, + /// How many columns a tab should occupy. + #[serde(default)] + #[schemars(range(min = 1, max = 128))] + pub tab_size: Option, + /// How to soft-wrap long lines of text. + #[serde(default)] + pub soft_wrap: Option, + /// When set, selections can be wrapped using prefix/suffix pairs on both sides. + #[serde(default)] + pub wrap_characters: Option, + /// The name of a Prettier parser that will be used for this language when no file path is available. + /// If there's a parser name in the language settings, that will be used instead. + #[serde(default)] + pub prettier_parser_name: Option, + /// If true, this language is only for syntax highlighting via an injection into other + /// languages, but should not appear to the user as a distinct language. + #[serde(default)] + pub hidden: bool, + /// If configured, this language contains JSX style tags, and should support auto-closing of those tags. + #[serde(default)] + pub jsx_tag_auto_close: Option, + /// A list of characters that Zed should treat as word characters for completion queries. + #[serde(default)] + pub completion_query_characters: HashSet, + /// A list of characters that Zed should treat as word characters for linked edit operations. + #[serde(default)] + pub linked_edit_characters: HashSet, + /// A list of preferred debuggers for this language. + #[serde(default)] + pub debuggers: IndexSet, + /// A list of import namespace segments that aren't expected to appear in file paths. For + /// example, "super" and "crate" in Rust. + #[serde(default)] + pub ignored_import_segments: HashSet>, + /// Regular expression that matches substrings to omit from import paths, to make the paths more + /// similar to how they are specified when imported. For example, "/mod\.rs$" or "/__init__\.py$". + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub import_path_strip_regex: Option, +} + +impl LanguageConfig { + pub const FILE_NAME: &str = "config.toml"; + + pub fn load(config_path: impl AsRef) -> anyhow::Result { + let config = std::fs::read_to_string(config_path.as_ref())?; + toml::from_str(&config).map_err(Into::into) + } +} + +impl Default for LanguageConfig { + fn default() -> Self { + Self { + name: LanguageName::new_static(""), + code_fence_block_name: None, + kernel_language_names: Default::default(), + grammar: None, + matcher: LanguageMatcher::default(), + brackets: Default::default(), + auto_indent_using_last_non_empty_line: auto_indent_using_last_non_empty_line_default(), + auto_indent_on_paste: None, + increase_indent_pattern: Default::default(), + decrease_indent_pattern: Default::default(), + decrease_indent_patterns: Default::default(), + autoclose_before: Default::default(), + line_comments: Default::default(), + block_comment: Default::default(), + documentation_comment: Default::default(), + unordered_list: Default::default(), + ordered_list: Default::default(), + task_list: Default::default(), + rewrap_prefixes: Default::default(), + scope_opt_in_language_servers: Default::default(), + overrides: Default::default(), + word_characters: Default::default(), + collapsed_placeholder: Default::default(), + hard_tabs: None, + tab_size: None, + soft_wrap: None, + wrap_characters: None, + prettier_parser_name: None, + hidden: false, + jsx_tag_auto_close: None, + completion_query_characters: Default::default(), + linked_edit_characters: Default::default(), + debuggers: Default::default(), + ignored_import_segments: Default::default(), + import_path_strip_regex: None, + } + } +} + +#[derive(Clone, Debug, Deserialize, Default, JsonSchema)] +pub struct DecreaseIndentConfig { + #[serde(default, deserialize_with = "deserialize_regex")] + #[schemars(schema_with = "regex_json_schema")] + pub pattern: Option, + #[serde(default)] + pub valid_after: Vec, +} + +/// Configuration for continuing ordered lists with auto-incrementing numbers. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct OrderedListConfig { + /// A regex pattern with a capture group for the number portion (e.g., `(\\d+)\\. `). + pub pattern: String, + /// A format string where `{1}` is replaced with the incremented number (e.g., `{1}. `). + pub format: String, +} + +/// Configuration for continuing task lists on newline. +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct TaskListConfig { + /// The list markers to match (e.g., `- [ ] `, `- [x] `). + pub prefixes: Vec>, + /// The marker to insert when continuing the list on a new line (e.g., `- [ ] `). + pub continuation: Arc, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)] +pub struct LanguageMatcher { + /// Given a list of `LanguageConfig`'s, the language of a file can be determined based on the path extension matching any of the `path_suffixes`. + #[serde(default)] + pub path_suffixes: Vec, + /// A regex pattern that determines whether the language should be assigned to a file or not. + #[serde( + default, + serialize_with = "serialize_regex", + deserialize_with = "deserialize_regex" + )] + #[schemars(schema_with = "regex_json_schema")] + pub first_line_pattern: Option, + /// Alternative names for this language used in vim/emacs modelines. + /// These are matched case-insensitively against the `mode` (emacs) or + /// `filetype`/`ft` (vim) specified in the modeline. + #[serde(default)] + pub modeline_aliases: Vec, +} + +impl Ord for LanguageMatcher { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.path_suffixes + .cmp(&other.path_suffixes) + .then_with(|| { + self.first_line_pattern + .as_ref() + .map(Regex::as_str) + .cmp(&other.first_line_pattern.as_ref().map(Regex::as_str)) + }) + .then_with(|| self.modeline_aliases.cmp(&other.modeline_aliases)) + } +} + +impl PartialOrd for LanguageMatcher { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Eq for LanguageMatcher {} + +impl PartialEq for LanguageMatcher { + fn eq(&self, other: &Self) -> bool { + self.path_suffixes == other.path_suffixes + && self.first_line_pattern.as_ref().map(Regex::as_str) + == other.first_line_pattern.as_ref().map(Regex::as_str) + && self.modeline_aliases == other.modeline_aliases + } +} + +/// The configuration for JSX tag auto-closing. +#[derive(Clone, Deserialize, JsonSchema, Debug)] +pub struct JsxTagAutoCloseConfig { + /// The name of the node for a opening tag + pub open_tag_node_name: String, + /// The name of the node for an closing tag + pub close_tag_node_name: String, + /// The name of the node for a complete element with children for open and close tags + pub jsx_element_node_name: String, + /// The name of the node found within both opening and closing + /// tags that describes the tag name + pub tag_name_node_name: String, + /// Alternate Node names for tag names. + /// Specifically needed as TSX represents the name in `` + /// as `member_expression` rather than `identifier` as usual + #[serde(default)] + pub tag_name_node_name_alternates: Vec, + /// Some grammars are smart enough to detect a closing tag + /// that is not valid i.e. doesn't match it's corresponding + /// opening tag or does not have a corresponding opening tag + /// This should be set to the name of the node for invalid + /// closing tags if the grammar contains such a node, otherwise + /// detecting already closed tags will not work properly + #[serde(default)] + pub erroneous_close_tag_node_name: Option, + /// See above for erroneous_close_tag_node_name for details + /// This should be set if the node used for the tag name + /// within erroneous closing tags is different from the + /// normal tag name node name + #[serde(default)] + pub erroneous_close_tag_name_node_name: Option, +} + +/// The configuration for block comments for this language. +#[derive(Clone, Debug, JsonSchema, PartialEq)] +pub struct BlockCommentConfig { + /// A start tag of block comment. + pub start: Arc, + /// A end tag of block comment. + pub end: Arc, + /// A character to add as a prefix when a new line is added to a block comment. + pub prefix: Arc, + /// A indent to add for prefix and end line upon new line. + #[schemars(range(min = 1, max = 128))] + pub tab_size: u32, +} + +impl<'de> Deserialize<'de> for BlockCommentConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum BlockCommentConfigHelper { + New { + start: Arc, + end: Arc, + prefix: Arc, + tab_size: u32, + }, + Old([Arc; 2]), + } + + match BlockCommentConfigHelper::deserialize(deserializer)? { + BlockCommentConfigHelper::New { + start, + end, + prefix, + tab_size, + } => Ok(BlockCommentConfig { + start, + end, + prefix, + tab_size, + }), + BlockCommentConfigHelper::Old([start, end]) => Ok(BlockCommentConfig { + start, + end, + prefix: "".into(), + tab_size: 0, + }), + } + } +} + +#[derive(Clone, Deserialize, Default, Debug, JsonSchema)] +pub struct LanguageConfigOverride { + #[serde(default)] + pub line_comments: Override>>, + #[serde(default)] + pub block_comment: Override, + #[serde(skip)] + pub disabled_bracket_ixs: Vec, + #[serde(default)] + pub word_characters: Override>, + #[serde(default)] + pub completion_query_characters: Override>, + #[serde(default)] + pub linked_edit_characters: Override>, + #[serde(default)] + pub opt_into_language_servers: Vec, + #[serde(default)] + pub prefer_label_for_snippet: Option, +} + +#[derive(Clone, Deserialize, Debug, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum Override { + Remove { remove: bool }, + Set(T), +} + +impl Default for Override { + fn default() -> Self { + Override::Remove { remove: false } + } +} + +impl Override { + pub fn as_option<'a>(this: Option<&'a Self>, original: Option<&'a T>) -> Option<&'a T> { + match this { + Some(Self::Set(value)) => Some(value), + Some(Self::Remove { remove: true }) => None, + Some(Self::Remove { remove: false }) | None => original, + } + } +} + +/// Configuration of handling bracket pairs for a given language. +/// +/// This struct includes settings for defining which pairs of characters are considered brackets and +/// also specifies any language-specific scopes where these pairs should be ignored for bracket matching purposes. +#[derive(Clone, Debug, Default, JsonSchema)] +#[schemars(with = "Vec::")] +pub struct BracketPairConfig { + /// A list of character pairs that should be treated as brackets in the context of a given language. + pub pairs: Vec, + /// A list of tree-sitter scopes for which a given bracket should not be active. + /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]` + pub disabled_scopes_by_bracket_ix: Vec>, +} + +impl BracketPairConfig { + pub fn is_closing_brace(&self, c: char) -> bool { + self.pairs.iter().any(|pair| pair.end.starts_with(c)) + } +} + +#[derive(Deserialize, JsonSchema)] +pub struct BracketPairContent { + #[serde(flatten)] + pub bracket_pair: BracketPair, + #[serde(default)] + pub not_in: Vec, +} + +impl<'de> Deserialize<'de> for BracketPairConfig { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let result = Vec::::deserialize(deserializer)?; + let (brackets, disabled_scopes_by_bracket_ix) = result + .into_iter() + .map(|entry| (entry.bracket_pair, entry.not_in)) + .unzip(); + + Ok(BracketPairConfig { + pairs: brackets, + disabled_scopes_by_bracket_ix, + }) + } +} + +/// Describes a single bracket pair and how an editor should react to e.g. inserting +/// an opening bracket or to a newline character insertion in between `start` and `end` characters. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] +pub struct BracketPair { + /// Starting substring for a bracket. + pub start: String, + /// Ending substring for a bracket. + pub end: String, + /// True if `end` should be automatically inserted right after `start` characters. + pub close: bool, + /// True if selected text should be surrounded by `start` and `end` characters. + #[serde(default = "default_true")] + pub surround: bool, + /// True if an extra newline should be inserted while the cursor is in the middle + /// of that bracket pair. + pub newline: bool, +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +pub struct WrapCharactersConfig { + /// Opening token split into a prefix and suffix. The first caret goes + /// after the prefix (i.e., between prefix and suffix). + pub start_prefix: String, + pub start_suffix: String, + /// Closing token split into a prefix and suffix. The second caret goes + /// after the prefix (i.e., between prefix and suffix). + pub end_prefix: String, + pub end_suffix: String, +} + +pub fn auto_indent_using_last_non_empty_line_default() -> bool { + true +} + +pub fn deserialize_regex<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let source = Option::::deserialize(d)?; + if let Some(source) = source { + Ok(Some(regex::Regex::new(&source).map_err(de::Error::custom)?)) + } else { + Ok(None) + } +} + +pub fn regex_json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "string" + }) +} + +pub fn serialize_regex(regex: &Option, serializer: S) -> Result +where + S: Serializer, +{ + match regex { + Some(regex) => serializer.serialize_str(regex.as_str()), + None => serializer.serialize_none(), + } +} + +pub fn deserialize_regex_vec<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let sources = Vec::::deserialize(d)?; + sources + .into_iter() + .map(|source| regex::Regex::new(&source)) + .collect::>() + .map_err(de::Error::custom) +} + +pub fn regex_vec_json_schema(_: &mut SchemaGenerator) -> schemars::Schema { + json_schema!({ + "type": "array", + "items": { "type": "string" } + }) +} diff --git a/crates/language_core/src/language_core.rs b/crates/language_core/src/language_core.rs new file mode 100644 index 0000000000000000000000000000000000000000..c908db7ecefd96b59f601ec74adc7a1a9a6425bc --- /dev/null +++ b/crates/language_core/src/language_core.rs @@ -0,0 +1,39 @@ +// language_core: tree-sitter grammar infrastructure, LSP adapter traits, +// language configuration, and highlight mapping. + +pub mod diagnostic; +pub mod grammar; +pub mod highlight_map; +pub mod language_config; + +pub use diagnostic::{Diagnostic, DiagnosticSourceKind}; +pub use grammar::{ + BracketsConfig, BracketsPatternConfig, DebugVariablesConfig, DebuggerTextObject, Grammar, + GrammarId, HighlightsConfig, ImportsConfig, IndentConfig, InjectionConfig, + InjectionPatternConfig, NEXT_GRAMMAR_ID, OutlineConfig, OverrideConfig, OverrideEntry, + RedactionConfig, RunnableCapture, RunnableConfig, TextObject, TextObjectConfig, +}; +pub use highlight_map::{HighlightId, HighlightMap}; +pub use language_config::{ + BlockCommentConfig, BracketPair, BracketPairConfig, BracketPairContent, DecreaseIndentConfig, + JsxTagAutoCloseConfig, LanguageConfig, LanguageConfigOverride, LanguageMatcher, + OrderedListConfig, Override, SoftWrap, TaskListConfig, WrapCharactersConfig, + auto_indent_using_last_non_empty_line_default, deserialize_regex, deserialize_regex_vec, + regex_json_schema, regex_vec_json_schema, serialize_regex, +}; + +pub mod code_label; +pub mod language_name; +pub mod lsp_adapter; +pub mod manifest; +pub mod queries; +pub mod toolchain; + +pub use code_label::{CodeLabel, CodeLabelBuilder, Symbol}; +pub use language_name::{LanguageId, LanguageName}; +pub use lsp_adapter::{ + BinaryStatus, LanguageServerStatusUpdate, PromptResponseContext, ServerHealth, ToLspPosition, +}; +pub use manifest::ManifestName; +pub use queries::{LanguageQueries, QUERY_FILENAME_PREFIXES}; +pub use toolchain::{Toolchain, ToolchainList, ToolchainMetadata, ToolchainScope}; diff --git a/crates/language_core/src/language_name.rs b/crates/language_core/src/language_name.rs new file mode 100644 index 0000000000000000000000000000000000000000..764b54a48a566ad98212de3e22bce6aca9a1e393 --- /dev/null +++ b/crates/language_core/src/language_name.rs @@ -0,0 +1,109 @@ +use gpui::SharedString; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{ + borrow::Borrow, + sync::atomic::{AtomicUsize, Ordering::SeqCst}, +}; + +static NEXT_LANGUAGE_ID: AtomicUsize = AtomicUsize::new(0); + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +pub struct LanguageId(usize); + +impl LanguageId { + pub fn new() -> Self { + Self(NEXT_LANGUAGE_ID.fetch_add(1, SeqCst)) + } +} + +impl Default for LanguageId { + fn default() -> Self { + Self::new() + } +} + +#[derive( + Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +pub struct LanguageName(pub SharedString); + +impl LanguageName { + pub fn new(s: &str) -> Self { + Self(SharedString::new(s)) + } + + pub fn new_static(s: &'static str) -> Self { + Self(SharedString::new_static(s)) + } + + pub fn from_proto(s: String) -> Self { + Self(SharedString::from(s)) + } + + pub fn to_proto(&self) -> String { + self.0.to_string() + } + + pub fn lsp_id(&self) -> String { + match self.0.as_ref() { + "Plain Text" => "plaintext".to_string(), + language_name => language_name.to_lowercase(), + } + } +} + +impl From for SharedString { + fn from(value: LanguageName) -> Self { + value.0 + } +} + +impl From for LanguageName { + fn from(value: SharedString) -> Self { + LanguageName(value) + } +} + +impl AsRef for LanguageName { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl Borrow for LanguageName { + fn borrow(&self) -> &str { + self.0.as_ref() + } +} + +impl PartialEq for LanguageName { + fn eq(&self, other: &str) -> bool { + self.0.as_ref() == other + } +} + +impl PartialEq<&str> for LanguageName { + fn eq(&self, other: &&str) -> bool { + self.0.as_ref() == *other + } +} + +impl std::fmt::Display for LanguageName { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From<&'static str> for LanguageName { + fn from(str: &'static str) -> Self { + Self(SharedString::new_static(str)) + } +} + +impl From for String { + fn from(value: LanguageName) -> Self { + let value: &str = &value.0; + Self::from(value) + } +} diff --git a/crates/language_core/src/lsp_adapter.rs b/crates/language_core/src/lsp_adapter.rs new file mode 100644 index 0000000000000000000000000000000000000000..03012f71143428b49ea9d75a03b0118b50e413b4 --- /dev/null +++ b/crates/language_core/src/lsp_adapter.rs @@ -0,0 +1,44 @@ +use gpui::SharedString; +use serde::{Deserialize, Serialize}; + +/// Converts a value into an LSP position. +pub trait ToLspPosition { + /// Converts the value into an LSP position. + fn to_lsp_position(self) -> lsp::Position; +} + +/// Context provided to LSP adapters when a user responds to a ShowMessageRequest prompt. +/// This allows adapters to intercept preference selections (like "Always" or "Never") +/// and potentially persist them to Zed's settings. +#[derive(Debug, Clone)] +pub struct PromptResponseContext { + /// The original message shown to the user + pub message: String, + /// The action (button) the user selected + pub selected_action: lsp::MessageActionItem, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LanguageServerStatusUpdate { + Binary(BinaryStatus), + Health(ServerHealth, Option), +} + +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub enum ServerHealth { + Ok, + Warning, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BinaryStatus { + None, + CheckingForUpdate, + Downloading, + Starting, + Stopping, + Stopped, + Failed { error: String }, +} diff --git a/crates/language_core/src/manifest.rs b/crates/language_core/src/manifest.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e762ff6e7c364eef02eea16ce9e1ecaaa198554 --- /dev/null +++ b/crates/language_core/src/manifest.rs @@ -0,0 +1,36 @@ +use std::borrow::Borrow; + +use gpui::SharedString; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ManifestName(SharedString); + +impl Borrow for ManifestName { + fn borrow(&self) -> &SharedString { + &self.0 + } +} + +impl Borrow for ManifestName { + fn borrow(&self) -> &str { + &self.0 + } +} + +impl From for ManifestName { + fn from(value: SharedString) -> Self { + Self(value) + } +} + +impl From for SharedString { + fn from(value: ManifestName) -> Self { + value.0 + } +} + +impl AsRef for ManifestName { + fn as_ref(&self) -> &SharedString { + &self.0 + } +} diff --git a/crates/language_core/src/queries.rs b/crates/language_core/src/queries.rs new file mode 100644 index 0000000000000000000000000000000000000000..a0ec6890814e08013badef97bb26ac12d89c02f5 --- /dev/null +++ b/crates/language_core/src/queries.rs @@ -0,0 +1,33 @@ +use std::borrow::Cow; + +pub type QueryFieldAccessor = fn(&mut LanguageQueries) -> &mut Option>; + +pub const QUERY_FILENAME_PREFIXES: &[(&str, QueryFieldAccessor)] = &[ + ("highlights", |q| &mut q.highlights), + ("brackets", |q| &mut q.brackets), + ("outline", |q| &mut q.outline), + ("indents", |q| &mut q.indents), + ("injections", |q| &mut q.injections), + ("overrides", |q| &mut q.overrides), + ("redactions", |q| &mut q.redactions), + ("runnables", |q| &mut q.runnables), + ("debugger", |q| &mut q.debugger), + ("textobjects", |q| &mut q.text_objects), + ("imports", |q| &mut q.imports), +]; + +/// Tree-sitter language queries for a given language. +#[derive(Debug, Default)] +pub struct LanguageQueries { + pub highlights: Option>, + pub brackets: Option>, + pub indents: Option>, + pub outline: Option>, + pub injections: Option>, + pub overrides: Option>, + pub redactions: Option>, + pub runnables: Option>, + pub text_objects: Option>, + pub debugger: Option>, + pub imports: Option>, +} diff --git a/crates/language_core/src/toolchain.rs b/crates/language_core/src/toolchain.rs new file mode 100644 index 0000000000000000000000000000000000000000..a021cb86bd36295a065b16281209c5fc3b63cffc --- /dev/null +++ b/crates/language_core/src/toolchain.rs @@ -0,0 +1,124 @@ +//! Provides core data types for language toolchains. +//! +//! A language can have associated toolchains, +//! which is a set of tools used to interact with the projects written in said language. +//! For example, a Python project can have an associated virtual environment; a Rust project can have a toolchain override. + +use std::{path::Path, sync::Arc}; + +use gpui::SharedString; +use util::rel_path::RelPath; + +use crate::{LanguageName, ManifestName}; + +/// Represents a single toolchain. +#[derive(Clone, Eq, Debug)] +pub struct Toolchain { + /// User-facing label + pub name: SharedString, + /// Absolute path + pub path: SharedString, + pub language_name: LanguageName, + /// Full toolchain data (including language-specific details) + pub as_json: serde_json::Value, +} + +impl std::hash::Hash for Toolchain { + fn hash(&self, state: &mut H) { + let Self { + name, + path, + language_name, + as_json: _, + } = self; + name.hash(state); + path.hash(state); + language_name.hash(state); + } +} + +impl PartialEq for Toolchain { + fn eq(&self, other: &Self) -> bool { + let Self { + name, + path, + language_name, + as_json: _, + } = self; + // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. + // Thus, there could be multiple entries that look the same in the UI. + (name, path, language_name).eq(&(&other.name, &other.path, &other.language_name)) + } +} + +/// Declares a scope of a toolchain added by user. +/// +/// When the user adds a toolchain, we give them an option to see that toolchain in: +/// - All of their projects +/// - A project they're currently in. +/// - Only in the subproject they're currently in. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ToolchainScope { + Subproject(Arc, Arc), + Project, + /// Available in all projects on this box. It wouldn't make sense to show suggestions across machines. + Global, +} + +impl ToolchainScope { + pub fn label(&self) -> &'static str { + match self { + ToolchainScope::Subproject(_, _) => "Subproject", + ToolchainScope::Project => "Project", + ToolchainScope::Global => "Global", + } + } + + pub fn description(&self) -> &'static str { + match self { + ToolchainScope::Subproject(_, _) => { + "Available only in the subproject you're currently in." + } + ToolchainScope::Project => "Available in all locations in your current project.", + ToolchainScope::Global => "Available in all of your projects on this machine.", + } + } +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ToolchainMetadata { + /// Returns a term which we should use in UI to refer to toolchains produced by a given `ToolchainLister`. + pub term: SharedString, + /// A user-facing placeholder describing the semantic meaning of a path to a new toolchain. + pub new_toolchain_placeholder: SharedString, + /// The name of the manifest file for this toolchain. + pub manifest_name: ManifestName, +} + +type DefaultIndex = usize; +#[derive(Default, Clone, Debug)] +pub struct ToolchainList { + pub toolchains: Vec, + pub default: Option, + pub groups: Box<[(usize, SharedString)]>, +} + +impl ToolchainList { + pub fn toolchains(&self) -> &[Toolchain] { + &self.toolchains + } + pub fn default_toolchain(&self) -> Option { + self.default.and_then(|ix| self.toolchains.get(ix)).cloned() + } + pub fn group_for_index(&self, index: usize) -> Option<(usize, SharedString)> { + if index >= self.toolchains.len() { + return None; + } + let first_equal_or_greater = self + .groups + .partition_point(|(group_lower_bound, _)| group_lower_bound <= &index); + self.groups + .get(first_equal_or_greater.checked_sub(1)?) + .cloned() + } +} diff --git a/crates/language_tools/src/highlights_tree_view.rs b/crates/language_tools/src/highlights_tree_view.rs index 1e19ee47b3c0c42005a266f7e1cd081aa74b2095..c2f684c11dc148c8f66b6cf20e0ca06e40905db7 100644 --- a/crates/language_tools/src/highlights_tree_view.rs +++ b/crates/language_tools/src/highlights_tree_view.rs @@ -9,6 +9,7 @@ use gpui::{ Task, UniformListScrollHandle, WeakEntity, Window, actions, div, rems, uniform_list, }; use language::ToOffset; + use menu::{SelectNext, SelectPrevious}; use std::{mem, ops::Range}; use theme::ActiveTheme; @@ -419,12 +420,12 @@ impl HighlightsTreeView { for capture in captures { let highlight_id = highlight_maps[capture.grammar_index].get(capture.index); - let Some(style) = highlight_id.style(&syntax_theme) else { + let Some(style) = syntax_theme.get(highlight_id).cloned() else { continue; }; - let theme_key = highlight_id - .name(&syntax_theme) + let theme_key = syntax_theme + .get_capture_name(highlight_id) .map(|theme_key| SharedString::from(theme_key.to_string())); let capture_name = grammars[capture.grammar_index] diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index b66f661b5e8782a7a072332141e4e2246ab1a2b9..93c70d4b27a0b769df521618c22c0700430be2f8 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -13,24 +13,9 @@ test-support = [ "load-grammars" ] load-grammars = [ + "grammars/load-grammars", "tree-sitter", - "tree-sitter-bash", - "tree-sitter-c", - "tree-sitter-cpp", - "tree-sitter-css", - "tree-sitter-diff", "tree-sitter-gitcommit", - "tree-sitter-go", - "tree-sitter-go-mod", - "tree-sitter-gowork", - "tree-sitter-jsdoc", - "tree-sitter-json", - "tree-sitter-md", - "tree-sitter-python", - "tree-sitter-regex", - "tree-sitter-rust", - "tree-sitter-typescript", - "tree-sitter-yaml", ] [dependencies] @@ -44,6 +29,7 @@ collections.workspace = true futures.workspace = true globset.workspace = true gpui.workspace = true +grammars.workspace = true http_client.workspace = true itertools.workspace = true json_schema_store.workspace = true @@ -62,7 +48,6 @@ pet.workspace = true project.workspace = true regex.workspace = true rope.workspace = true -rust-embed.workspace = true serde.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true @@ -74,29 +59,13 @@ snippet.workspace = true task.workspace = true terminal.workspace = true theme.workspace = true -toml.workspace = true tree-sitter = { workspace = true, optional = true } -tree-sitter-bash = { workspace = true, optional = true } -tree-sitter-c = { workspace = true, optional = true } -tree-sitter-cpp = { workspace = true, optional = true } -tree-sitter-css = { workspace = true, optional = true } -tree-sitter-diff = { workspace = true, optional = true } tree-sitter-gitcommit = { workspace = true, optional = true } -tree-sitter-go = { workspace = true, optional = true } -tree-sitter-go-mod = { workspace = true, optional = true } -tree-sitter-gowork = { workspace = true, optional = true } -tree-sitter-jsdoc = { workspace = true, optional = true } -tree-sitter-json = { workspace = true, optional = true } -tree-sitter-md = { workspace = true, optional = true } -tree-sitter-python = { workspace = true, optional = true } -tree-sitter-regex = { workspace = true, optional = true } -tree-sitter-rust = { workspace = true, optional = true } -tree-sitter-typescript = { workspace = true, optional = true } -tree-sitter-yaml = { workspace = true, optional = true } url.workspace = true util.workspace = true [dev-dependencies] +fs = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true theme = { workspace = true, features = ["test-support"] } tree-sitter-bash.workspace = true @@ -105,6 +74,7 @@ tree-sitter-cpp.workspace = true tree-sitter-css.workspace = true tree-sitter-go.workspace = true tree-sitter-python.workspace = true +tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true tree-sitter.workspace = true unindent.workspace = true diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 3a9207329d58a60acb0da42699116336d4528c97..bc75a9dbabbf0687124da5e35e6435ebc377e854 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -368,7 +368,7 @@ impl super::LspAdapter for CLspAdapter { Ok(original) } - fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic, _: &App) -> bool { + fn retain_old_diagnostic(&self, previous_diagnostic: &Diagnostic) -> bool { clangd_ext::is_inactive_region(previous_diagnostic) } diff --git a/crates/languages/src/cpp.rs b/crates/languages/src/cpp.rs index 3207b492f4b11be345cd67a989f9667d025d6660..5985baa54808b86a62e9d7ade38dca3480931459 100644 --- a/crates/languages/src/cpp.rs +++ b/crates/languages/src/cpp.rs @@ -1,9 +1,7 @@ use settings::SemanticTokenRules; -use crate::LanguageDir; - pub(crate) fn semantic_token_rules() -> SemanticTokenRules { - let content = LanguageDir::get("cpp/semantic_token_rules.json") + let content = grammars::get_file("cpp/semantic_token_rules.json") .expect("missing cpp/semantic_token_rules.json"); let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); settings::parse_json_with_comments::(json) diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index 461327d62731ec10ee862cdd78f5e484b917e495..73e9b162f4d6e76c4a42d4e24accfd90e79733c9 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -31,10 +31,8 @@ use std::{ use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName}; use util::{ResultExt, fs::remove_matching, maybe, merge_json_value_into}; -use crate::LanguageDir; - pub(crate) fn semantic_token_rules() -> SemanticTokenRules { - let content = LanguageDir::get("go/semantic_token_rules.json") + let content = grammars::get_file("go/semantic_token_rules.json") .expect("missing go/semantic_token_rules.json"); let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); settings::parse_json_with_comments::(json) diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 9987e7bfab327b39e639036ae68d6dedb5d548d9..3a84ee7c283007d3f40ef8a557981a9490f07c28 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -1,14 +1,12 @@ -use anyhow::Context as _; use gpui::{App, SharedString, UpdateGlobal}; use node_runtime::NodeRuntime; use project::Fs; use python::PyprojectTomlManifestProvider; use rust::CargoManifestProvider; -use rust_embed::RustEmbed; use settings::{SemanticTokenRules, SettingsStore}; use smol::stream::StreamExt; -use std::{str, sync::Arc}; -use util::{ResultExt, asset_str}; +use std::sync::Arc; +use util::ResultExt; pub use language::*; @@ -35,11 +33,6 @@ mod yaml; pub(crate) use package_json::{PackageJson, PackageJsonData}; -#[derive(RustEmbed)] -#[folder = "src/"] -#[exclude = "*.rs"] -struct LanguageDir; - /// A shared grammar for plain text, exposed for reuse by downstream crates. #[cfg(feature = "tree-sitter-gitcommit")] pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = @@ -47,7 +40,7 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = Arc::new(Language::new( LanguageConfig { name: "Git Commit".into(), - soft_wrap: Some(language::language_settings::SoftWrap::EditorWidth), + soft_wrap: Some(language::SoftWrap::EditorWidth), matcher: LanguageMatcher { path_suffixes: vec!["COMMIT_EDITMSG".to_owned()], first_line_pattern: None, @@ -62,28 +55,7 @@ pub static LANGUAGE_GIT_COMMIT: std::sync::LazyLock> = pub fn init(languages: Arc, fs: Arc, node: NodeRuntime, cx: &mut App) { #[cfg(feature = "load-grammars")] - languages.register_native_grammars([ - ("bash", tree_sitter_bash::LANGUAGE), - ("c", tree_sitter_c::LANGUAGE), - ("cpp", tree_sitter_cpp::LANGUAGE), - ("css", tree_sitter_css::LANGUAGE), - ("diff", tree_sitter_diff::LANGUAGE), - ("go", tree_sitter_go::LANGUAGE), - ("gomod", tree_sitter_go_mod::LANGUAGE), - ("gowork", tree_sitter_gowork::LANGUAGE), - ("jsdoc", tree_sitter_jsdoc::LANGUAGE), - ("json", tree_sitter_json::LANGUAGE), - ("jsonc", tree_sitter_json::LANGUAGE), - ("markdown", tree_sitter_md::LANGUAGE), - ("markdown-inline", tree_sitter_md::INLINE_LANGUAGE), - ("python", tree_sitter_python::LANGUAGE), - ("regex", tree_sitter_regex::LANGUAGE), - ("rust", tree_sitter_rust::LANGUAGE), - ("tsx", tree_sitter_typescript::LANGUAGE_TSX), - ("typescript", tree_sitter_typescript::LANGUAGE_TYPESCRIPT), - ("yaml", tree_sitter_yaml::LANGUAGE), - ("gitcommit", tree_sitter_gitcommit::LANGUAGE), - ]); + languages.register_native_grammars(grammars::native_grammars()); let c_lsp_adapter = Arc::new(c::CLspAdapter); let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone())); @@ -99,7 +71,7 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime let python_lsp_adapter = Arc::new(python::PyrightLspAdapter::new(node.clone())); let basedpyright_lsp_adapter = Arc::new(BasedPyrightLspAdapter::new(node.clone())); let ruff_lsp_adapter = Arc::new(RuffLspAdapter::new(fs.clone())); - let python_toolchain_provider = Arc::new(python::PythonToolchainProvider); + let python_toolchain_provider = Arc::new(python::PythonToolchainProvider::new(fs.clone())); let rust_context_provider = Arc::new(rust::RustContextProvider); let rust_lsp_adapter = Arc::new(rust::RustLspAdapter); let tailwind_adapter = Arc::new(tailwind::TailwindLspAdapter::new(node.clone())); @@ -402,56 +374,17 @@ fn register_language( #[cfg(any(test, feature = "test-support"))] pub fn language(name: &str, grammar: tree_sitter::Language) -> Arc { Arc::new( - Language::new(load_config(name), Some(grammar)) - .with_queries(load_queries(name)) + Language::new(grammars::load_config(name), Some(grammar)) + .with_queries(grammars::load_queries(name)) .unwrap(), ) } fn load_config(name: &str) -> LanguageConfig { - let config_toml = String::from_utf8( - LanguageDir::get(&format!("{}/config.toml", name)) - .unwrap_or_else(|| panic!("missing config for language {:?}", name)) - .data - .to_vec(), - ) - .unwrap(); - - #[allow(unused_mut)] - let mut config: LanguageConfig = ::toml::from_str(&config_toml) - .with_context(|| format!("failed to load config.toml for language {name:?}")) - .unwrap(); - - #[cfg(not(any(feature = "load-grammars", test)))] - { - config = LanguageConfig { - name: config.name, - matcher: config.matcher, - jsx_tag_auto_close: config.jsx_tag_auto_close, - ..Default::default() - } - } - - config + let grammars_loaded = cfg!(any(feature = "load-grammars", test)); + grammars::load_config_for_feature(name, grammars_loaded) } fn load_queries(name: &str) -> LanguageQueries { - let mut result = LanguageQueries::default(); - for path in LanguageDir::iter() { - if let Some(remainder) = path.strip_prefix(name).and_then(|p| p.strip_prefix('/')) { - if !remainder.ends_with(".scm") { - continue; - } - for (name, query) in QUERY_FILENAME_PREFIXES { - if remainder.starts_with(name) { - let contents = asset_str::(path.as_ref()); - match query(&mut result) { - None => *query(&mut result) = Some(contents), - Some(r) => r.to_mut().push_str(contents.as_ref()), - } - } - } - } - } - result + grammars::load_queries(name) } diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index a6f098f2cc51054fdf43c84858a976f030b01dfa..d27db372bf3d5f84ba282b30afd060f3ae4b183e 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -39,7 +39,6 @@ use util::fs::{make_file_executable, remove_matching}; use util::paths::PathStyle; use util::rel_path::RelPath; -use crate::LanguageDir; use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; use parking_lot::Mutex; use std::str::FromStr; @@ -53,7 +52,7 @@ use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName}; use util::{ResultExt, maybe}; pub(crate) fn semantic_token_rules() -> SemanticTokenRules { - let content = LanguageDir::get("python/semantic_token_rules.json") + let content = grammars::get_file("python/semantic_token_rules.json") .expect("missing python/semantic_token_rules.json"); let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); settings::parse_json_with_comments::(json) @@ -1121,7 +1120,15 @@ fn python_env_kind_display(k: &PythonEnvironmentKind) -> &'static str { } } -pub(crate) struct PythonToolchainProvider; +pub(crate) struct PythonToolchainProvider { + fs: Arc, +} + +impl PythonToolchainProvider { + pub fn new(fs: Arc) -> Self { + Self { fs } + } +} static ENV_PRIORITY_LIST: &[PythonEnvironmentKind] = &[ // Prioritize non-Conda environments. @@ -1236,8 +1243,8 @@ impl ToolchainLister for PythonToolchainProvider { worktree_root: PathBuf, subroot_relative_path: Arc, project_env: Option>, - fs: &dyn Fs, ) -> ToolchainList { + let fs = &*self.fs; let env = project_env.unwrap_or_default(); let environment = EnvironmentApi::from_env(&env); let locators = pet::locators::create_locators( @@ -1368,8 +1375,8 @@ impl ToolchainLister for PythonToolchainProvider { &self, path: PathBuf, env: Option>, - fs: &dyn Fs, ) -> anyhow::Result { + let fs = &*self.fs; let env = env.unwrap_or_default(); let environment = EnvironmentApi::from_env(&env); let locators = pet::locators::create_locators( @@ -2664,7 +2671,8 @@ mod tests { }); }); - let provider = PythonToolchainProvider; + let fs = project::FakeFs::new(cx.executor()); + let provider = PythonToolchainProvider::new(fs); let malicious_name = "foo; rm -rf /"; let manager_executable = std::env::current_exe().unwrap(); diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index e13163c37f8f5a84220475ccb92dc9bf9dec5432..3bb8826d555308145847d47525cba9de84a6aa89 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -31,11 +31,10 @@ use util::merge_json_value_into; use util::rel_path::RelPath; use util::{ResultExt, maybe}; -use crate::LanguageDir; use crate::language_settings::LanguageSettings; pub(crate) fn semantic_token_rules() -> SemanticTokenRules { - let content = LanguageDir::get("rust/semantic_token_rules.json") + let content = grammars::get_file("rust/semantic_token_rules.json") .expect("missing rust/semantic_token_rules.json"); let json = std::str::from_utf8(&content.data).expect("invalid utf-8 in semantic_token_rules"); settings::parse_json_with_comments::(json) @@ -263,12 +262,7 @@ impl LspAdapter for RustLspAdapter { Some("rust-analyzer/flycheck".into()) } - fn process_diagnostics( - &self, - params: &mut lsp::PublishDiagnosticsParams, - _: LanguageServerId, - _: Option<&'_ Buffer>, - ) { + fn process_diagnostics(&self, params: &mut lsp::PublishDiagnosticsParams, _: LanguageServerId) { static REGEX: LazyLock = LazyLock::new(|| Regex::new(r"(?m)`([^`]+)\n`$").expect("Failed to create REGEX")); @@ -1358,7 +1352,7 @@ mod tests { }, ], }; - RustLspAdapter.process_diagnostics(&mut params, LanguageServerId(0), None); + RustLspAdapter.process_diagnostics(&mut params, LanguageServerId(0)); assert_eq!(params.diagnostics[0].message, "use of moved value `a`"); diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index cc4a0187c540a149693e696663bd8756408e5d64..edff18e8eb14f42d380ef5081d9de25b82417fd5 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -7,6 +7,7 @@ use gpui::EdgesRefinement; use gpui::HitboxBehavior; use gpui::UnderlineStyle; use language::LanguageName; + use log::Level; pub use path_range::{LineCol, PathWithRange}; use settings::Settings as _; @@ -1904,9 +1905,10 @@ impl MarkdownElementBuilder { } let mut run_style = self.text_style(); - if let Some(highlight) = highlight_id.style(&self.syntax_theme) { + if let Some(highlight) = self.syntax_theme.get(highlight_id).cloned() { run_style = run_style.highlight(highlight); } + self.pending_line.runs.push(run_style.to_run(range.len())); offset = range.end; } diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index 1887da31621901fe7582192770018bd4e53a3c64..e8d9fd0ab8e3ea8583548f5abb3168e07119a4d9 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -3,6 +3,7 @@ use gpui::{ UnderlineStyle, px, }; use language::HighlightId; + use std::{fmt::Display, ops::Range, path::PathBuf}; use urlencoding; @@ -242,7 +243,7 @@ impl MarkdownHighlight { Some(highlight) } - MarkdownHighlight::Code(id) => id.style(theme), + MarkdownHighlight::Code(id) => theme.get(*id).cloned(), } } } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 59837621a6827f7cbc5840ac9b8f150dd4b59513..6f7a28db775868e185fe183a1e35d1f7b8eaa662 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -15,6 +15,7 @@ use gpui::{ Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled, StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems, }; + use settings::Settings; use std::{ ops::{Mul, Range}, @@ -750,8 +751,9 @@ fn render_markdown_code_block( StyledText::new(parsed.contents.clone()).with_default_highlights( &cx.buffer_text_style, highlights.iter().filter_map(|(range, highlight_id)| { - highlight_id - .style(cx.syntax_theme.as_ref()) + cx.syntax_theme + .get(*highlight_id) + .cloned() .map(|style| (range.clone(), style)) }), ) diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 83d9d51a0e85e50a1a1dae6dad4e797d6763e58d..d4f8fe5b6dd488c1c1af522a0ed0c8b0b0a435fc 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -25,6 +25,7 @@ use gpui::{ use itertools::Itertools; use language::language_settings::LanguageSettings; use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; + use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use std::{ cmp, @@ -236,7 +237,8 @@ impl SearchState { } let style = chunk .syntax_highlight_id - .and_then(|highlight| highlight.style(&theme)); + .and_then(|highlight| theme.get(highlight).cloned()); + if let Some(style) = style { let start = context_text.len(); let end = start + chunk.text.len(); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index b08aa8df27692d6abf7fce35e71a2381969e1a59..d36a45692bec13e2ab4c9b21d1ee4da6879ab6fc 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -71,10 +71,10 @@ use http_client::HttpClient; use itertools::Itertools as _; use language::{ Bias, BinaryStatus, Buffer, BufferRow, BufferSnapshot, CachedLspAdapter, Capability, CodeLabel, - Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, - LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, LspInstaller, - ManifestDelegate, ManifestName, ModelineSettings, Patch, PointUtf16, TextBufferSnapshot, - ToOffset, ToPointUtf16, Toolchain, Transaction, Unclipped, + CodeLabelExt, Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, + File as _, Language, LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, + LspInstaller, ManifestDelegate, ManifestName, ModelineSettings, Patch, PointUtf16, + TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain, Transaction, Unclipped, language_settings::{ AllLanguageSettings, FormatOnSave, Formatter, LanguageSettings, all_language_settings, }, @@ -822,15 +822,7 @@ impl LocalLspStore { let adapter = adapter.clone(); if let Some(this) = this.upgrade() { this.update(cx, |this, cx| { - { - let buffer = params - .uri - .to_file_path() - .map(|file_path| this.get_buffer(&file_path, cx)) - .ok() - .flatten(); - adapter.process_diagnostics(&mut params, server_id, buffer); - } + adapter.process_diagnostics(&mut params, server_id); this.merge_lsp_diagnostics( DiagnosticSourceKind::Pushed, @@ -843,9 +835,9 @@ impl LocalLspStore { ), registration_id: None, }], - |_, diagnostic, cx| match diagnostic.source_kind { + |_, diagnostic, _cx| match diagnostic.source_kind { DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => { - adapter.retain_old_diagnostic(diagnostic, cx) + adapter.retain_old_diagnostic(diagnostic) } DiagnosticSourceKind::Pulled => true, }, @@ -11206,23 +11198,6 @@ impl LspStore { cx.background_spawn(futures::future::join_all(tasks).map(|_| ())) } - fn get_buffer<'a>(&self, abs_path: &Path, cx: &'a App) -> Option<&'a Buffer> { - let (worktree, relative_path) = - self.worktree_store.read(cx).find_worktree(&abs_path, cx)?; - - let project_path = ProjectPath { - worktree_id: worktree.read(cx).id(), - path: relative_path, - }; - - Some( - self.buffer_store() - .read(cx) - .get_by_path(&project_path)? - .read(cx), - ) - } - #[cfg(any(test, feature = "test-support"))] pub fn update_diagnostics( &mut self, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 3dbfc6edc6ef4b2d59e028d027ebc29fc5b9f0d0..0f9ad1a4356d72bd15688d64a3909dd73dbaad35 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1186,7 +1186,6 @@ impl Project { worktree_store.clone(), environment.clone(), manifest_tree.clone(), - fs.clone(), cx, ) }); diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 0820e4506e5c6b8d51c2732c64afcb21566350dd..c72b99c6a11271870ab8d4b4b73a7c8eb5e095ba 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -4,7 +4,7 @@ use anyhow::{Context as _, Result, bail}; use async_trait::async_trait; use collections::{BTreeMap, IndexSet}; -use fs::Fs; + use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, }; @@ -62,7 +62,6 @@ impl ToolchainStore { worktree_store: Entity, project_environment: Entity, manifest_tree: Entity, - fs: Arc, cx: &mut Context, ) -> Self { let entity = cx.new(|_| LocalToolchainStore { @@ -71,7 +70,6 @@ impl ToolchainStore { project_environment, active_toolchains: Default::default(), manifest_tree, - fs, }); let _sub = cx.subscribe(&entity, |_, _, e: &ToolchainStoreEvent, cx| { cx.emit(e.clone()) @@ -418,7 +416,6 @@ pub struct LocalToolchainStore { project_environment: Entity, active_toolchains: BTreeMap<(WorktreeId, LanguageName), BTreeMap, Toolchain>>, manifest_tree: Entity, - fs: Arc, } #[async_trait(?Send)] @@ -507,7 +504,6 @@ impl LocalToolchainStore { let registry = self.languages.clone(); let manifest_tree = self.manifest_tree.downgrade(); - let fs = self.fs.clone(); let environment = self.project_environment.clone(); cx.spawn(async move |this, cx| { @@ -554,12 +550,7 @@ impl LocalToolchainStore { cx.background_spawn(async move { Some(( toolchains - .list( - worktree_root, - relative_path.path.clone(), - project_env, - fs.as_ref(), - ) + .list(worktree_root, relative_path.path.clone(), project_env) .await, relative_path.path, )) @@ -593,7 +584,6 @@ impl LocalToolchainStore { ) -> Task> { let registry = self.languages.clone(); let environment = self.project_environment.clone(); - let fs = self.fs.clone(); cx.spawn(async move |_, cx| { let language = cx .background_spawn(registry.language_for_name(&language_name.0)) @@ -612,12 +602,8 @@ impl LocalToolchainStore { ) }) .await; - cx.background_spawn(async move { - toolchain_lister - .resolve(path, project_env, fs.as_ref()) - .await - }) - .await + cx.background_spawn(async move { toolchain_lister.resolve(path, project_env).await }) + .await }) } } diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 5f2c590197b7f5d49d88da4cf97111865580c590..3230df665557077ed2f50142815242e7caef85a4 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -11931,7 +11931,6 @@ fn python_lang(fs: Arc) -> Arc { worktree_root: PathBuf, subroot_relative_path: Arc, _: Option>, - _: &dyn Fs, ) -> ToolchainList { // This lister will always return a path .venv directories within ancestors let ancestors = subroot_relative_path.ancestors().collect::>(); @@ -11956,7 +11955,6 @@ fn python_lang(fs: Arc) -> Arc { &self, _: PathBuf, _: Option>, - _: &dyn Fs, ) -> anyhow::Result { Err(anyhow::anyhow!("Not implemented")) } diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index ac6be6d413c08a73b1aa872b1f5acef6931d9c12..c725f8177648ea0ca16106251e65908255a38d6d 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -129,7 +129,6 @@ impl HeadlessProject { worktree_store.clone(), environment.clone(), manifest_tree.clone(), - fs.clone(), cx, ) }); diff --git a/crates/theme/src/styles/syntax.rs b/crates/theme/src/styles/syntax.rs index aa2590547c204ccd33871c00f74dc961470fdd4b..faf21d54f1f581efa8e44e3e9b478ed32ef93ea9 100644 --- a/crates/theme/src/styles/syntax.rs +++ b/crates/theme/src/styles/syntax.rs @@ -57,8 +57,8 @@ impl SyntaxTheme { ) } - pub fn get(&self, highlight_index: usize) -> Option<&HighlightStyle> { - self.highlights.get(highlight_index) + pub fn get(&self, highlight_index: impl Into) -> Option<&HighlightStyle> { + self.highlights.get(highlight_index.into()) } pub fn style_for_name(&self, name: &str) -> Option { @@ -67,7 +67,8 @@ impl SyntaxTheme { .map(|highlight_idx| self.highlights[*highlight_idx]) } - pub fn get_capture_name(&self, idx: usize) -> Option<&str> { + pub fn get_capture_name(&self, idx: impl Into) -> Option<&str> { + let idx = idx.into(); self.capture_name_map .iter() .find(|(_, value)| **value == idx) diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 85bc6991d3ece282b6dd549925c13c94b7919eef..0b68eb7dd0cac51352d5119c7a0c07ae8f8abb3d 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -18,6 +18,7 @@ use gpui::{ EntityId, Global, HighlightStyle, StyledText, Subscription, Task, TextStyle, WeakEntity, }; use language::{Buffer, BufferEvent, BufferId, Chunk, Point}; + use multi_buffer::MultiBufferRow; use picker::{Picker, PickerDelegate}; use project::{Project, ProjectItem, ProjectPath}; @@ -1402,8 +1403,8 @@ impl MarksMatchInfo { let mut offset = 0; for chunk in chunks { line.push_str(chunk.text); - if let Some(highlight_style) = chunk.syntax_highlight_id - && let Some(highlight) = highlight_style.style(cx.theme().syntax()) + if let Some(highlight_id) = chunk.syntax_highlight_id + && let Some(highlight) = cx.theme().syntax().get(highlight_id).cloned() { highlights.push((offset..offset + chunk.text.len(), highlight)) } From efb73d7796693d34bc955fa42b22de15b06528b4 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 26 Mar 2026 01:29:52 +0100 Subject: [PATCH 22/45] agent_ui: Add agent connection restart controls (#52401) Track agent connection status in the configuration UI, show a restart action for connected custom agents, and only render the External Agents menu section when entries exist. image Release Notes: - acp: Allow for restarting agent servers from the Agent Settings panel. --------- Co-authored-by: MrSubidubi Co-authored-by: Danilo Leal --- crates/agent_ui/src/agent_configuration.rs | 587 ++++++++---------- crates/agent_ui/src/agent_connection_store.rs | 174 ++++-- crates/agent_ui/src/agent_panel.rs | 6 +- crates/ui/src/components/ai.rs | 2 + .../ui/src/components/ai/ai_setting_item.rs | 406 ++++++++++++ .../ui/src/components/icon/icon_decoration.rs | 16 +- 6 files changed, 806 insertions(+), 385 deletions(-) create mode 100644 crates/ui/src/components/ai/ai_setting_item.rs diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index fc5a78dfc936617f3782eae154b6a13531e5c425..5f71df75d6287822c77eedfcb2f8fb96487b7950 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -4,7 +4,7 @@ mod configure_context_server_tools_modal; mod manage_profiles_modal; mod tool_picker; -use std::{ops::Range, sync::Arc}; +use std::{ops::Range, rc::Rc, sync::Arc}; use agent::ContextServerRegistry; use anyhow::Result; @@ -33,9 +33,9 @@ use project::{ }; use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ - ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, - DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip, - WithScrollbar, prelude::*, + AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu, + ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu, + Switch, Tooltip, WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{Workspace, create_and_open_local_file}; @@ -45,29 +45,32 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; -use crate::agent_configuration::add_llm_provider_modal::{ - AddLlmProviderModal, LlmCompatibleProvider, +use crate::{ + Agent, + agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, + agent_connection_store::{AgentConnectionStatus, AgentConnectionStore}, }; pub struct AgentConfiguration { fs: Arc, language_registry: Arc, agent_server_store: Entity, + agent_connection_store: Entity, workspace: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, expanded_provider_configurations: HashMap, context_server_registry: Entity, - _registry_subscription: Subscription, + _subscriptions: Vec, scroll_handle: ScrollHandle, - _check_for_gemini: Task<()>, } impl AgentConfiguration { pub fn new( fs: Arc, agent_server_store: Entity, + agent_connection_store: Entity, context_server_store: Entity, context_server_registry: Entity, language_registry: Arc, @@ -77,25 +80,27 @@ impl AgentConfiguration { ) -> Self { let focus_handle = cx.focus_handle(); - let registry_subscription = cx.subscribe_in( - &LanguageModelRegistry::global(cx), - window, - |this, _, event: &language_model::Event, window, cx| match event { - language_model::Event::AddedProvider(provider_id) => { - let provider = LanguageModelRegistry::read_global(cx).provider(provider_id); - if let Some(provider) = provider { - this.add_provider_configuration_view(&provider, window, cx); + let subscriptions = vec![ + cx.subscribe_in( + &LanguageModelRegistry::global(cx), + window, + |this, _, event: &language_model::Event, window, cx| match event { + language_model::Event::AddedProvider(provider_id) => { + let provider = LanguageModelRegistry::read_global(cx).provider(provider_id); + if let Some(provider) = provider { + this.add_provider_configuration_view(&provider, window, cx); + } } - } - language_model::Event::RemovedProvider(provider_id) => { - this.remove_provider_configuration_view(provider_id); - } - _ => {} - }, - ); - - cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) - .detach(); + language_model::Event::RemovedProvider(provider_id) => { + this.remove_provider_configuration_view(provider_id); + } + _ => {} + }, + ), + cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()), + cx.observe(&agent_connection_store, |_, _, cx| cx.notify()), + cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()), + ]; let mut this = Self { fs, @@ -104,13 +109,14 @@ impl AgentConfiguration { focus_handle, configuration_views_by_provider: HashMap::default(), agent_server_store, + agent_connection_store, context_server_store, expanded_provider_configurations: HashMap::default(), context_server_registry, - _registry_subscription: registry_subscription, + _subscriptions: subscriptions, scroll_handle: ScrollHandle::new(), - _check_for_gemini: Task::ready(()), }; + this.build_provider_configuration_views(window, cx); this } @@ -636,6 +642,22 @@ impl AgentConfiguration { ) }); + let display_name = if provided_by_extension { + resolve_extension_for_context_server(&context_server_id, cx) + .map(|(_, manifest)| { + let name = manifest.name.as_str(); + let stripped = name + .strip_suffix(" MCP Server") + .or_else(|| name.strip_suffix(" MCP")) + .or_else(|| name.strip_suffix(" Context Server")) + .unwrap_or(name); + SharedString::from(stripped.to_string()) + }) + .unwrap_or_else(|| item_id.clone()) + } else { + item_id.clone() + }; + let error = if let ContextServerStatus::Error(error) = server_status.clone() { Some(error) } else { @@ -651,57 +673,19 @@ impl AgentConfiguration { .tools_for_server(&context_server_id) .count(); - let (source_icon, source_tooltip) = if provided_by_extension { - ( - IconName::ZedSrcExtension, - "This MCP server was installed from an extension.", - ) + let source = if provided_by_extension { + AiSettingItemSource::Extension } else { - ( - IconName::ZedSrcCustom, - "This custom MCP server was installed directly.", - ) + AiSettingItemSource::Custom }; - let (status_indicator, tooltip_text) = match server_status { - ContextServerStatus::Starting => ( - Icon::new(IconName::LoadCircle) - .size(IconSize::XSmall) - .color(Color::Accent) - .with_keyed_rotate_animation( - SharedString::from(format!("{}-starting", context_server_id.0)), - 3, - ) - .into_any_element(), - "Server is starting.", - ), - ContextServerStatus::Running => ( - Indicator::dot().color(Color::Success).into_any_element(), - "Server is active.", - ), - ContextServerStatus::Error(_) => ( - Indicator::dot().color(Color::Error).into_any_element(), - "Server has an error.", - ), - ContextServerStatus::Stopped => ( - Indicator::dot().color(Color::Muted).into_any_element(), - "Server is stopped.", - ), - ContextServerStatus::AuthRequired => ( - Indicator::dot().color(Color::Warning).into_any_element(), - "Authentication required.", - ), - ContextServerStatus::Authenticating => ( - Icon::new(IconName::LoadCircle) - .size(IconSize::XSmall) - .color(Color::Accent) - .with_keyed_rotate_animation( - SharedString::from(format!("{}-authenticating", context_server_id.0)), - 3, - ) - .into_any_element(), - "Waiting for authorization...", - ), + let status = match server_status { + ContextServerStatus::Starting => AiSettingItemStatus::Starting, + ContextServerStatus::Running => AiSettingItemStatus::Running, + ContextServerStatus::Error(_) => AiSettingItemStatus::Error, + ContextServerStatus::Stopped => AiSettingItemStatus::Stopped, + ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired, + ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating, }; let is_remote = server_configuration @@ -845,232 +829,165 @@ impl AgentConfiguration { let feedback_base_container = || h_flex().py_1().min_w_0().w_full().gap_1().justify_between(); - v_flex() - .min_w_0() - .id(item_id.clone()) - .child( - h_flex() - .min_w_0() - .w_full() - .justify_between() + let details: Option = if let Some(error) = error { + Some( + feedback_base_container() .child( h_flex() - .flex_1() + .pr_4() .min_w_0() + .w_full() + .gap_2() .child( - h_flex() - .id(format!("tooltip-{}", item_id)) - .h_full() - .w_3() - .mr_2() - .justify_center() - .tooltip(Tooltip::text(tooltip_text)) - .child(status_indicator), - ) - .child(Label::new(item_id).flex_shrink_0().truncate()) - .child( - div() - .id("extension-source") - .min_w_0() - .mt_0p5() - .mx_1() - .tooltip(Tooltip::text(source_tooltip)) - .child( - Icon::new(source_icon) - .size(IconSize::Small) - .color(Color::Muted), - ), + Icon::new(IconName::XCircle) + .size(IconSize::XSmall) + .color(Color::Error), ) - .when(is_running, |this| { - this.child( - Label::new(if tool_count == 1 { - SharedString::from("1 tool") - } else { - SharedString::from(format!("{} tools", tool_count)) - }) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }), + .child(div().min_w_0().flex_1().child( + Label::new(error).color(Color::Muted).size(LabelSize::Small), + )), ) - .child( - h_flex() - .gap_0p5() - .flex_none() - .child(context_server_configuration_menu) - .child( - Switch::new("context-server-switch", is_running.into()) + .when(should_show_logout_button, |this| { + this.child( + Button::new("error-logout-server", "Log Out") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) .on_click({ - let context_server_manager = self.context_server_store.clone(); - let fs = self.fs.clone(); + let context_server_store = context_server_store.clone(); let context_server_id = context_server_id.clone(); - - move |state, _window, cx| { - let is_enabled = match state { - ToggleState::Unselected - | ToggleState::Indeterminate => { - context_server_manager.update(cx, |this, cx| { - this.stop_server(&context_server_id, cx) - .log_err(); - }); - false - } - ToggleState::Selected => { - context_server_manager.update(cx, |this, cx| { - if let Some(server) = - this.get_server(&context_server_id) - { - this.start_server(server, cx); - } - }); - true - } - }; - update_settings_file(fs.clone(), cx, { - let context_server_id = context_server_id.clone(); - - move |settings, _| { - settings - .project - .context_servers - .entry(context_server_id.0) - .or_insert_with(|| { - settings::ContextServerSettingsContent::Extension { - enabled: is_enabled, - remote: false, - settings: serde_json::json!({}), - } - }) - .set_enabled(is_enabled); - } + move |_event, _window, cx| { + context_server_store.update(cx, |store, cx| { + store.logout_server(&context_server_id, cx).log_err(); }); } }), - ), - ), + ) + }) + .into_any_element(), ) - .map(|parent| { - if let Some(error) = error { - return parent - .child( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::XCircle) - .size(IconSize::XSmall) - .color(Color::Error), - ) - .child( - div().min_w_0().flex_1().child( - Label::new(error) - .color(Color::Muted) - .size(LabelSize::Small), - ), - ), - ) - .when(should_show_logout_button, |this| { - this.child( - Button::new("error-logout-server", "Log Out") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_store = - context_server_store.clone(); - let context_server_id = - context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update( - cx, - |store, cx| { - store - .logout_server( - &context_server_id, - cx, - ) - .log_err(); - }, - ); - } - }), - ) - }), - ); - } - if auth_required { - return parent.child( - feedback_base_container() - .child( - h_flex() - .pr_4() - .min_w_0() - .w_full() - .gap_2() - .child( - Icon::new(IconName::Info) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( - Label::new("Authenticate to connect this server") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ) - .child( - Button::new("error-logout-server", "Authenticate") - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .on_click({ - let context_server_store = context_server_store.clone(); - let context_server_id = context_server_id.clone(); - move |_event, _window, cx| { - context_server_store.update(cx, |store, cx| { - store - .authenticate_server(&context_server_id, cx) - .log_err(); - }); - } - }), - ), - ); - } - if authenticating { - return parent.child( + } else if auth_required { + Some( + feedback_base_container() + .child( h_flex() - .mt_1() .pr_4() .min_w_0() .w_full() .gap_2() .child( - div().size_3().flex_shrink_0(), // Alignment Div + Icon::new(IconName::Info) + .size(IconSize::XSmall) + .color(Color::Muted), ) .child( - Label::new("Authenticating…") + Label::new("Authenticate to connect this server") .color(Color::Muted) .size(LabelSize::Small), ), + ) + .child( + Button::new("error-logout-server", "Authenticate") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click({ + let context_server_id = context_server_id.clone(); + move |_event, _window, cx| { + context_server_store.update(cx, |store, cx| { + store.authenticate_server(&context_server_id, cx).log_err(); + }); + } + }), + ) + .into_any_element(), + ) + } else if authenticating { + Some( + h_flex() + .mt_1() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child(div().size_3().flex_shrink_0()) + .child( + Label::new("Authenticating…") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element(), + ) + } else { + None + }; - ); - } - parent + let tool_label = if is_running { + Some(if tool_count == 1 { + SharedString::from("1 tool") + } else { + SharedString::from(format!("{} tools", tool_count)) }) + } else { + None + }; + + AiSettingItem::new(item_id, display_name, status, source) + .action(context_server_configuration_menu) + .action( + Switch::new("context-server-switch", is_running.into()).on_click({ + let context_server_manager = self.context_server_store.clone(); + let fs = self.fs.clone(); + + move |state, _window, cx| { + let is_enabled = match state { + ToggleState::Unselected | ToggleState::Indeterminate => { + context_server_manager.update(cx, |this, cx| { + this.stop_server(&context_server_id, cx).log_err(); + }); + false + } + ToggleState::Selected => { + context_server_manager.update(cx, |this, cx| { + if let Some(server) = this.get_server(&context_server_id) { + this.start_server(server, cx); + } + }); + true + } + }; + update_settings_file(fs.clone(), cx, { + let context_server_id = context_server_id.clone(); + + move |settings, _| { + settings + .project + .context_servers + .entry(context_server_id.0) + .or_insert_with(|| { + settings::ContextServerSettingsContent::Extension { + enabled: is_enabled, + remote: false, + settings: serde_json::json!({}), + } + }) + .set_enabled(is_enabled); + } + }); + } + }), + ) + .when_some(tool_label, |this, label| this.detail_label(label)) + .when_some(details, |this, details| this.details(details)) } fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { let agent_server_store = self.agent_server_store.read(cx); - let user_defined_agents = agent_server_store + let agents = agent_server_store .external_agents() .cloned() .collect::>(); - let user_defined_agents: Vec<_> = user_defined_agents + let agents: Vec<_> = agents .into_iter() .map(|name| { let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { @@ -1159,24 +1076,31 @@ impl AgentConfiguration { "All agents connected through the Agent Client Protocol.", add_agent_popover.into_any_element(), )) - .child(v_flex().p_4().pt_0().gap_2().map(|mut parent| { - let mut first = true; - for (name, icon, display_name, source) in user_defined_agents { - if !first { - parent = parent - .child(Divider::horizontal().color(DividerColor::BorderFaded)); - } - first = false; - parent = parent.child(self.render_agent_server( - icon, - name, - display_name, - source, - cx, - )); - } - parent - })), + .child( + v_flex() + .p_4() + .pt_0() + .gap_2() + .children(Itertools::intersperse_with( + agents + .into_iter() + .map(|(name, icon, display_name, source)| { + self.render_agent_server( + icon, + name, + display_name, + source, + cx, + ) + .into_any_element() + }), + || { + Divider::horizontal() + .color(DividerColor::BorderFaded) + .into_any_element() + }, + )), + ), ) } @@ -1200,27 +1124,46 @@ impl AgentConfiguration { .color(Color::Muted), }; - let source_badge = match source { - ExternalAgentSource::Extension => Some(( - SharedString::new(format!("agent-source-{}", id)), - SharedString::from(format!( - "The {} agent was installed from an extension.", - display_name - )), - IconName::ZedSrcExtension, - )), - ExternalAgentSource::Registry => Some(( - SharedString::new(format!("agent-source-{}", id)), - SharedString::from(format!( - "The {} agent was installed from the ACP registry.", - display_name - )), - IconName::AcpRegistry, - )), - ExternalAgentSource::Custom => None, + let source_kind = match source { + ExternalAgentSource::Extension => AiSettingItemSource::Extension, + ExternalAgentSource::Registry => AiSettingItemSource::Registry, + ExternalAgentSource::Custom => AiSettingItemSource::Custom, }; let agent_server_name = AgentId(id.clone()); + let agent = Agent::Custom { + id: agent_server_name.clone(), + }; + + let connection_status = self + .agent_connection_store + .read(cx) + .connection_status(&agent, cx); + + let restart_button = matches!( + connection_status, + AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting + ) + .then(|| { + IconButton::new( + SharedString::from(format!("restart-{}", id)), + IconName::RotateCw, + ) + .disabled(connection_status == AgentConnectionStatus::Connecting) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Restart Agent Connection")) + .on_click(cx.listener({ + let agent = agent.clone(); + move |this, _, _window, cx| { + let server: Rc = + Rc::new(agent_servers::CustomAgentServer::new(agent.id())); + this.agent_connection_store.update(cx, |store, cx| { + store.restart_connection(agent.clone(), server, cx); + }); + } + })) + }); let uninstall_button = match source { ExternalAgentSource::Extension => Some( @@ -1301,32 +1244,16 @@ impl AgentConfiguration { } }; - h_flex() - .gap_1() - .justify_between() - .child( - h_flex() - .gap_1p5() - .child(icon) - .child(Label::new(display_name)) - .when_some(source_badge, |this, (tooltip_id, tooltip_message, icon)| { - this.child( - div() - .id(tooltip_id) - .flex_none() - .tooltip(Tooltip::text(tooltip_message)) - .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)), - ) - }) - .child( - Icon::new(IconName::Check) - .color(Color::Success) - .size(IconSize::Small), - ), - ) - .when_some(uninstall_button, |this, uninstall_button| { - this.child(uninstall_button) - }) + let status = match connection_status { + AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped, + AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting, + AgentConnectionStatus::Connected => AiSettingItemStatus::Running, + }; + + AiSettingItem::new(id, display_name, status, source_kind) + .icon(icon) + .when_some(restart_button, |this, button| this.action(button)) + .when_some(uninstall_button, |this, button| this.action(button)) } } diff --git a/crates/agent_ui/src/agent_connection_store.rs b/crates/agent_ui/src/agent_connection_store.rs index 89b3b0ef16f46753a747b1e06a9b9e4a76e839e8..55b8c1493cc990310dc13253ce33cbc4b71a748f 100644 --- a/crates/agent_ui/src/agent_connection_store.rs +++ b/crates/agent_ui/src/agent_connection_store.rs @@ -5,7 +5,8 @@ use agent_servers::{AgentServer, AgentServerDelegate}; use anyhow::Result; use collections::HashMap; use futures::{FutureExt, future::Shared}; -use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task}; + use project::{AgentServerStore, AgentServersUpdated, Project}; use watch::Receiver; @@ -27,6 +28,13 @@ pub struct AgentConnectedState { pub history: Option>, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentConnectionStatus { + Disconnected, + Connecting, + Connected, +} + impl AgentConnectionEntry { pub fn wait_for_connection(&self) -> Shared>> { match self { @@ -42,6 +50,14 @@ impl AgentConnectionEntry { _ => None, } } + + pub fn status(&self) -> AgentConnectionStatus { + match self { + AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting, + AgentConnectionEntry::Connected(_) => AgentConnectionStatus::Connected, + AgentConnectionEntry::Error { .. } => AgentConnectionStatus::Disconnected, + } + } } pub enum AgentConnectionEntryEvent { @@ -71,66 +87,124 @@ impl AgentConnectionStore { self.entries.get(key) } + pub fn connection_status(&self, key: &Agent, cx: &App) -> AgentConnectionStatus { + self.entries + .get(key) + .map(|entry| entry.read(cx).status()) + .unwrap_or(AgentConnectionStatus::Disconnected) + } + + pub fn restart_connection( + &mut self, + key: Agent, + server: Rc, + cx: &mut Context, + ) -> Entity { + if let Some(entry) = self.entries.get(&key) { + if matches!(entry.read(cx), AgentConnectionEntry::Connecting { .. }) { + return entry.clone(); + } + } + + self.entries.remove(&key); + self.request_connection(key, server, cx) + } + pub fn request_connection( &mut self, key: Agent, server: Rc, cx: &mut Context, ) -> Entity { - self.entries.get(&key).cloned().unwrap_or_else(|| { - let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx); - let connect_task = connect_task.shared(); - - let entry = cx.new(|_cx| AgentConnectionEntry::Connecting { - connect_task: connect_task.clone(), - }); - - self.entries.insert(key.clone(), entry.clone()); - - cx.spawn({ - let key = key.clone(); - let entry = entry.clone(); - async move |this, cx| match connect_task.await { - Ok(connected_state) => { - entry.update(cx, |entry, cx| { - if let AgentConnectionEntry::Connecting { .. } = entry { - *entry = AgentConnectionEntry::Connected(connected_state); - cx.notify(); - } - }); - } - Err(error) => { - entry.update(cx, |entry, cx| { - if let AgentConnectionEntry::Connecting { .. } = entry { - *entry = AgentConnectionEntry::Error { error }; - cx.notify(); - } - }); - this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); - } + if let Some(entry) = self.entries.get(&key) { + return entry.clone(); + } + + let (mut new_version_rx, connect_task) = self.start_connection(server, cx); + let connect_task = connect_task.shared(); + + let entry = cx.new(|_cx| AgentConnectionEntry::Connecting { + connect_task: connect_task.clone(), + }); + + self.entries.insert(key.clone(), entry.clone()); + cx.notify(); + + cx.spawn({ + let key = key.clone(); + let entry = entry.downgrade(); + async move |this, cx| match connect_task.await { + Ok(connected_state) => { + this.update(cx, move |this, cx| { + if this.entries.get(&key) != entry.upgrade().as_ref() { + return; + } + + entry + .update(cx, move |entry, cx| { + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Connected(connected_state); + cx.notify(); + } + }) + .ok(); + }) + .ok(); } - }) - .detach(); - - cx.spawn({ - let entry = entry.clone(); - async move |this, cx| { - while let Ok(version) = new_version_rx.recv().await { - if let Some(version) = version { - entry.update(cx, |_entry, cx| { - cx.emit(AgentConnectionEntryEvent::NewVersionAvailable( - version.clone().into(), - )); - }); - this.update(cx, |this, _cx| this.entries.remove(&key)).ok(); + Err(error) => { + this.update(cx, move |this, cx| { + if this.entries.get(&key) != entry.upgrade().as_ref() { + return; } - } + + entry + .update(cx, move |entry, cx| { + if let AgentConnectionEntry::Connecting { .. } = entry { + *entry = AgentConnectionEntry::Error { error }; + cx.notify(); + } + }) + .ok(); + this.entries.remove(&key); + cx.notify(); + }) + .ok(); } - }) - .detach(); + } + }) + .detach(); + + cx.spawn({ + let entry = entry.downgrade(); + async move |this, cx| { + while let Ok(version) = new_version_rx.recv().await { + let Some(version) = version else { + continue; + }; + + this.update(cx, move |this, cx| { + if this.entries.get(&key) != entry.upgrade().as_ref() { + return; + } - entry + entry + .update(cx, move |_entry, cx| { + cx.emit(AgentConnectionEntryEvent::NewVersionAvailable( + version.into(), + )); + }) + .ok(); + this.entries.remove(&key); + cx.notify(); + }) + .ok(); + break; + } + } }) + .detach(); + + entry } fn handle_agent_servers_updated( diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index fcc141c85db6a8698b17f2b97336c11b6e67bf34..1015e87cef976be5584d7cb7607fd52f216babd6 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1689,6 +1689,7 @@ impl AgentPanel { AgentConfiguration::new( fs, agent_server_store, + self.connection_store.clone(), context_server_store, self.context_server_registry.clone(), self.language_registry.clone(), @@ -3822,8 +3823,6 @@ impl AgentPanel { } }), ) - .separator() - .header("External Agents") .map(|mut menu| { let agent_server_store = agent_server_store.read(cx); let registry_store = project::AgentRegistryStore::try_global(cx); @@ -3854,6 +3853,9 @@ impl AgentPanel { .sorted_unstable_by_key(|e| e.display_name.to_lowercase()) .collect::>(); + if !agent_items.is_empty() { + menu = menu.separator().header("External Agents"); + } for item in &agent_items { let mut entry = ContextMenuEntry::new(item.display_name.clone()); diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index a31db264e985b3adbca26b9e8d3fb2bdca306dcb..e3ad1db794902ae28b28274a60e3593efb3be392 100644 --- a/crates/ui/src/components/ai.rs +++ b/crates/ui/src/components/ai.rs @@ -1,5 +1,7 @@ +mod ai_setting_item; mod configured_api_card; mod thread_item; +pub use ai_setting_item::*; pub use configured_api_card::*; pub use thread_item::*; diff --git a/crates/ui/src/components/ai/ai_setting_item.rs b/crates/ui/src/components/ai/ai_setting_item.rs new file mode 100644 index 0000000000000000000000000000000000000000..bfb55e4c7da688b736b4ff5c64a5767f1e930120 --- /dev/null +++ b/crates/ui/src/components/ai/ai_setting_item.rs @@ -0,0 +1,406 @@ +use crate::{IconDecoration, IconDecorationKind, Tooltip, prelude::*}; +use gpui::{Animation, AnimationExt, SharedString, pulsating_between}; +use std::time::Duration; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AiSettingItemStatus { + #[default] + Stopped, + Starting, + Running, + Error, + AuthRequired, + Authenticating, +} + +impl AiSettingItemStatus { + fn tooltip_text(&self) -> &'static str { + match self { + Self::Stopped => "Server is stopped.", + Self::Starting => "Server is starting.", + Self::Running => "Server is active.", + Self::Error => "Server has an error.", + Self::AuthRequired => "Authentication required.", + Self::Authenticating => "Waiting for authorization…", + } + } + + fn indicator_color(&self) -> Option { + match self { + Self::Stopped => None, + Self::Starting | Self::Authenticating => Some(Color::Muted), + Self::Running => Some(Color::Success), + Self::Error => Some(Color::Error), + Self::AuthRequired => Some(Color::Warning), + } + } + + fn is_animated(&self) -> bool { + matches!(self, Self::Starting | Self::Authenticating) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AiSettingItemSource { + Extension, + Custom, + Registry, +} + +impl AiSettingItemSource { + fn icon_name(&self) -> IconName { + match self { + Self::Extension => IconName::ZedSrcExtension, + Self::Custom => IconName::ZedSrcCustom, + Self::Registry => IconName::AcpRegistry, + } + } + + fn tooltip_text(&self, label: &str) -> String { + match self { + Self::Extension => format!("{label} was installed from an extension."), + Self::Registry => format!("{label} was installed from the ACP registry."), + Self::Custom => format!("{label} was configured manually."), + } + } +} + +/// A reusable setting item row for AI-related configuration lists. +#[derive(IntoElement, RegisterComponent)] +pub struct AiSettingItem { + id: ElementId, + status: AiSettingItemStatus, + source: AiSettingItemSource, + icon: Option, + label: SharedString, + detail_label: Option, + actions: Vec, + details: Option, +} + +impl AiSettingItem { + pub fn new( + id: impl Into, + label: impl Into, + status: AiSettingItemStatus, + source: AiSettingItemSource, + ) -> Self { + Self { + id: id.into(), + status, + source, + icon: None, + label: label.into(), + detail_label: None, + actions: Vec::new(), + details: None, + } + } + + pub fn icon(mut self, element: impl IntoElement) -> Self { + self.icon = Some(element.into_any_element()); + self + } + + pub fn detail_label(mut self, detail: impl Into) -> Self { + self.detail_label = Some(detail.into()); + self + } + + pub fn action(mut self, element: impl IntoElement) -> Self { + self.actions.push(element.into_any_element()); + self + } + + pub fn details(mut self, element: impl IntoElement) -> Self { + self.details = Some(element.into_any_element()); + self + } +} + +impl RenderOnce for AiSettingItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let Self { + id, + status, + source, + icon, + label, + detail_label, + actions, + details, + } = self; + + let source_id = format!("source-{}", id); + let icon_id = format!("icon-{}", id); + let status_tooltip = status.tooltip_text(); + let source_tooltip = source.tooltip_text(&label); + + let icon_element = icon.unwrap_or_else(|| { + let letter = label.chars().next().unwrap_or('?').to_ascii_uppercase(); + + h_flex() + .size_5() + .flex_none() + .justify_center() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().element_active.opacity(0.2)) + .child( + Label::new(SharedString::from(letter.to_string())) + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ) + .into_any_element() + }); + + let icon_child = if status.is_animated() { + div() + .child(icon_element) + .with_animation( + format!("icon-pulse-{}", id), + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |element, delta| element.opacity(delta), + ) + .into_any_element() + } else { + icon_element.into_any_element() + }; + + let icon_container = div() + .id(icon_id) + .relative() + .flex_none() + .tooltip(Tooltip::text(status_tooltip)) + .child(icon_child) + .when_some(status.indicator_color(), |this, color| { + this.child( + IconDecoration::new( + IconDecorationKind::Dot, + cx.theme().colors().panel_background, + cx, + ) + .size(px(12.)) + .color(color.color(cx)) + .position(gpui::Point { + x: px(-3.), + y: px(-3.), + }), + ) + }); + + v_flex() + .id(id) + .min_w_0() + .child( + h_flex() + .min_w_0() + .w_full() + .gap_1p5() + .justify_between() + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1p5() + .child(icon_container) + .child(Label::new(label).flex_shrink_0().truncate()) + .child( + div() + .id(source_id) + .min_w_0() + .flex_none() + .tooltip(Tooltip::text(source_tooltip)) + .child( + Icon::new(source.icon_name()) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + .when_some(detail_label, |this, detail| { + this.child( + Label::new(detail) + .color(Color::Muted) + .size(LabelSize::Small), + ) + }), + ) + .when(!actions.is_empty(), |this| { + this.child(h_flex().gap_0p5().flex_none().children(actions)) + }), + ) + .children(details) + } +} + +impl Component for AiSettingItem { + fn scope() -> ComponentScope { + ComponentScope::Agent + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let container = || { + v_flex() + .w_80() + .p_2() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + }; + + let details_row = |icon_name: IconName, icon_color: Color, message: &str| { + h_flex() + .py_1() + .min_w_0() + .w_full() + .gap_2() + .justify_between() + .child( + h_flex() + .pr_4() + .min_w_0() + .w_full() + .gap_2() + .child( + Icon::new(icon_name) + .size(IconSize::XSmall) + .color(icon_color), + ) + .child( + div().min_w_0().flex_1().child( + Label::new(SharedString::from(message.to_string())) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ), + ) + }; + + let examples = vec![ + single_example( + "MCP server with letter avatar (running)", + container() + .child( + AiSettingItem::new( + "ext-mcp", + "Postgres", + AiSettingItemStatus::Running, + AiSettingItemSource::Extension, + ) + .detail_label("3 tools") + .action( + IconButton::new("menu", IconName::Settings) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ) + .action( + IconButton::new("toggle", IconName::Check) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "MCP server (stopped)", + container() + .child(AiSettingItem::new( + "custom-mcp", + "my-local-server", + AiSettingItemStatus::Stopped, + AiSettingItemSource::Custom, + )) + .into_any_element(), + ), + single_example( + "MCP server (starting, animated)", + container() + .child(AiSettingItem::new( + "starting-mcp", + "Context7", + AiSettingItemStatus::Starting, + AiSettingItemSource::Extension, + )) + .into_any_element(), + ), + single_example( + "Agent with icon (running)", + container() + .child( + AiSettingItem::new( + "ext-agent", + "Claude Agent", + AiSettingItemStatus::Running, + AiSettingItemSource::Extension, + ) + .icon( + Icon::new(IconName::AiClaude) + .size(IconSize::Small) + .color(Color::Muted), + ) + .action( + IconButton::new("restart", IconName::RotateCw) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ) + .action( + IconButton::new("delete", IconName::Trash) + .icon_size(IconSize::Small) + .icon_color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "Registry agent (starting, animated)", + container() + .child( + AiSettingItem::new( + "reg-agent", + "Devin Agent", + AiSettingItemStatus::Starting, + AiSettingItemSource::Registry, + ) + .icon( + Icon::new(IconName::ZedAssistant) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + .into_any_element(), + ), + single_example( + "Error with details", + container() + .child( + AiSettingItem::new( + "error-mcp", + "Amplitude", + AiSettingItemStatus::Error, + AiSettingItemSource::Extension, + ) + .details( + details_row( + IconName::XCircle, + Color::Error, + "Failed to connect: connection refused", + ) + .child( + Button::new("logout", "Log Out") + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small), + ), + ), + ) + .into_any_element(), + ), + ]; + + Some(example_group(examples).vertical().into_any_element()) + } +} diff --git a/crates/ui/src/components/icon/icon_decoration.rs b/crates/ui/src/components/icon/icon_decoration.rs index 9f84a8bcf4eb10672161ed2733d7ed5baa95f899..423f6d73a68e8ee3aea550129e2a6220a8a699a6 100644 --- a/crates/ui/src/components/icon/icon_decoration.rs +++ b/crates/ui/src/components/icon/icon_decoration.rs @@ -63,6 +63,7 @@ pub struct IconDecoration { color: Hsla, knockout_color: Hsla, knockout_hover_color: Hsla, + size: Pixels, position: Point, group_name: Option, } @@ -78,6 +79,7 @@ impl IconDecoration { color, knockout_color, knockout_hover_color: knockout_color, + size: ICON_DECORATION_SIZE, position, group_name: None, } @@ -116,6 +118,12 @@ impl IconDecoration { self } + /// Sets the size of the decoration. + pub fn size(mut self, size: Pixels) -> Self { + self.size = size; + self + } + /// Sets the name of the group the decoration belongs to pub fn group_name(mut self, name: Option) -> Self { self.group_name = name; @@ -125,11 +133,13 @@ impl IconDecoration { impl RenderOnce for IconDecoration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let size = self.size; + let foreground = svg() .absolute() .bottom_0() .right_0() - .size(ICON_DECORATION_SIZE) + .size(size) .path(self.kind.fg().path()) .text_color(self.color); @@ -137,7 +147,7 @@ impl RenderOnce for IconDecoration { .absolute() .bottom_0() .right_0() - .size(ICON_DECORATION_SIZE) + .size(size) .path(self.kind.bg().path()) .text_color(self.knockout_color) .map(|this| match self.group_name { @@ -148,7 +158,7 @@ impl RenderOnce for IconDecoration { }); div() - .size(ICON_DECORATION_SIZE) + .size(size) .flex_none() .absolute() .bottom(self.position.y) From 8eb86241f6727835bbd9837af1f169344a9aa29c Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 25 Mar 2026 18:35:10 -0700 Subject: [PATCH 23/45] Add a setting for moving the sidebar to the right (#52457) ## Context This adds a setting for controlling the sidebar side ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Eric --- Cargo.lock | 2 + assets/icons/threads_sidebar_right_closed.svg | 5 + assets/icons/threads_sidebar_right_open.svg | 5 + assets/settings/default.json | 2 + crates/agent/src/tool_permissions.rs | 1 + crates/agent_settings/src/agent_settings.rs | 16 +- crates/agent_ui/src/agent_panel.rs | 6 +- crates/agent_ui/src/agent_ui.rs | 2 +- crates/agent_ui/src/threads_archive_view.rs | 21 ++- crates/collab_ui/src/notification_panel.rs | 2 +- crates/debugger_ui/src/debugger_panel.rs | 2 +- crates/git_ui/src/git_panel.rs | 2 +- crates/icons/src/icons.rs | 2 + .../src/platform_title_bar.rs | 47 ++++-- crates/project_panel/src/project_panel.rs | 2 +- crates/settings_content/src/agent.rs | 41 +++++ crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 159 +++++++++++------- crates/terminal_view/src/terminal_panel.rs | 2 +- crates/title_bar/src/title_bar.rs | 114 ++++--------- crates/workspace/Cargo.toml | 1 + crates/workspace/src/dock.rs | 14 ++ crates/workspace/src/multi_workspace.rs | 137 +++++++++++---- crates/workspace/src/status_bar.rs | 156 ++++++++++++----- crates/workspace/src/workspace.rs | 47 ++++-- 25 files changed, 524 insertions(+), 265 deletions(-) create mode 100644 assets/icons/threads_sidebar_right_closed.svg create mode 100644 assets/icons/threads_sidebar_right_open.svg diff --git a/Cargo.lock b/Cargo.lock index dd9e9399d00845bfe2382183e6359653466cff4c..16f8dd76ab23bf274b1e6b79515fa8060f2a646f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15980,6 +15980,7 @@ dependencies = [ "action_log", "agent", "agent-client-protocol", + "agent_settings", "agent_ui", "anyhow", "assistant_text_thread", @@ -21516,6 +21517,7 @@ dependencies = [ name = "workspace" version = "0.1.0" dependencies = [ + "agent_settings", "any_vec", "anyhow", "async-recursion", diff --git a/assets/icons/threads_sidebar_right_closed.svg b/assets/icons/threads_sidebar_right_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..10fa4b792fd65b5875dcf2cadab1fc12a123ab47 --- /dev/null +++ b/assets/icons/threads_sidebar_right_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/threads_sidebar_right_open.svg b/assets/icons/threads_sidebar_right_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..23a01eb3f82a5866157220172c868ed9ded46033 --- /dev/null +++ b/assets/icons/threads_sidebar_right_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 6a973314fc36b3b3cc1056dbb10a629f7868d2a1..9ea3285f90885d1ab2c33717b802ac6e8ebbfe3d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -943,6 +943,8 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", + // Where to position the sidebar. Can be 'left', 'right', or 'follow_agent'. + "sidebar_side": "follow_agent", // Default width when the agent panel is docked to the left or right. "default_width": 640, // Default height when the agent panel is docked to the bottom. diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 345511c5025b25601c630c572980d44a23f724e7..ec22fa11da00f6f41dfbdcee283ea983fbeac1af 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -596,6 +596,7 @@ mod tests { tool_permissions, show_turn_stats: false, new_thread_location: Default::default(), + sidebar_side: Default::default(), } } diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index d5d4f16eb742a92f6abf8081c43709f161ef4038..ec0a46af0636877210d26b2c45660baae648ffea 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -12,7 +12,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, - NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode, + NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SidebarDockPosition, + SidebarSide, ToolPermissionMode, }; pub use crate::agent_profile::*; @@ -26,6 +27,7 @@ pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, + pub sidebar_side: SidebarDockPosition, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, @@ -77,6 +79,17 @@ impl AgentSettings { return None; } + pub fn sidebar_side(&self) -> SidebarSide { + match self.sidebar_side { + SidebarDockPosition::Left => SidebarSide::Left, + SidebarDockPosition::Right => SidebarSide::Right, + SidebarDockPosition::FollowAgent => match self.dock { + DockPosition::Right => SidebarSide::Right, + _ => SidebarSide::Left, + }, + } + } + pub fn set_message_editor_max_lines(&self) -> usize { self.message_editor_min_lines * 2 } @@ -407,6 +420,7 @@ impl Settings for AgentSettings { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), + sidebar_side: agent.sidebar_side.unwrap(), default_width: px(agent.default_width.unwrap()), default_height: px(agent.default_height.unwrap()), default_model: Some(agent.default_model.unwrap()), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1015e87cef976be5584d7cb7607fd52f216babd6..ff819b3730bdaf8dc89d5c40e5fdad04b3342496 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -3186,13 +3186,17 @@ impl Panel for AgentPanel { } fn activation_priority(&self) -> u32 { - 8 + 0 } fn enabled(&self, cx: &App) -> bool { AgentSettings::get_global(cx).enabled(cx) } + fn is_agent_panel(&self) -> bool { + true + } + fn is_zoomed(&self, _window: &Window, _cx: &App) -> bool { self.zoomed } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 1817fdbe31a4fed12caf2e5f804461aecf9da973..2395a74c281f5b84ab1f328b175fe8385ce3fb12 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -648,7 +648,6 @@ mod tests { default_profile: AgentProfileId::default(), default_view: DefaultAgentView::Thread, profiles: Default::default(), - notify_when_agent_waiting: NotifyWhenAgentWaiting::default(), play_sound_when_agent_done: false, single_file_review: false, @@ -662,6 +661,7 @@ mod tests { tool_permissions: Default::default(), show_turn_stats: false, new_thread_location: Default::default(), + sidebar_side: Default::default(), }; cx.update(|cx| { diff --git a/crates/agent_ui/src/threads_archive_view.rs b/crates/agent_ui/src/threads_archive_view.rs index ef4e3ab5393b1045b4de15b348c3e01e07c366bc..a5c83d48b71c08f183c842cb3b5a3d8b75db4d89 100644 --- a/crates/agent_ui/src/threads_archive_view.rs +++ b/crates/agent_ui/src/threads_archive_view.rs @@ -7,6 +7,7 @@ use crate::{ use acp_thread::AgentSessionInfo; use agent::ThreadStore; use agent_client_protocol as acp; +use agent_settings::AgentSettings; use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc}; use editor::Editor; use fs::Fs; @@ -17,6 +18,7 @@ use gpui::{ use itertools::Itertools as _; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{AgentId, AgentServerStore}; +use settings::Settings as _; use theme::ActiveTheme; use ui::{ ButtonLike, CommonAnimationExt, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel, @@ -795,7 +797,12 @@ impl ThreadsArchiveView { fn render_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let has_query = !self.filter_editor.read(cx).text(cx).is_empty(); - let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen(); + let sidebar_on_left = matches!( + AgentSettings::get_global(cx).sidebar_side(), + settings::SidebarSide::Left + ); + let traffic_lights = + cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left; let header_height = platform_title_bar_height(window); let show_focus_keybinding = self.selection.is_some() && !self.filter_editor.focus_handle(cx).is_focused(window); @@ -804,15 +811,21 @@ impl ThreadsArchiveView { .h(header_height) .mt_px() .pb_px() - .when(traffic_lights, |this| { - this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + .map(|this| { + if traffic_lights { + this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING)) + } else { + this.pl_1p5() + } }) .pr_1p5() .gap_1() .justify_between() .border_b_1() .border_color(cx.theme().colors().border) - .child(Divider::vertical().color(ui::DividerColor::Border)) + .when(traffic_lights, |this| { + this.child(Divider::vertical().color(ui::DividerColor::Border)) + }) .child( h_flex() .ml_1() diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index a40dc154b0f4e498f435080572ed5a7161917ab3..d7fef4873c687ab23a25b3144ba902cf4c42c137 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -640,7 +640,7 @@ impl Panel for NotificationPanel { } fn activation_priority(&self) -> u32 { - 3 + 4 } } diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 3f2ba98de7519e8343f4bc1791a6d8f7f36b3e86..c2d8a7a5478cfc9eae53f9e7a6018864865a4d1a 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -1601,7 +1601,7 @@ impl Panel for DebugPanel { } fn activation_priority(&self) -> u32 { - 9 + 7 } fn set_active(&mut self, _: bool, _: &mut Window, _: &mut Context) {} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 77d05f52f7b799bfdbb4b081e3ca2368de6fd45f..00b287f7f3d724e0fcc5275ea302c44983c9a61b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -5818,7 +5818,7 @@ impl Panel for GitPanel { } fn activation_priority(&self) -> u32 { - 2 + 3 } } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index c8bf3b4e7708650a030218c91bb71bfd6a398635..89932125c1bfbc05202038c1abac2a6380e19e93 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -244,6 +244,8 @@ pub enum IconName { ThreadFromSummary, ThreadsSidebarLeftClosed, ThreadsSidebarLeftOpen, + ThreadsSidebarRightClosed, + ThreadsSidebarRightOpen, ThumbsDown, ThumbsUp, TodoComplete, diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index c5c9f94290c0e96a63fb71a098ea7ea29ec1e3cd..777dd9e2d905e7ba54fcb97b9912610b04c49527 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -4,8 +4,8 @@ mod system_window_tabs; use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ Action, AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowButtonLayout, - WindowControlArea, div, px, + MouseButton, ParentElement, StatefulInteractiveElement, Styled, WeakEntity, Window, + WindowButtonLayout, WindowControlArea, div, px, }; use project::DisableAiSettings; use settings::Settings; @@ -15,6 +15,7 @@ use ui::{ prelude::*, utils::{TRAFFIC_LIGHT_PADDING, platform_title_bar_height}, }; +use workspace::{MultiWorkspace, SidebarRenderState, SidebarSide}; use crate::{ platforms::{platform_linux, platform_windows}, @@ -32,7 +33,7 @@ pub struct PlatformTitleBar { should_move: bool, system_window_tabs: Entity, button_layout: Option, - workspace_sidebar_open: bool, + multi_workspace: Option>, } impl PlatformTitleBar { @@ -47,10 +48,19 @@ impl PlatformTitleBar { should_move: false, system_window_tabs, button_layout: None, - workspace_sidebar_open: false, + multi_workspace: None, } } + pub fn with_multi_workspace(mut self, multi_workspace: WeakEntity) -> Self { + self.multi_workspace = Some(multi_workspace); + self + } + + pub fn set_multi_workspace(&mut self, multi_workspace: WeakEntity) { + self.multi_workspace = Some(multi_workspace); + } + pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context) -> Hsla { if cfg!(any(target_os = "linux", target_os = "freebsd")) { if window.is_window_active() && !self.should_move { @@ -92,13 +102,12 @@ impl PlatformTitleBar { SystemWindowTabs::init(cx); } - pub fn is_workspace_sidebar_open(&self) -> bool { - self.workspace_sidebar_open - } - - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); + fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState { + self.multi_workspace + .as_ref() + .and_then(|mw| mw.upgrade()) + .map(|mw| mw.read(cx).sidebar_render_state(cx)) + .unwrap_or_default() } pub fn is_multi_workspace_enabled(cx: &App) -> bool { @@ -116,8 +125,7 @@ impl Render for PlatformTitleBar { let children = mem::take(&mut self.children); let button_layout = self.effective_button_layout(&decorations, cx); - let is_multiworkspace_sidebar_open = - PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); + let sidebar = self.sidebar_render_state(cx); let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) @@ -168,7 +176,7 @@ impl Render for PlatformTitleBar { if window.is_fullscreen() { this.pl_2() } else if self.platform_style == PlatformStyle::Mac - && !is_multiworkspace_sidebar_open + && !(sidebar.open && sidebar.side == SidebarSide::Left) { this.pl(px(TRAFFIC_LIGHT_PADDING)) } else if let Some(button_layout) = @@ -186,11 +194,14 @@ impl Render for PlatformTitleBar { .map(|el| match decorations { Decorations::Server => el, Decorations::Client { tiling, .. } => el - .when(!(tiling.top || tiling.right), |el| { - el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) .when( - !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open, + !(tiling.top || tiling.right) + && !(sidebar.open && sidebar.side == SidebarSide::Right), + |el| el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.top || tiling.left) + && !(sidebar.open && sidebar.side == SidebarSide::Left), |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), ) // this border is to avoid a transparent gap in the rounded corners diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 68199376e8bd39cf4014bb219a305f52a46347cb..ac348d9d5bab659c115f7b6c9f1a11c4d7c951bc 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -7239,7 +7239,7 @@ impl Panel for ProjectPanel { } fn activation_priority(&self) -> u32 { - 0 + 1 } } diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 1b71f9b33c58b6980431d25f2af51007ae861a1c..0c77957bc6a1dab2af47164cbd1f46c5dc679d37 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -33,6 +33,39 @@ pub enum NewThreadLocation { NewWorktree, } +/// Where to position the sidebar. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum SidebarDockPosition { + /// Always show the sidebar on the left side. + Left, + /// Always show the sidebar on the right side. + Right, + /// Show the sidebar on the same side as the agent panel. + #[default] + FollowAgent, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum SidebarSide { + #[default] + Left, + Right, +} + #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] pub struct AgentSettingsContent { @@ -48,6 +81,10 @@ pub struct AgentSettingsContent { /// /// Default: right pub dock: Option, + /// Where to position the sidebar. + /// + /// Default: follow_agent + pub sidebar_side: Option, /// Default width in pixels when the agent panel is docked to the left or right. /// /// Default: 640 @@ -157,6 +194,10 @@ impl AgentSettingsContent { self.dock = Some(dock); } + pub fn set_sidebar_side(&mut self, position: SidebarDockPosition) { + self.sidebar_side = Some(position); + } + pub fn set_model(&mut self, language_model: LanguageModelSelection) { self.default_model = Some(language_model) } diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index 6b4d93790236f32b0533374626e337f5c05ab75b..7fcb97a92695b5c3e9e1b32560f332d6bd6908d5 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -19,6 +19,7 @@ acp_thread.workspace = true action_log.workspace = true agent.workspace = true agent-client-protocol.workspace = true +agent_settings.workspace = true agent_ui.workspace = true anyhow.workspace = true chrono.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 252b306ce0af157954971c986499b372f2a2290f..501b55a73260f0d453775fc245868669c35ab406 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1,6 +1,7 @@ use acp_thread::ThreadStatus; use action_log::DiffStats; use agent_client_protocol::{self as acp}; +use agent_settings::AgentSettings; use agent_ui::thread_metadata_store::{SidebarThreadMetadataStore, ThreadMetadata}; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, @@ -37,7 +38,8 @@ use util::ResultExt as _; use util::path_list::PathList; use workspace::{ AddFolderToProject, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, Open, - Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar, Workspace, WorkspaceId, + Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId, + sidebar_side_context_menu, }; use zed_actions::OpenRecent; @@ -2874,7 +2876,9 @@ impl Sidebar { cx: &mut Context, ) -> impl IntoElement { let has_query = self.has_filter_query(cx); - let traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen(); + let sidebar_on_left = self.side(cx) == SidebarSide::Left; + let traffic_lights = + cfg!(target_os = "macos") && !window.is_fullscreen() && sidebar_on_left; let header_height = platform_title_bar_height(window); h_flex() @@ -2928,38 +2932,92 @@ impl Sidebar { } fn render_sidebar_toggle_button(&self, _cx: &mut Context) -> impl IntoElement { - IconButton::new("sidebar-close-toggle", IconName::ThreadsSidebarLeftOpen) - .icon_size(IconSize::Small) - .tooltip(Tooltip::element(move |_window, cx| { - v_flex() - .gap_1() - .child( - h_flex() - .gap_2() - .justify_between() - .child(Label::new("Toggle Sidebar")) - .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)), - ) - .child( - h_flex() - .pt_1() - .gap_2() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .justify_between() - .child(Label::new("Focus Sidebar")) - .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)), - ) - .into_any_element() - })) - .on_click(|_, window, cx| { - if let Some(multi_workspace) = window.root::().flatten() { - multi_workspace.update(cx, |multi_workspace, cx| { - multi_workspace.close_sidebar(window, cx); - }); - } + let on_right = AgentSettings::get_global(_cx).sidebar_side() == SidebarSide::Right; + + sidebar_side_context_menu("sidebar-toggle-menu", _cx) + .anchor(if on_right { + gpui::Corner::BottomRight + } else { + gpui::Corner::BottomLeft + }) + .attach(if on_right { + gpui::Corner::TopRight + } else { + gpui::Corner::TopLeft + }) + .trigger(move |_is_active, _window, _cx| { + let icon = if on_right { + IconName::ThreadsSidebarRightOpen + } else { + IconName::ThreadsSidebarLeftOpen + }; + IconButton::new("sidebar-close-toggle", icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_window, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Toggle Sidebar")) + .child(KeyBinding::for_action(&ToggleWorkspaceSidebar, cx)), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new("Focus Sidebar")) + .child(KeyBinding::for_action(&FocusWorkspaceSidebar, cx)), + ) + .into_any_element() + })) + .on_click(|_, window, cx| { + if let Some(multi_workspace) = window.root::().flatten() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.close_sidebar(window, cx); + }); + } + }) }) } + + fn render_sidebar_bottom_bar(&mut self, cx: &mut Context) -> impl IntoElement { + let on_right = self.side(cx) == SidebarSide::Right; + let is_archive = matches!(self.view, SidebarView::Archive(..)); + let action_buttons = h_flex() + .gap_1() + .child( + IconButton::new("archive", IconName::Archive) + .icon_size(IconSize::Small) + .toggle_state(is_archive) + .tooltip(move |_, cx| { + Tooltip::for_action("Toggle Archived Threads", &ToggleArchive, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_archive(&ToggleArchive, window, cx); + })), + ) + .child(self.render_recent_projects_button(cx)); + let border_color = cx.theme().colors().border; + let toggle_button = self.render_sidebar_toggle_button(cx); + + let bar = h_flex() + .p_1() + .gap_1() + .justify_between() + .border_t_1() + .border_color(border_color); + + if on_right { + bar.child(action_buttons).child(toggle_button) + } else { + bar.child(toggle_button).child(action_buttons) + } + } } impl Sidebar { @@ -3054,6 +3112,10 @@ impl WorkspaceSidebar for Sidebar { matches!(self.view, SidebarView::ThreadList) } + fn side(&self, cx: &App) -> SidebarSide { + AgentSettings::get_global(cx).sidebar_side() + } + fn prepare_for_focus(&mut self, _window: &mut Window, cx: &mut Context) { self.selection = None; cx.notify(); @@ -3108,7 +3170,8 @@ impl Render for Sidebar { .h_full() .w(self.width) .bg(bg) - .border_r_1() + .when(self.side(cx) == SidebarSide::Left, |el| el.border_r_1()) + .when(self.side(cx) == SidebarSide::Right, |el| el.border_l_1()) .border_color(color.border) .map(|this| match &self.view { SidebarView::ThreadList => this @@ -3140,35 +3203,7 @@ impl Render for Sidebar { }), SidebarView::Archive(archive_view) => this.child(archive_view.clone()), }) - .child( - h_flex() - .p_1() - .gap_1() - .justify_between() - .border_t_1() - .border_color(cx.theme().colors().border) - .child(self.render_sidebar_toggle_button(cx)) - .child( - h_flex() - .gap_1() - .child( - IconButton::new("archive", IconName::Archive) - .icon_size(IconSize::Small) - .toggle_state(matches!(self.view, SidebarView::Archive(..))) - .tooltip(move |_, cx| { - Tooltip::for_action( - "Toggle Archived Threads", - &ToggleArchive, - cx, - ) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_archive(&ToggleArchive, window, cx); - })), - ) - .child(self.render_recent_projects_button(cx)), - ), - ) + .child(self.render_sidebar_bottom_bar(cx)) } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 2ecc959663a082aac2d37410639f55e4ff242681..4f79914eb889b4402eb86ce7ca5359d3d0e16085 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1654,7 +1654,7 @@ impl Panel for TerminalPanel { } fn activation_priority(&self) -> u32 { - 1 + 2 } } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b40029bf93365d2b1fbaa4902f26e2bcb24ee9af..86e15dd7284881961cbc2c43f17e603ea3d39bc3 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -81,7 +81,8 @@ pub fn init(cx: &mut App) { let Some(window) = window else { return; }; - let item = cx.new(|cx| TitleBar::new("title-bar", workspace, window, cx)); + let multi_workspace = workspace.multi_workspace().cloned(); + let item = cx.new(|cx| TitleBar::new("title-bar", workspace, multi_workspace, window, cx)); workspace.set_titlebar_item(item.into(), window, cx); workspace.register_action(|workspace, _: &SimulateUpdateAvailable, _window, cx| { @@ -161,7 +162,18 @@ pub struct TitleBar { impl Render for TitleBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.sync_multi_workspace(window, cx); + if self.multi_workspace.is_none() { + if let Some(mw) = self + .workspace + .upgrade() + .and_then(|ws| ws.read(cx).multi_workspace().cloned()) + { + self.multi_workspace = Some(mw.clone()); + self.platform_titlebar.update(cx, |titlebar, _cx| { + titlebar.set_multi_workspace(mw); + }); + } + } let title_bar_settings = *TitleBarSettings::get_global(cx); let button_layout = title_bar_settings.button_layout; @@ -308,6 +320,7 @@ impl TitleBar { pub fn new( id: impl Into, workspace: &Workspace, + multi_workspace: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -385,52 +398,19 @@ impl TitleBar { }); let update_version = cx.new(|cx| UpdateVersion::new(cx)); - let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx)); - - // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar. - { - let platform_titlebar = platform_titlebar.clone(); - let window_handle = window.window_handle(); - cx.spawn(async move |this: WeakEntity, cx| { - let Some(multi_workspace_handle) = window_handle.downcast::() - else { - return; - }; - - let _ = cx.update(|cx| { - let Ok(multi_workspace) = multi_workspace_handle.entity(cx) else { - return; - }; - - let is_open = multi_workspace.read(cx).sidebar_open(); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - }); - - let platform_titlebar = platform_titlebar.clone(); - let subscription = cx.observe(&multi_workspace, move |mw, cx| { - let is_open = mw.read(cx).sidebar_open(); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - }); - }); - - if let Some(this) = this.upgrade() { - this.update(cx, |this, _| { - this._subscriptions.push(subscription); - this.multi_workspace = Some(multi_workspace.downgrade()); - }); - } - }); - }) - .detach(); - } + let platform_titlebar = cx.new(|cx| { + let mut titlebar = PlatformTitleBar::new(id, cx); + if let Some(mw) = multi_workspace.clone() { + titlebar = titlebar.with_multi_workspace(mw); + } + titlebar + }); let mut this = Self { platform_titlebar, application_menu, workspace: workspace.weak_handle(), - multi_workspace: None, + multi_workspace, project, user_store, client, @@ -446,46 +426,6 @@ impl TitleBar { this } - /// Used to update the title bar state in case the workspace has - /// been moved to a new window through the threads sidebar. - fn sync_multi_workspace(&mut self, window: &mut Window, cx: &mut Context) { - let current = window - .root::() - .flatten() - .map(|mw| mw.entity_id()); - - let tracked = self - .multi_workspace - .as_ref() - .and_then(|weak| weak.upgrade()) - .map(|mw| mw.entity_id()); - - if current == tracked { - return; - } - - let Some(multi_workspace) = window.root::().flatten() else { - self.multi_workspace = None; - return; - }; - - let is_open = multi_workspace.read(cx).sidebar_open(); - self.platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - }); - - let platform_titlebar = self.platform_titlebar.clone(); - let subscription = cx.observe(&multi_workspace, move |_this, mw, cx| { - let is_open = mw.read(cx).sidebar_open(); - platform_titlebar.update(cx, |titlebar, cx| { - titlebar.set_workspace_sidebar_open(is_open, cx); - }); - }); - - self.multi_workspace = Some(multi_workspace.downgrade()); - self._subscriptions.push(subscription); - } - fn worktree_count(&self, cx: &App) -> usize { self.project.read(cx).visible_worktrees(cx).count() } @@ -777,7 +717,13 @@ impl TitleBar { "Open Recent Project".to_string() }; - let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); + let is_sidebar_open = self + .multi_workspace + .as_ref() + .and_then(|mw| mw.upgrade()) + .map(|mw| mw.read(cx).sidebar_open()) + .unwrap_or(false) + && PlatformTitleBar::is_multi_workspace_enabled(cx); let is_threads_list_view_active = self .multi_workspace diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index d5e9400353eee50b3c5734a31684abdb0149caa0..fd160fd3024564d7451be0c29958cbb4a33eee38 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -27,6 +27,7 @@ test-support = [ [dependencies] any_vec.workspace = true +agent_settings.workspace = true anyhow.workspace = true async-recursion.workspace = true client.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 16d31ca699bfc984ceaa49c6bda2e38d66da13f7..131c02e9c885b66ddf32ed6d2a0dfb01d2764a49 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -69,6 +69,9 @@ pub trait Panel: Focusable + EventEmitter + Render + Sized { fn enabled(&self, _cx: &App) -> bool { true } + fn is_agent_panel(&self) -> bool { + false + } } pub trait PanelHandle: Send + Sync { @@ -95,6 +98,7 @@ pub trait PanelHandle: Send + Sync { fn to_any(&self) -> AnyView; fn activation_priority(&self, cx: &App) -> u32; fn enabled(&self, cx: &App) -> bool; + fn is_agent_panel(&self, cx: &App) -> bool; fn move_to_next_position(&self, window: &mut Window, cx: &mut App) { let current_position = self.position(window, cx); let next_position = [ @@ -207,6 +211,10 @@ where fn enabled(&self, cx: &App) -> bool { self.read(cx).enabled(cx) } + + fn is_agent_panel(&self, cx: &App) -> bool { + self.read(cx).is_agent_panel() + } } impl From<&dyn PanelHandle> for AnyView { @@ -720,6 +728,12 @@ impl Dock { self.panel_entries.len() } + pub fn has_agent_panel(&self, cx: &App) -> bool { + self.panel_entries + .iter() + .any(|entry| entry.panel.is_agent_panel(cx)) + } + pub fn activate_panel(&mut self, panel_ix: usize, window: &mut Window, cx: &mut Context) { if Some(panel_ix) != self.active_panel_index { if let Some(active_panel) = self.active_panel_entry() { diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 652331bc724a18b4f8cef20ba6ca41037af929eb..9e043e9ae7feb9f4ece21945d48d818f7345a03d 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -9,6 +9,7 @@ use project::DisableAiSettings; #[cfg(any(test, feature = "test-support"))] use project::Project; use settings::Settings; +pub use settings::SidebarSide; use std::future::Future; use std::path::PathBuf; use std::sync::Arc; @@ -16,6 +17,10 @@ use ui::prelude::*; use util::ResultExt; use zed_actions::agents_sidebar::MoveWorkspaceToNewWindow; +use agent_settings::AgentSettings; +use settings::SidebarDockPosition; +use ui::{ContextMenu, right_click_menu}; + const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); use crate::{ @@ -39,6 +44,47 @@ actions!( ] ); +#[derive(Default)] +pub struct SidebarRenderState { + pub open: bool, + pub side: SidebarSide, +} + +pub fn sidebar_side_context_menu( + id: impl Into, + cx: &App, +) -> ui::RightClickMenu { + let current_position = AgentSettings::get_global(cx).sidebar_side; + right_click_menu(id).menu(move |window, cx| { + let fs = ::global(cx); + ContextMenu::build(window, cx, move |mut menu, _, _cx| { + let positions: [(SidebarDockPosition, &str); 3] = [ + (SidebarDockPosition::Left, "Left"), + (SidebarDockPosition::Right, "Right"), + (SidebarDockPosition::FollowAgent, "Follow Agent Panel"), + ]; + for (position, label) in positions { + let fs = fs.clone(); + menu = menu.toggleable_entry( + label, + position == current_position, + IconPosition::Start, + None, + move |_window, cx| { + settings::update_settings_file(fs.clone(), cx, move |settings, _cx| { + settings + .agent + .get_or_insert_default() + .set_sidebar_side(position); + }); + }, + ); + } + menu + }) + }) +} + pub enum MultiWorkspaceEvent { ActiveWorkspaceChanged, WorkspaceAdded(Entity), @@ -49,6 +95,7 @@ pub trait Sidebar: Focusable + Render + Sized { fn width(&self, cx: &App) -> Pixels; fn set_width(&mut self, width: Option, cx: &mut Context); fn has_notifications(&self, cx: &App) -> bool; + fn side(&self, _cx: &App) -> SidebarSide; fn is_threads_list_view_active(&self) -> bool { true @@ -68,6 +115,8 @@ pub trait SidebarHandle: 'static + Send + Sync { fn entity_id(&self) -> EntityId; fn is_threads_list_view_active(&self, cx: &App) -> bool; + + fn side(&self, cx: &App) -> SidebarSide; } #[derive(Clone)] @@ -116,6 +165,10 @@ impl SidebarHandle for Entity { fn is_threads_list_view_active(&self, cx: &App) -> bool { self.read(cx).is_threads_list_view_active() } + + fn side(&self, cx: &App) -> SidebarSide { + self.read(cx).side(cx) + } } pub struct MultiWorkspace { @@ -132,6 +185,19 @@ pub struct MultiWorkspace { impl EventEmitter for MultiWorkspace {} impl MultiWorkspace { + pub fn sidebar_side(&self, cx: &App) -> SidebarSide { + self.sidebar + .as_ref() + .map_or(SidebarSide::Left, |s| s.side(cx)) + } + + pub fn sidebar_render_state(&self, cx: &App) -> SidebarRenderState { + SidebarRenderState { + open: self.sidebar_open() && self.multi_workspace_enabled(cx), + side: self.sidebar_side(cx), + } + } + pub fn new(workspace: Entity, window: &mut Window, cx: &mut Context) -> Self { let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| { if let Some(task) = this._serialize_task.take() { @@ -149,6 +215,10 @@ impl MultiWorkspace { } }); Self::subscribe_to_workspace(&workspace, cx); + let weak_self = cx.weak_entity(); + workspace.update(cx, |workspace, cx| { + workspace.set_multi_workspace(weak_self, cx); + }); Self { window_id: window.window_handle().window_id(), workspaces: vec![workspace], @@ -167,20 +237,8 @@ impl MultiWorkspace { pub fn register_sidebar(&mut self, sidebar: Entity, cx: &mut Context) { self._subscriptions - .push(cx.observe(&sidebar, |this, _, cx| { - let has_notifications = this.sidebar_has_notifications(cx); - let is_open = this.sidebar_open; - let show_toggle = this.multi_workspace_enabled(cx); - for workspace in &this.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open( - is_open, - has_notifications, - show_toggle, - cx, - ); - }); - } + .push(cx.observe(&sidebar, |_this, _, cx| { + cx.notify(); })); self.sidebar = Some(Box::new(sidebar)); } @@ -266,11 +324,8 @@ impl MultiWorkspace { pub fn open_sidebar(&mut self, cx: &mut Context) { self.sidebar_open = true; let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx)); - let has_notifications = self.sidebar_has_notifications(cx); - let show_toggle = self.multi_workspace_enabled(cx); for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, has_notifications, show_toggle, cx); + workspace.update(cx, |workspace, _cx| { workspace.set_sidebar_focus_handle(sidebar_focus_handle.clone()); }); } @@ -280,11 +335,8 @@ impl MultiWorkspace { pub fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { self.sidebar_open = false; - let has_notifications = self.sidebar_has_notifications(cx); - let show_toggle = self.multi_workspace_enabled(cx); for workspace in &self.workspaces { - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(false, has_notifications, show_toggle, cx); + workspace.update(cx, |workspace, _cx| { workspace.set_sidebar_focus_handle(None); }); } @@ -381,13 +433,14 @@ impl MultiWorkspace { } else { if self.sidebar_open { let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx)); - let has_notifications = self.sidebar_has_notifications(cx); - let show_toggle = self.multi_workspace_enabled(cx); - workspace.update(cx, |workspace, cx| { - workspace.set_workspace_sidebar_open(true, has_notifications, show_toggle, cx); + workspace.update(cx, |workspace, _cx| { workspace.set_sidebar_focus_handle(sidebar_focus_handle); }); } + let weak_self = cx.weak_entity(); + workspace.update(cx, |workspace, cx| { + workspace.set_multi_workspace(weak_self, cx); + }); Self::subscribe_to_workspace(&workspace, cx); self.workspaces.push(workspace.clone()); cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace)); @@ -767,6 +820,8 @@ impl MultiWorkspace { impl Render for MultiWorkspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let multi_workspace_enabled = self.multi_workspace_enabled(cx); + let sidebar_side = self.sidebar_side(cx); + let sidebar_on_right = sidebar_side == SidebarSide::Right; let sidebar: Option = if multi_workspace_enabled && self.sidebar_open() { self.sidebar.as_ref().map(|sidebar_handle| { @@ -777,7 +832,12 @@ impl Render for MultiWorkspace { div() .id("sidebar-resize-handle") .absolute() - .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + .when(!sidebar_on_right, |el| { + el.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) + .when(sidebar_on_right, |el| { + el.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + }) .top(px(0.)) .h_full() .w(SIDEBAR_RESIZE_HANDLE_SIZE) @@ -817,6 +877,12 @@ impl Render for MultiWorkspace { None }; + let (left_sidebar, right_sidebar) = if sidebar_on_right { + (None, sidebar) + } else { + (sidebar, None) + }; + let ui_font = theme::setup_ui_font(window, cx); let text_color = cx.theme().colors().text; @@ -855,16 +921,23 @@ impl Render for MultiWorkspace { self.sidebar_open() && self.multi_workspace_enabled(cx), |this| { this.on_drag_move(cx.listener( - |this: &mut Self, e: &DragMoveEvent, _window, cx| { + move |this: &mut Self, + e: &DragMoveEvent, + window, + cx| { if let Some(sidebar) = &this.sidebar { - let new_width = e.event.position.x; + let new_width = if sidebar_on_right { + window.bounds().size.width - e.event.position.x + } else { + e.event.position.x + }; sidebar.set_width(Some(new_width), cx); } }, )) - .children(sidebar) }, ) + .children(left_sidebar) .child( div() .flex() @@ -873,11 +946,13 @@ impl Render for MultiWorkspace { .overflow_hidden() .child(self.workspace().clone()), ) + .children(right_sidebar) .child(self.workspace().read(cx).modal_layer.clone()), window, cx, Tiling { - left: multi_workspace_enabled && self.sidebar_open(), + left: !sidebar_on_right && multi_workspace_enabled && self.sidebar_open(), + right: sidebar_on_right && multi_workspace_enabled && self.sidebar_open(), ..Tiling::default() }, ) diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 304c6417baab6c6a9b4b6e26e8f685992c1f80db..dad5389f2f5574c773af740fd61c6c1501c2fea0 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -1,7 +1,10 @@ -use crate::{ItemHandle, MultiWorkspace, Pane, ToggleWorkspaceSidebar}; +use crate::{ + ItemHandle, MultiWorkspace, Pane, SidebarSide, ToggleWorkspaceSidebar, + sidebar_side_context_menu, +}; use gpui::{ - AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render, Styled, - Subscription, Window, + AnyView, App, Context, Corner, Decorations, Entity, IntoElement, ParentElement, Render, Styled, + Subscription, WeakEntity, Window, }; use std::any::TypeId; use theme::CLIENT_SIDE_DECORATION_ROUNDING; @@ -29,18 +32,45 @@ trait StatusItemViewHandle: Send { fn item_type(&self) -> TypeId; } +#[derive(Default)] +struct SidebarStatus { + open: bool, + side: SidebarSide, + has_notifications: bool, + show_toggle: bool, +} + +impl SidebarStatus { + fn query(multi_workspace: &Option>, cx: &App) -> Self { + multi_workspace + .as_ref() + .and_then(|mw| mw.upgrade()) + .map(|mw| { + let mw = mw.read(cx); + let enabled = mw.multi_workspace_enabled(cx); + Self { + open: mw.sidebar_open() && enabled, + side: mw.sidebar_side(cx), + has_notifications: mw.sidebar_has_notifications(cx), + show_toggle: enabled, + } + }) + .unwrap_or_default() + } +} + pub struct StatusBar { left_items: Vec>, right_items: Vec>, active_pane: Entity, + multi_workspace: Option>, _observe_active_pane: Subscription, - workspace_sidebar_open: bool, - sidebar_has_notifications: bool, - show_sidebar_toggle: bool, } impl Render for StatusBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let sidebar = SidebarStatus::query(&self.multi_workspace, cx); + h_flex() .w_full() .justify_between() @@ -50,11 +80,14 @@ impl Render for StatusBar { .map(|el| match window.window_decorations() { Decorations::Server => el, Decorations::Client { tiling, .. } => el - .when(!(tiling.bottom || tiling.right), |el| { - el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) - }) .when( - !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open, + !(tiling.bottom || tiling.right) + && !(sidebar.open && sidebar.side == SidebarSide::Right), + |el| el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.bottom || tiling.left) + && !(sidebar.open && sidebar.side == SidebarSide::Left), |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING), ) // This border is to avoid a transparent gap in the rounded corners @@ -62,44 +95,77 @@ impl Render for StatusBar { .border_b(px(1.0)) .border_color(cx.theme().colors().status_bar_background), }) - .child(self.render_left_tools(cx)) - .child(self.render_right_tools()) + .child(self.render_left_tools(&sidebar, cx)) + .child(self.render_right_tools(&sidebar, cx)) } } impl StatusBar { - fn render_left_tools(&self, cx: &mut Context) -> impl IntoElement { + fn render_left_tools( + &self, + sidebar: &SidebarStatus, + cx: &mut Context, + ) -> impl IntoElement { h_flex() .gap_1() .min_w_0() .overflow_x_hidden() .when( - self.show_sidebar_toggle && !self.workspace_sidebar_open, - |this| this.child(self.render_sidebar_toggle(cx)), + sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Left, + |this| this.child(self.render_sidebar_toggle(sidebar, cx)), ) .children(self.left_items.iter().map(|item| item.to_any())) } - fn render_right_tools(&self) -> impl IntoElement { + fn render_right_tools( + &self, + sidebar: &SidebarStatus, + cx: &mut Context, + ) -> impl IntoElement { h_flex() .flex_shrink_0() .gap_1() .overflow_x_hidden() .children(self.right_items.iter().rev().map(|item| item.to_any())) + .when( + sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Right, + |this| this.child(self.render_sidebar_toggle(sidebar, cx)), + ) } - fn render_sidebar_toggle(&self, cx: &mut Context) -> impl IntoElement { - h_flex() - .gap_0p5() - .child( + fn render_sidebar_toggle( + &self, + sidebar: &SidebarStatus, + cx: &mut Context, + ) -> impl IntoElement { + let on_right = sidebar.side == SidebarSide::Right; + let has_notifications = sidebar.has_notifications; + let indicator_border = cx.theme().colors().status_bar_background; + + let toggle = sidebar_side_context_menu("sidebar-status-toggle-menu", cx) + .anchor(if on_right { + Corner::BottomRight + } else { + Corner::BottomLeft + }) + .attach(if on_right { + Corner::TopRight + } else { + Corner::TopLeft + }) + .trigger(move |_is_active, _window, _cx| { IconButton::new( "toggle-workspace-sidebar", - IconName::ThreadsSidebarLeftClosed, + if on_right { + IconName::ThreadsSidebarRightClosed + } else { + IconName::ThreadsSidebarLeftClosed + }, ) .icon_size(IconSize::Small) - .when(self.sidebar_has_notifications, |this| { + .when(has_notifications, |this| { this.indicator(Indicator::dot().color(Color::Accent)) - .indicator_border_color(Some(cx.theme().colors().status_bar_background)) + .indicator_border_color(Some(indicator_border)) }) .tooltip(move |_, cx| { Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) @@ -110,41 +176,47 @@ impl StatusBar { multi_workspace.toggle_sidebar(window, cx); }); } - }), - ) - .child(Divider::vertical().color(ui::DividerColor::Border)) + }) + }); + + h_flex() + .gap_0p5() + .when(on_right, |this| { + this.child(Divider::vertical().color(ui::DividerColor::Border)) + }) + .child(toggle) + .when(!on_right, |this| { + this.child(Divider::vertical().color(ui::DividerColor::Border)) + }) } } impl StatusBar { - pub fn new(active_pane: &Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + active_pane: &Entity, + multi_workspace: Option>, + window: &mut Window, + cx: &mut Context, + ) -> Self { let mut this = Self { left_items: Default::default(), right_items: Default::default(), active_pane: active_pane.clone(), + multi_workspace, _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), - workspace_sidebar_open: false, - sidebar_has_notifications: false, - show_sidebar_toggle: false, }; this.update_active_pane_item(window, cx); this } - pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { - self.workspace_sidebar_open = open; - cx.notify(); - } - - pub fn set_sidebar_has_notifications(&mut self, has: bool, cx: &mut Context) { - self.sidebar_has_notifications = has; - cx.notify(); - } - - pub fn set_show_sidebar_toggle(&mut self, show: bool, cx: &mut Context) { - self.show_sidebar_toggle = show; + pub fn set_multi_workspace( + &mut self, + multi_workspace: WeakEntity, + cx: &mut Context, + ) { + self.multi_workspace = Some(multi_workspace); cx.notify(); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d5cb3bf5e63ff333cd050f75536bf8de18ff372e..37cac09863b2251a7c8dc259d3fb1fc68c00c07e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -29,7 +29,7 @@ pub use dock::Panel; pub use multi_workspace::{ CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextWorkspace, PreviousWorkspace, Sidebar, SidebarHandle, - ToggleWorkspaceSidebar, + SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu, }; pub use path_list::{PathList, SerializedPathList}; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -1342,6 +1342,7 @@ pub struct Workspace { removing: bool, _panels_task: Option>>, sidebar_focus_handle: Option, + multi_workspace: Option>, } impl EventEmitter for Workspace {} @@ -1626,8 +1627,13 @@ impl Workspace { let left_dock_buttons = cx.new(|cx| PanelButtons::new(left_dock.clone(), cx)); let bottom_dock_buttons = cx.new(|cx| PanelButtons::new(bottom_dock.clone(), cx)); let right_dock_buttons = cx.new(|cx| PanelButtons::new(right_dock.clone(), cx)); + let multi_workspace = window + .root::() + .flatten() + .map(|mw| mw.downgrade()); let status_bar = cx.new(|cx| { - let mut status_bar = StatusBar::new(¢er_pane.clone(), window, cx); + let mut status_bar = + StatusBar::new(¢er_pane.clone(), multi_workspace.clone(), window, cx); status_bar.add_left_item(left_dock_buttons, window, cx); status_bar.add_right_item(right_dock_buttons, window, cx); status_bar.add_right_item(bottom_dock_buttons, window, cx); @@ -1754,6 +1760,7 @@ impl Workspace { last_open_dock_positions: Vec::new(), removing: false, sidebar_focus_handle: None, + multi_workspace, } } @@ -2127,6 +2134,13 @@ impl Workspace { } } + pub fn agent_panel_position(&self, cx: &App) -> Option { + self.all_docks().into_iter().find_map(|dock| { + let dock = dock.read(cx); + dock.has_agent_panel(cx).then_some(dock.position()) + }) + } + pub fn panel_size_state(&self, cx: &App) -> Option { self.all_docks().into_iter().find_map(|dock| { let dock = dock.read(cx); @@ -2327,20 +2341,6 @@ impl Workspace { &self.status_bar } - pub fn set_workspace_sidebar_open( - &self, - open: bool, - has_notifications: bool, - show_toggle: bool, - cx: &mut App, - ) { - self.status_bar.update(cx, |status_bar, cx| { - status_bar.set_workspace_sidebar_open(open, cx); - status_bar.set_sidebar_has_notifications(has_notifications, cx); - status_bar.set_show_sidebar_toggle(show_toggle, cx); - }); - } - pub fn set_sidebar_focus_handle(&mut self, handle: Option) { self.sidebar_focus_handle = handle; } @@ -2349,6 +2349,21 @@ impl Workspace { StatusBarSettings::get_global(cx).show } + pub fn multi_workspace(&self) -> Option<&WeakEntity> { + self.multi_workspace.as_ref() + } + + pub fn set_multi_workspace( + &mut self, + multi_workspace: WeakEntity, + cx: &mut App, + ) { + self.status_bar.update(cx, |status_bar, cx| { + status_bar.set_multi_workspace(multi_workspace.clone(), cx); + }); + self.multi_workspace = Some(multi_workspace); + } + pub fn app_state(&self) -> &Arc { &self.app_state } From 9efe3c5a215b0d5164f0426b670f6577d9f9839e Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Thu, 26 Mar 2026 12:27:39 +0530 Subject: [PATCH 24/45] markdown_preview: Refactor to use shared markdown crate (#52008) We now use the parser and renderer from the `markdown` crate for Markdown Preview, instead of maintaining two separate code paths. How it works: `markdown_preview_view.rs` is now a consumer of `MarkdownElement`. It acts as a thin wrapper, handling things like resolving URL clicks and image URLs, which can vary between consumers. It also handles syncing the editor selection with the active block in the preview. The APIs for this are provided by `MarkdownElement`. All the heavy lifting like parsing HTML, rendering block markers on hover, handling the active block, etc. is done by `MarkdownElement`. Everything is opt-in. For example, markdown in the Agent Panel can choose not to enable block marker rendering or HTML parsing, while Markdown Preview opts into those features. Final outcome: For Markdown Preview View: - Added: - Selection support in the preview - Stays: - Syncing between editor and preview - Autoscroll - Hover and active block markers - Checkbox toggling - Image rendering - Mermaid rendering For the `markdown` crate: - No changes for existing consumers like the Agent Panel - Consumers can now opt into: - HTML rendering - Block marker rendering - Click event handling - Custom image resolvers - Mermaid rendering Release Notes: - N/A --- Cargo.lock | 15 +- crates/markdown/Cargo.toml | 5 + crates/markdown/src/html.rs | 3 + .../src/html/html_minifier.rs} | 0 crates/markdown/src/html/html_parser.rs | 883 +++++ crates/markdown/src/html/html_rendering.rs | 613 +++ crates/markdown/src/markdown.rs | 663 +++- crates/markdown/src/mermaid.rs | 614 +++ crates/markdown/src/parser.rs | 370 +- crates/markdown_preview/Cargo.toml | 12 +- .../markdown_preview/src/markdown_elements.rs | 374 -- .../markdown_preview/src/markdown_parser.rs | 3320 ----------------- .../markdown_preview/src/markdown_preview.rs | 4 - .../src/markdown_preview_view.rs | 625 ++-- .../markdown_preview/src/markdown_renderer.rs | 1517 -------- 15 files changed, 3332 insertions(+), 5686 deletions(-) create mode 100644 crates/markdown/src/html.rs rename crates/{markdown_preview/src/markdown_minifier.rs => markdown/src/html/html_minifier.rs} (100%) create mode 100644 crates/markdown/src/html/html_parser.rs create mode 100644 crates/markdown/src/html/html_rendering.rs create mode 100644 crates/markdown/src/mermaid.rs delete mode 100644 crates/markdown_preview/src/markdown_elements.rs delete mode 100644 crates/markdown_preview/src/markdown_parser.rs delete mode 100644 crates/markdown_preview/src/markdown_renderer.rs diff --git a/Cargo.lock b/Cargo.lock index 16f8dd76ab23bf274b1e6b79515fa8060f2a646f..33645135abda30a991f7645338fa84bd1618d574 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10265,6 +10265,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" name = "markdown" version = "0.1.0" dependencies = [ + "anyhow", "assets", "base64 0.22.1", "collections", @@ -10273,13 +10274,17 @@ dependencies = [ "futures 0.3.31", "gpui", "gpui_platform", + "html5ever 0.27.0", "language", "languages", "linkify", "log", + "markup5ever_rcdom", + "mermaid-rs-renderer", "node_runtime", "pulldown-cmark 0.13.0", "settings", + "stacksafe", "sum_tree", "theme", "ui", @@ -10291,21 +10296,13 @@ name = "markdown_preview" version = "0.1.0" dependencies = [ "anyhow", - "async-recursion", - "collections", "editor", "gpui", - "html5ever 0.27.0", "language", - "linkify", "log", "markdown", - "markup5ever_rcdom", - "mermaid-rs-renderer", - "pretty_assertions", - "pulldown-cmark 0.13.0", "settings", - "stacksafe", + "tempfile", "theme", "ui", "urlencoding", diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index c923d3f704488a5364707486d25181188f74f166..18bba1fc64f193cf17be1a728fc533a6596296b1 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -19,15 +19,20 @@ test-support = [ ] [dependencies] +anyhow.workspace = true base64.workspace = true collections.workspace = true futures.workspace = true gpui.workspace = true +html5ever.workspace = true language.workspace = true linkify.workspace = true log.workspace = true +markup5ever_rcdom.workspace = true +mermaid-rs-renderer.workspace = true pulldown-cmark.workspace = true settings.workspace = true +stacksafe.workspace = true sum_tree.workspace = true theme.workspace = true ui.workspace = true diff --git a/crates/markdown/src/html.rs b/crates/markdown/src/html.rs new file mode 100644 index 0000000000000000000000000000000000000000..cf37f6138cd49733b5ca6f093ced9a00481f4edb --- /dev/null +++ b/crates/markdown/src/html.rs @@ -0,0 +1,3 @@ +mod html_minifier; +pub(crate) mod html_parser; +mod html_rendering; diff --git a/crates/markdown_preview/src/markdown_minifier.rs b/crates/markdown/src/html/html_minifier.rs similarity index 100% rename from crates/markdown_preview/src/markdown_minifier.rs rename to crates/markdown/src/html/html_minifier.rs diff --git a/crates/markdown/src/html/html_parser.rs b/crates/markdown/src/html/html_parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..20338ec2abef2314b7cd6ca91e45ee05be909745 --- /dev/null +++ b/crates/markdown/src/html/html_parser.rs @@ -0,0 +1,883 @@ +use std::{cell::RefCell, collections::HashMap, mem, ops::Range}; + +use gpui::{DefiniteLength, FontWeight, SharedString, px, relative}; +use html5ever::{ + Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink, +}; +use markup5ever_rcdom::{Node, NodeData, RcDom}; +use pulldown_cmark::{Alignment, HeadingLevel}; +use stacksafe::stacksafe; + +use crate::html::html_minifier::{Minifier, MinifierOptions}; + +#[derive(Debug, Clone, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlBlock { + pub source_range: Range, + pub children: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum ParsedHtmlElement { + Heading(ParsedHtmlHeading), + List(ParsedHtmlList), + Table(ParsedHtmlTable), + BlockQuote(ParsedHtmlBlockQuote), + Paragraph(HtmlParagraph), + Image(HtmlImage), +} + +impl ParsedHtmlElement { + pub fn source_range(&self) -> Option> { + Some(match self { + Self::Heading(heading) => heading.source_range.clone(), + Self::List(list) => list.source_range.clone(), + Self::Table(table) => table.source_range.clone(), + Self::BlockQuote(block_quote) => block_quote.source_range.clone(), + Self::Paragraph(text) => match text.first()? { + HtmlParagraphChunk::Text(text) => text.source_range.clone(), + HtmlParagraphChunk::Image(image) => image.source_range.clone(), + }, + Self::Image(image) => image.source_range.clone(), + }) + } +} + +pub(crate) type HtmlParagraph = Vec; + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum HtmlParagraphChunk { + Text(ParsedHtmlText), + Image(HtmlImage), +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlList { + pub source_range: Range, + pub depth: u16, + pub ordered: bool, + pub items: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlListItem { + pub source_range: Range, + pub item_type: ParsedHtmlListItemType, + pub content: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) enum ParsedHtmlListItemType { + Ordered(u64), + Unordered, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlHeading { + pub source_range: Range, + pub level: HeadingLevel, + pub contents: HtmlParagraph, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTable { + pub source_range: Range, + pub header: Vec, + pub body: Vec, + pub caption: Option, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTableColumn { + pub col_span: usize, + pub row_span: usize, + pub is_header: bool, + pub children: HtmlParagraph, + pub alignment: Alignment, +} + +#[derive(Debug, Clone, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlTableRow { + pub columns: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlBlockQuote { + pub source_range: Range, + pub children: Vec, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedHtmlText { + pub source_range: Range, + pub contents: SharedString, + pub highlights: Vec<(Range, HtmlHighlightStyle)>, + pub links: Vec<(Range, SharedString)>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct HtmlHighlightStyle { + pub italic: bool, + pub underline: bool, + pub strikethrough: bool, + pub weight: FontWeight, + pub link: bool, + pub oblique: bool, +} + +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct HtmlImage { + pub dest_url: SharedString, + pub source_range: Range, + pub alt_text: Option, + pub width: Option, + pub height: Option, +} + +impl HtmlImage { + fn new(dest_url: String, source_range: Range) -> Self { + Self { + dest_url: dest_url.into(), + source_range, + alt_text: None, + width: None, + height: None, + } + } + + fn set_alt_text(&mut self, alt_text: SharedString) { + self.alt_text = Some(alt_text); + } + + fn set_width(&mut self, width: DefiniteLength) { + self.width = Some(width); + } + + fn set_height(&mut self, height: DefiniteLength) { + self.height = Some(height); + } +} + +#[derive(Debug)] +struct ParseHtmlNodeContext { + list_item_depth: u16, +} + +impl Default for ParseHtmlNodeContext { + fn default() -> Self { + Self { list_item_depth: 1 } + } +} + +pub(crate) fn parse_html_block( + source: &str, + source_range: Range, +) -> Option { + let bytes = cleanup_html(source); + let mut cursor = std::io::Cursor::new(bytes); + let dom = parse_document(RcDom::default(), ParseOpts::default()) + .from_utf8() + .read_from(&mut cursor) + .ok()?; + + let mut children = Vec::new(); + parse_html_node( + source_range.clone(), + &dom.document, + &mut children, + &ParseHtmlNodeContext::default(), + ); + + Some(ParsedHtmlBlock { + source_range, + children, + }) +} + +fn cleanup_html(source: &str) -> Vec { + let mut writer = std::io::Cursor::new(Vec::new()); + let mut reader = std::io::Cursor::new(source); + let mut minify = Minifier::new( + &mut writer, + MinifierOptions { + omit_doctype: true, + collapse_whitespace: true, + ..Default::default() + }, + ); + if let Ok(()) = minify.minify(&mut reader) { + writer.into_inner() + } else { + source.bytes().collect() + } +} + +#[stacksafe] +fn parse_html_node( + source_range: Range, + node: &Node, + elements: &mut Vec, + context: &ParseHtmlNodeContext, +) { + match &node.data { + NodeData::Document => { + consume_children(source_range, node, elements, context); + } + NodeData::Text { contents } => { + elements.push(ParsedHtmlElement::Paragraph(vec![ + HtmlParagraphChunk::Text(ParsedHtmlText { + source_range, + highlights: Vec::default(), + links: Vec::default(), + contents: contents.borrow().to_string().into(), + }), + ])); + } + NodeData::Comment { .. } => {} + NodeData::Element { name, attrs, .. } => { + let mut styles = if let Some(styles) = + html_style_from_html_styles(extract_styles_from_attributes(attrs)) + { + vec![styles] + } else { + Vec::default() + }; + + if name.local == local_name!("img") { + if let Some(image) = extract_image(source_range, attrs) { + elements.push(ParsedHtmlElement::Image(image)); + } + } else if name.local == local_name!("p") { + let mut paragraph = HtmlParagraph::new(); + parse_paragraph( + source_range, + node, + &mut paragraph, + &mut styles, + &mut Vec::new(), + ); + + if !paragraph.is_empty() { + elements.push(ParsedHtmlElement::Paragraph(paragraph)); + } + } else if matches!( + name.local, + local_name!("h1") + | local_name!("h2") + | local_name!("h3") + | local_name!("h4") + | local_name!("h5") + | local_name!("h6") + ) { + let mut paragraph = HtmlParagraph::new(); + consume_paragraph( + source_range.clone(), + node, + &mut paragraph, + &mut styles, + &mut Vec::new(), + ); + + if !paragraph.is_empty() { + elements.push(ParsedHtmlElement::Heading(ParsedHtmlHeading { + source_range, + level: match name.local { + local_name!("h1") => HeadingLevel::H1, + local_name!("h2") => HeadingLevel::H2, + local_name!("h3") => HeadingLevel::H3, + local_name!("h4") => HeadingLevel::H4, + local_name!("h5") => HeadingLevel::H5, + local_name!("h6") => HeadingLevel::H6, + _ => unreachable!(), + }, + contents: paragraph, + })); + } + } else if name.local == local_name!("ul") || name.local == local_name!("ol") { + if let Some(list) = extract_html_list( + node, + name.local == local_name!("ol"), + context.list_item_depth, + source_range, + ) { + elements.push(ParsedHtmlElement::List(list)); + } + } else if name.local == local_name!("blockquote") { + if let Some(blockquote) = extract_html_blockquote(node, source_range) { + elements.push(ParsedHtmlElement::BlockQuote(blockquote)); + } + } else if name.local == local_name!("table") { + if let Some(table) = extract_html_table(node, source_range) { + elements.push(ParsedHtmlElement::Table(table)); + } + } else { + consume_children(source_range, node, elements, context); + } + } + _ => {} + } +} + +#[stacksafe] +fn parse_paragraph( + source_range: Range, + node: &Node, + paragraph: &mut HtmlParagraph, + highlights: &mut Vec, + links: &mut Vec, +) { + fn items_with_range( + range: Range, + items: impl IntoIterator, + ) -> Vec<(Range, T)> { + items + .into_iter() + .map(|item| (range.clone(), item)) + .collect() + } + + match &node.data { + NodeData::Text { contents } => { + if let Some(text) = + paragraph + .iter_mut() + .last() + .and_then(|paragraph_chunk| match paragraph_chunk { + HtmlParagraphChunk::Text(text) => Some(text), + _ => None, + }) + { + let mut new_text = text.contents.to_string(); + new_text.push_str(&contents.borrow()); + + text.highlights.extend(items_with_range( + text.contents.len()..new_text.len(), + mem::take(highlights), + )); + text.links.extend(items_with_range( + text.contents.len()..new_text.len(), + mem::take(links), + )); + text.contents = SharedString::from(new_text); + } else { + let contents = contents.borrow().to_string(); + paragraph.push(HtmlParagraphChunk::Text(ParsedHtmlText { + source_range, + highlights: items_with_range(0..contents.len(), mem::take(highlights)), + links: items_with_range(0..contents.len(), mem::take(links)), + contents: contents.into(), + })); + } + } + NodeData::Element { name, attrs, .. } => { + if name.local == local_name!("img") { + if let Some(image) = extract_image(source_range, attrs) { + paragraph.push(HtmlParagraphChunk::Image(image)); + } + } else if name.local == local_name!("b") || name.local == local_name!("strong") { + highlights.push(HtmlHighlightStyle { + weight: FontWeight::BOLD, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("i") { + highlights.push(HtmlHighlightStyle { + italic: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("em") { + highlights.push(HtmlHighlightStyle { + oblique: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("del") { + highlights.push(HtmlHighlightStyle { + strikethrough: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("ins") { + highlights.push(HtmlHighlightStyle { + underline: true, + ..Default::default() + }); + consume_paragraph(source_range, node, paragraph, highlights, links); + } else if name.local == local_name!("a") { + if let Some(url) = attr_value(attrs, local_name!("href")) { + highlights.push(HtmlHighlightStyle { + link: true, + ..Default::default() + }); + links.push(url.into()); + } + consume_paragraph(source_range, node, paragraph, highlights, links); + } else { + consume_paragraph(source_range, node, paragraph, highlights, links); + } + } + _ => {} + } +} + +fn consume_paragraph( + source_range: Range, + node: &Node, + paragraph: &mut HtmlParagraph, + highlights: &mut Vec, + links: &mut Vec, +) { + for child in node.children.borrow().iter() { + parse_paragraph(source_range.clone(), child, paragraph, highlights, links); + } +} + +fn parse_table_row(source_range: Range, node: &Node) -> Option { + let mut columns = Vec::new(); + + if let NodeData::Element { name, .. } = &node.data { + if name.local != local_name!("tr") { + return None; + } + + for child in node.children.borrow().iter() { + if let Some(column) = parse_table_column(source_range.clone(), child) { + columns.push(column); + } + } + } + + if columns.is_empty() { + None + } else { + Some(ParsedHtmlTableRow { columns }) + } +} + +fn parse_table_column(source_range: Range, node: &Node) -> Option { + match &node.data { + NodeData::Element { name, attrs, .. } => { + if !matches!(name.local, local_name!("th") | local_name!("td")) { + return None; + } + + let mut children = HtmlParagraph::new(); + consume_paragraph( + source_range, + node, + &mut children, + &mut Vec::new(), + &mut Vec::new(), + ); + + let is_header = name.local == local_name!("th"); + + Some(ParsedHtmlTableColumn { + col_span: std::cmp::max( + attr_value(attrs, local_name!("colspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + row_span: std::cmp::max( + attr_value(attrs, local_name!("rowspan")) + .and_then(|span| span.parse().ok()) + .unwrap_or(1), + 1, + ), + is_header, + children, + alignment: attr_value(attrs, local_name!("align")) + .and_then(|align| match align.as_str() { + "left" => Some(Alignment::Left), + "center" => Some(Alignment::Center), + "right" => Some(Alignment::Right), + _ => None, + }) + .unwrap_or(if is_header { + Alignment::Center + } else { + Alignment::None + }), + }) + } + _ => None, + } +} + +fn consume_children( + source_range: Range, + node: &Node, + elements: &mut Vec, + context: &ParseHtmlNodeContext, +) { + for child in node.children.borrow().iter() { + parse_html_node(source_range.clone(), child, elements, context); + } +} + +fn attr_value(attrs: &RefCell>, name: LocalName) -> Option { + attrs.borrow().iter().find_map(|attr| { + if attr.name.local == name { + Some(attr.value.to_string()) + } else { + None + } + }) +} + +fn html_style_from_html_styles(styles: HashMap) -> Option { + let mut html_style = HtmlHighlightStyle::default(); + + if let Some(text_decoration) = styles.get("text-decoration") { + match text_decoration.to_lowercase().as_str() { + "underline" => { + html_style.underline = true; + } + "line-through" => { + html_style.strikethrough = true; + } + _ => {} + } + } + + if let Some(font_style) = styles.get("font-style") { + match font_style.to_lowercase().as_str() { + "italic" => { + html_style.italic = true; + } + "oblique" => { + html_style.oblique = true; + } + _ => {} + } + } + + if let Some(font_weight) = styles.get("font-weight") { + match font_weight.to_lowercase().as_str() { + "bold" => { + html_style.weight = FontWeight::BOLD; + } + "lighter" => { + html_style.weight = FontWeight::THIN; + } + _ => { + if let Ok(weight) = font_weight.parse::() { + html_style.weight = FontWeight(weight); + } + } + } + } + + if html_style != HtmlHighlightStyle::default() { + Some(html_style) + } else { + None + } +} + +fn extract_styles_from_attributes(attrs: &RefCell>) -> HashMap { + let mut styles = HashMap::new(); + + if let Some(style) = attr_value(attrs, local_name!("style")) { + for declaration in style.split(';') { + let mut parts = declaration.splitn(2, ':'); + if let Some((key, value)) = parts.next().zip(parts.next()) { + styles.insert(key.trim().to_lowercase(), value.trim().to_string()); + } + } + } + + styles +} + +fn extract_image(source_range: Range, attrs: &RefCell>) -> Option { + let src = attr_value(attrs, local_name!("src"))?; + + let mut image = HtmlImage::new(src, source_range); + + if let Some(alt) = attr_value(attrs, local_name!("alt")) { + image.set_alt_text(alt.into()); + } + + let styles = extract_styles_from_attributes(attrs); + + if let Some(width) = attr_value(attrs, local_name!("width")) + .or_else(|| styles.get("width").cloned()) + .and_then(|width| parse_html_element_dimension(&width)) + { + image.set_width(width); + } + + if let Some(height) = attr_value(attrs, local_name!("height")) + .or_else(|| styles.get("height").cloned()) + .and_then(|height| parse_html_element_dimension(&height)) + { + image.set_height(height); + } + + Some(image) +} + +fn extract_html_list( + node: &Node, + ordered: bool, + depth: u16, + source_range: Range, +) -> Option { + let mut items = Vec::with_capacity(node.children.borrow().len()); + + for (index, child) in node.children.borrow().iter().enumerate() { + if let NodeData::Element { name, .. } = &child.data { + if name.local != local_name!("li") { + continue; + } + + let mut content = Vec::new(); + consume_children( + source_range.clone(), + child, + &mut content, + &ParseHtmlNodeContext { + list_item_depth: depth + 1, + }, + ); + + if !content.is_empty() { + items.push(ParsedHtmlListItem { + source_range: source_range.clone(), + item_type: if ordered { + ParsedHtmlListItemType::Ordered(index as u64 + 1) + } else { + ParsedHtmlListItemType::Unordered + }, + content, + }); + } + } + } + + if items.is_empty() { + None + } else { + Some(ParsedHtmlList { + source_range, + depth, + ordered, + items, + }) + } +} + +fn parse_html_element_dimension(value: &str) -> Option { + if value.ends_with('%') { + value + .trim_end_matches('%') + .parse::() + .ok() + .map(|value| relative(value / 100.)) + } else { + value + .trim_end_matches("px") + .parse() + .ok() + .map(|value| px(value).into()) + } +} + +fn extract_html_blockquote( + node: &Node, + source_range: Range, +) -> Option { + let mut children = Vec::new(); + consume_children( + source_range.clone(), + node, + &mut children, + &ParseHtmlNodeContext::default(), + ); + + if children.is_empty() { + None + } else { + Some(ParsedHtmlBlockQuote { + children, + source_range, + }) + } +} + +fn extract_html_table(node: &Node, source_range: Range) -> Option { + let mut header_rows = Vec::new(); + let mut body_rows = Vec::new(); + let mut caption = None; + + for child in node.children.borrow().iter() { + if let NodeData::Element { name, .. } = &child.data { + if name.local == local_name!("caption") { + let mut paragraph = HtmlParagraph::new(); + parse_paragraph( + source_range.clone(), + child, + &mut paragraph, + &mut Vec::new(), + &mut Vec::new(), + ); + caption = Some(paragraph); + } + + if name.local == local_name!("thead") { + for row in child.children.borrow().iter() { + if let Some(row) = parse_table_row(source_range.clone(), row) { + header_rows.push(row); + } + } + } else if name.local == local_name!("tbody") { + for row in child.children.borrow().iter() { + if let Some(row) = parse_table_row(source_range.clone(), row) { + body_rows.push(row); + } + } + } + } + } + + if !header_rows.is_empty() || !body_rows.is_empty() { + Some(ParsedHtmlTable { + source_range, + body: body_rows, + header: header_rows, + caption, + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_html_styled_text() { + let parsed = parse_html_block( + "

Some text strong link

", + 0..79, + ) + .unwrap(); + + assert_eq!(parsed.children.len(), 1); + let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else { + panic!("expected paragraph"); + }; + let HtmlParagraphChunk::Text(text) = ¶graph[0] else { + panic!("expected text chunk"); + }; + + assert_eq!(text.contents.as_ref(), "Some text strong link"); + assert_eq!( + text.highlights, + vec![ + ( + 10..16, + HtmlHighlightStyle { + weight: FontWeight::BOLD, + ..Default::default() + } + ), + ( + 17..21, + HtmlHighlightStyle { + link: true, + ..Default::default() + } + ) + ] + ); + assert_eq!( + text.links, + vec![(17..21, SharedString::from("https://example.com"))] + ); + } + + #[test] + fn parses_html_table_spans() { + let parsed = parse_html_block( + "
a
bc
", + 0..91, + ) + .unwrap(); + + let ParsedHtmlElement::Table(table) = &parsed.children[0] else { + panic!("expected table"); + }; + assert_eq!(table.body.len(), 2); + assert_eq!(table.body[0].columns[0].col_span, 2); + assert_eq!(table.body[1].columns.len(), 2); + } + + #[test] + fn parses_html_list_as_explicit_list_node() { + let parsed = parse_html_block( + "
  • parent
    • child
  • sibling
", + 0..64, + ) + .unwrap(); + + assert_eq!(parsed.children.len(), 1); + + let ParsedHtmlElement::List(list) = &parsed.children[0] else { + panic!("expected list"); + }; + + assert!(!list.ordered); + assert_eq!(list.depth, 1); + assert_eq!(list.items.len(), 2); + + let first_item = &list.items[0]; + let ParsedHtmlElement::Paragraph(paragraph) = &first_item.content[0] else { + panic!("expected first item paragraph"); + }; + let HtmlParagraphChunk::Text(text) = ¶graph[0] else { + panic!("expected first item text"); + }; + assert_eq!(text.contents.as_ref(), "parent"); + + let ParsedHtmlElement::List(nested_list) = &first_item.content[1] else { + panic!("expected nested list"); + }; + assert_eq!(nested_list.depth, 2); + assert_eq!(nested_list.items.len(), 1); + + let ParsedHtmlElement::Paragraph(nested_paragraph) = &nested_list.items[0].content[0] + else { + panic!("expected nested item paragraph"); + }; + let HtmlParagraphChunk::Text(nested_text) = &nested_paragraph[0] else { + panic!("expected nested item text"); + }; + assert_eq!(nested_text.contents.as_ref(), "child"); + + let second_item = &list.items[1]; + let ParsedHtmlElement::Paragraph(second_paragraph) = &second_item.content[0] else { + panic!("expected second item paragraph"); + }; + let HtmlParagraphChunk::Text(second_text) = &second_paragraph[0] else { + panic!("expected second item text"); + }; + assert_eq!(second_text.contents.as_ref(), "sibling"); + } +} diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs new file mode 100644 index 0000000000000000000000000000000000000000..6b52a98908ed8757986d7ca7f8778b330f97254f --- /dev/null +++ b/crates/markdown/src/html/html_rendering.rs @@ -0,0 +1,613 @@ +use std::ops::Range; + +use gpui::{App, FontStyle, FontWeight, StrikethroughStyle, TextStyleRefinement, UnderlineStyle}; +use pulldown_cmark::Alignment; +use ui::prelude::*; + +use crate::html::html_parser::{ + HtmlHighlightStyle, HtmlImage, HtmlParagraph, HtmlParagraphChunk, ParsedHtmlBlock, + ParsedHtmlElement, ParsedHtmlList, ParsedHtmlListItemType, ParsedHtmlTable, ParsedHtmlTableRow, + ParsedHtmlText, +}; +use crate::{MarkdownElement, MarkdownElementBuilder}; + +pub(crate) struct HtmlSourceAllocator { + source_range: Range, + next_source_index: usize, +} + +impl HtmlSourceAllocator { + pub(crate) fn new(source_range: Range) -> Self { + Self { + next_source_index: source_range.start, + source_range, + } + } + + pub(crate) fn allocate(&mut self, requested_len: usize) -> Range { + let remaining = self.source_range.end.saturating_sub(self.next_source_index); + let len = requested_len.min(remaining); + let start = self.next_source_index; + let end = start + len; + self.next_source_index = end; + start..end + } +} + +impl MarkdownElement { + pub(crate) fn render_html_block( + &self, + block: &ParsedHtmlBlock, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + let mut source_allocator = HtmlSourceAllocator::new(block.source_range.clone()); + self.render_html_elements( + &block.children, + &mut source_allocator, + builder, + markdown_end, + cx, + ); + } + + fn render_html_elements( + &self, + elements: &[ParsedHtmlElement], + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + for element in elements { + self.render_html_element(element, source_allocator, builder, markdown_end, cx); + } + } + + fn render_html_element( + &self, + element: &ParsedHtmlElement, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + let Some(source_range) = element.source_range() else { + return; + }; + + match element { + ParsedHtmlElement::Paragraph(paragraph) => { + self.push_markdown_paragraph(builder, &source_range, markdown_end); + self.render_html_paragraph(paragraph, source_allocator, builder, cx, markdown_end); + builder.pop_div(); + } + ParsedHtmlElement::Heading(heading) => { + self.push_markdown_heading( + builder, + heading.level, + &heading.source_range, + markdown_end, + ); + self.render_html_paragraph( + &heading.contents, + source_allocator, + builder, + cx, + markdown_end, + ); + self.pop_markdown_heading(builder); + } + ParsedHtmlElement::List(list) => { + self.render_html_list(list, source_allocator, builder, markdown_end, cx); + } + ParsedHtmlElement::BlockQuote(block_quote) => { + self.push_markdown_block_quote(builder, &block_quote.source_range, markdown_end); + self.render_html_elements( + &block_quote.children, + source_allocator, + builder, + markdown_end, + cx, + ); + self.pop_markdown_block_quote(builder); + } + ParsedHtmlElement::Table(table) => { + self.render_html_table(table, source_allocator, builder, markdown_end, cx); + } + ParsedHtmlElement::Image(image) => { + self.render_html_image(image, builder); + } + } + } + + fn render_html_list( + &self, + list: &ParsedHtmlList, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + builder.push_div(div().pl_2p5(), &list.source_range, markdown_end); + + for list_item in &list.items { + let bullet = match list_item.item_type { + ParsedHtmlListItemType::Ordered(order) => html_list_item_prefix( + order as usize, + list.ordered, + list.depth.saturating_sub(1) as usize, + ), + ParsedHtmlListItemType::Unordered => { + html_list_item_prefix(1, false, list.depth.saturating_sub(1) as usize) + } + }; + + self.push_markdown_list_item( + builder, + div().child(bullet).into_any_element(), + &list_item.source_range, + markdown_end, + ); + self.render_html_elements( + &list_item.content, + source_allocator, + builder, + markdown_end, + cx, + ); + self.pop_markdown_list_item(builder); + } + + builder.pop_div(); + } + + fn render_html_table( + &self, + table: &ParsedHtmlTable, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + markdown_end: usize, + cx: &mut App, + ) { + if let Some(caption) = &table.caption { + builder.push_div( + div().when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_2().line_height(rems(1.3)) + }), + &table.source_range, + markdown_end, + ); + self.render_html_paragraph(caption, source_allocator, builder, cx, markdown_end); + builder.pop_div(); + } + + let actual_header_column_count = html_table_columns_count(&table.header); + let actual_body_column_count = html_table_columns_count(&table.body); + let max_column_count = actual_header_column_count.max(actual_body_column_count); + + if max_column_count == 0 { + return; + } + + let total_rows = table.header.len() + table.body.len(); + let mut grid_occupied = vec![vec![false; max_column_count]; total_rows]; + + builder.push_div( + div() + .id(("html-table", table.source_range.start)) + .grid() + .grid_cols(max_column_count as u16) + .when(self.style.table_columns_min_size, |this| { + this.grid_cols_min_content(max_column_count as u16) + }) + .when(!self.style.table_columns_min_size, |this| { + this.grid_cols(max_column_count as u16) + }) + .w_full() + .mb_2() + .border(px(1.5)) + .border_color(cx.theme().colors().border) + .rounded_sm() + .overflow_hidden(), + &table.source_range, + markdown_end, + ); + + for (row_index, row) in table.header.iter().chain(table.body.iter()).enumerate() { + let mut column_index = 0; + + for cell in &row.columns { + while column_index < max_column_count && grid_occupied[row_index][column_index] { + column_index += 1; + } + + if column_index >= max_column_count { + break; + } + + let max_span = max_column_count.saturating_sub(column_index); + let mut cell_div = div() + .col_span(cell.col_span.min(max_span) as u16) + .row_span(cell.row_span.min(total_rows - row_index) as u16) + .when(column_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .px_2() + .py_1() + .when(cell.is_header, |this| { + this.bg(cx.theme().colors().title_bar_background) + }) + .when(!cell.is_header && row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }); + + cell_div = match cell.alignment { + Alignment::Center => cell_div.items_center(), + Alignment::Right => cell_div.items_end(), + _ => cell_div, + }; + + builder.push_div(cell_div, &table.source_range, markdown_end); + self.render_html_paragraph( + &cell.children, + source_allocator, + builder, + cx, + markdown_end, + ); + builder.pop_div(); + + for row_offset in 0..cell.row_span { + for column_offset in 0..cell.col_span { + if row_index + row_offset < total_rows + && column_index + column_offset < max_column_count + { + grid_occupied[row_index + row_offset][column_index + column_offset] = + true; + } + } + } + + column_index += cell.col_span; + } + + while column_index < max_column_count { + if grid_occupied[row_index][column_index] { + column_index += 1; + continue; + } + + builder.push_div( + div() + .when(column_index > 0, |this| this.border_l_1()) + .when(row_index > 0, |this| this.border_t_1()) + .border_color(cx.theme().colors().border) + .when(row_index % 2 == 1, |this| { + this.bg(cx.theme().colors().panel_background) + }), + &table.source_range, + markdown_end, + ); + builder.pop_div(); + column_index += 1; + } + } + + builder.pop_div(); + } + + fn render_html_paragraph( + &self, + paragraph: &HtmlParagraph, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + cx: &mut App, + _markdown_end: usize, + ) { + for chunk in paragraph { + match chunk { + HtmlParagraphChunk::Text(text) => { + self.render_html_text(text, source_allocator, builder, cx); + } + HtmlParagraphChunk::Image(image) => { + self.render_html_image(image, builder); + } + } + } + } + + fn render_html_text( + &self, + text: &ParsedHtmlText, + source_allocator: &mut HtmlSourceAllocator, + builder: &mut MarkdownElementBuilder, + cx: &mut App, + ) { + let text_contents = text.contents.as_ref(); + if text_contents.is_empty() { + return; + } + + let allocated_range = source_allocator.allocate(text_contents.len()); + let allocated_len = allocated_range.end.saturating_sub(allocated_range.start); + + let mut boundaries = vec![0, text_contents.len()]; + for (range, _) in &text.highlights { + boundaries.push(range.start); + boundaries.push(range.end); + } + for (range, _) in &text.links { + boundaries.push(range.start); + boundaries.push(range.end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + for segment in boundaries.windows(2) { + let start = segment[0]; + let end = segment[1]; + if start >= end { + continue; + } + + let source_start = allocated_range.start + start.min(allocated_len); + let source_end = allocated_range.start + end.min(allocated_len); + if source_start >= source_end { + continue; + } + + let mut refinement = TextStyleRefinement::default(); + let mut has_refinement = false; + + for (highlight_range, style) in &text.highlights { + if highlight_range.start < end && highlight_range.end > start { + apply_html_highlight_style(&mut refinement, style); + has_refinement = true; + } + } + + let link = text.links.iter().find_map(|(link_range, link)| { + if link_range.start < end && link_range.end > start { + Some(link.clone()) + } else { + None + } + }); + + if let Some(link) = link.as_ref() { + builder.push_link(link.clone(), source_start..source_end); + let link_style = self + .style + .link_callback + .as_ref() + .and_then(|callback| callback(link.as_ref(), cx)) + .unwrap_or_else(|| self.style.link.clone()); + builder.push_text_style(link_style); + } + + if has_refinement { + builder.push_text_style(refinement); + } + + builder.push_text(&text_contents[start..end], source_start..source_end); + + if has_refinement { + builder.pop_text_style(); + } + + if link.is_some() { + builder.pop_text_style(); + } + } + } + + fn render_html_image(&self, image: &HtmlImage, builder: &mut MarkdownElementBuilder) { + let Some(source) = self + .image_resolver + .as_ref() + .and_then(|resolve| resolve(image.dest_url.as_ref())) + else { + return; + }; + + self.push_markdown_image( + builder, + &image.source_range, + source, + image.width, + image.height, + ); + } +} + +fn apply_html_highlight_style(refinement: &mut TextStyleRefinement, style: &HtmlHighlightStyle) { + if style.weight != FontWeight::default() { + refinement.font_weight = Some(style.weight); + } + + if style.oblique { + refinement.font_style = Some(FontStyle::Oblique); + } else if style.italic { + refinement.font_style = Some(FontStyle::Italic); + } + + if style.underline { + refinement.underline = Some(UnderlineStyle { + thickness: px(1.), + color: None, + ..Default::default() + }); + } + + if style.strikethrough { + refinement.strikethrough = Some(StrikethroughStyle { + thickness: px(1.), + color: None, + }); + } +} + +fn html_list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { + let index = order.saturating_sub(1); + const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; + const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; + + if ordered { + match depth { + 0 => format!("{}. ", order), + 1 => format!( + "{}. ", + NUMBERED_PREFIXES_1 + .chars() + .nth(index % NUMBERED_PREFIXES_1.len()) + .unwrap() + ), + _ => format!( + "{}. ", + NUMBERED_PREFIXES_2 + .chars() + .nth(index % NUMBERED_PREFIXES_2.len()) + .unwrap() + ), + } + } else { + let depth = depth.min(BULLETS.len() - 1); + format!("{} ", BULLETS[depth]) + } +} + +fn html_table_columns_count(rows: &[ParsedHtmlTableRow]) -> usize { + let mut actual_column_count = 0; + for row in rows { + actual_column_count = actual_column_count.max( + row.columns + .iter() + .map(|column| column.col_span) + .sum::(), + ); + } + actual_column_count +} + +#[cfg(test)] +mod tests { + use gpui::{TestAppContext, size}; + use ui::prelude::*; + + use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + + fn ensure_theme_initialized(cx: &mut TestAppContext) { + cx.update(|cx| { + if !cx.has_global::() { + settings::init(cx); + } + if !cx.has_global::() { + theme::init(theme::LoadThemes::JustBase, cx); + } + }); + } + + fn render_markdown_text(markdown: &str, cx: &mut TestAppContext) -> crate::RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| Markdown::new(markdown.to_string().into(), None, None, cx)); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + rendered.text + } + + #[gpui::test] + fn test_html_block_rendering_smoke(cx: &mut TestAppContext) { + let rendered = render_markdown_text( + "

Hello

world

  • item
", + cx, + ); + + let rendered_lines = rendered + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>(); + + assert_eq!( + rendered_lines.concat().replace('\n', ""), + "

Hello

world

  • item
" + ); + } + + #[gpui::test] + fn test_html_block_rendering_can_be_enabled(cx: &mut TestAppContext) { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + "

Hello

world

  • item
".into(), + None, + None, + MarkdownOptions { + parse_html: true, + ..Default::default() + }, + cx, + ) + }); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + + let rendered_lines = rendered + .text + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>(); + + assert_eq!(rendered_lines[0], "Hello"); + assert_eq!(rendered_lines[1], "world"); + assert!(rendered_lines.iter().any(|line| line.contains("item"))); + } +} diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index edff18e8eb14f42d380ef5081d9de25b82417fd5..7a8c50e0d0662e251173c2d433aee8ba2d5d3af7 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1,3 +1,5 @@ +pub mod html; +mod mermaid; pub mod parser; mod path_range; @@ -9,6 +11,9 @@ use gpui::UnderlineStyle; use language::LanguageName; use log::Level; +use mermaid::{ + MermaidState, ParsedMarkdownMermaidDiagram, extract_mermaid_diagrams, render_mermaid_diagram, +}; pub use path_range::{LineCol, PathWithRange}; use settings::Settings as _; use theme::ThemeSettings; @@ -29,13 +34,16 @@ use collections::{HashMap, HashSet}; use gpui::{ AnyElement, App, BorderStyle, Bounds, ClipboardItem, CursorStyle, DispatchPhase, Edges, Entity, FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image, - ImageFormat, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent, - MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, StyleRefinement, StyledText, - Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, actions, img, point, quad, + ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, + MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, + StyleRefinement, StyledText, Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, + actions, img, point, quad, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; -use parser::{MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown}; +use parser::{ + MarkdownEvent, MarkdownTag, MarkdownTagEnd, parse_links_only, parse_markdown_with_options, +}; use pulldown_cmark::Alignment; use sum_tree::TreeMap; use theme::SyntaxTheme; @@ -47,7 +55,8 @@ use crate::parser::CodeBlockKind; /// A callback function that can be used to customize the style of links based on the destination URL. /// If the callback returns `None`, the default link style will be used. type LinkStyleCallback = Rc Option>; - +type SourceClickCallback = Box bool>; +type CheckboxToggleCallback = Rc, bool, &mut Window, &mut App)>; /// Defines custom style refinements for each heading level (H1-H6) #[derive(Clone, Default)] pub struct HeadingLevelStyles { @@ -239,6 +248,7 @@ pub struct Markdown { selection: Selection, pressed_link: Option, autoscroll_request: Option, + active_root_block: Option, parsed_markdown: ParsedMarkdown, images_by_source_offset: HashMap>, should_reparse: bool, @@ -246,14 +256,18 @@ pub struct Markdown { focus_handle: FocusHandle, language_registry: Option>, fallback_code_block_language: Option, - options: Options, + options: MarkdownOptions, + mermaid_state: MermaidState, copied_code_blocks: HashSet, code_block_scroll_handles: BTreeMap, context_menu_selected_text: Option, } -struct Options { - parse_links_only: bool, +#[derive(Clone, Copy, Default)] +pub struct MarkdownOptions { + pub parse_links_only: bool, + pub parse_html: bool, + pub render_mermaid_diagrams: bool, } pub enum CodeBlockRenderer { @@ -300,6 +314,22 @@ impl Markdown { language_registry: Option>, fallback_code_block_language: Option, cx: &mut Context, + ) -> Self { + Self::new_with_options( + source, + language_registry, + fallback_code_block_language, + MarkdownOptions::default(), + cx, + ) + } + + pub fn new_with_options( + source: SharedString, + language_registry: Option>, + fallback_code_block_language: Option, + options: MarkdownOptions, + cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); let mut this = Self { @@ -307,6 +337,7 @@ impl Markdown { selection: Selection::default(), pressed_link: None, autoscroll_request: None, + active_root_block: None, should_reparse: false, images_by_source_offset: Default::default(), parsed_markdown: ParsedMarkdown::default(), @@ -314,9 +345,8 @@ impl Markdown { focus_handle, language_registry, fallback_code_block_language, - options: Options { - parse_links_only: false, - }, + options, + mermaid_state: MermaidState::default(), copied_code_blocks: HashSet::default(), code_block_scroll_handles: BTreeMap::default(), context_menu_selected_text: None, @@ -326,28 +356,16 @@ impl Markdown { } pub fn new_text(source: SharedString, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - let mut this = Self { + Self::new_with_options( source, - selection: Selection::default(), - pressed_link: None, - autoscroll_request: None, - should_reparse: false, - parsed_markdown: ParsedMarkdown::default(), - images_by_source_offset: Default::default(), - pending_parse: None, - focus_handle, - language_registry: None, - fallback_code_block_language: None, - options: Options { + None, + None, + MarkdownOptions { parse_links_only: true, + ..Default::default() }, - copied_code_blocks: HashSet::default(), - code_block_scroll_handles: BTreeMap::default(), - context_menu_selected_text: None, - }; - this.parse(cx); - this + cx, + ) } fn code_block_scroll_handle(&mut self, id: usize) -> ScrollHandle { @@ -410,6 +428,30 @@ impl Markdown { self.parse(cx); } + pub fn request_autoscroll_to_source_index( + &mut self, + source_index: usize, + cx: &mut Context, + ) { + self.autoscroll_request = Some(source_index); + cx.refresh_windows(); + } + + pub fn set_active_root_for_source_index( + &mut self, + source_index: Option, + cx: &mut Context, + ) { + let active_root_block = + source_index.and_then(|index| self.parsed_markdown.root_block_for_source_index(index)); + if self.active_root_block == active_root_block { + return; + } + + self.active_root_block = active_root_block; + cx.notify(); + } + pub fn reset(&mut self, source: SharedString, cx: &mut Context) { if source == self.source() { return; @@ -489,6 +531,17 @@ impl Markdown { fn parse(&mut self, cx: &mut Context) { if self.source.is_empty() { + self.should_reparse = false; + self.pending_parse.take(); + self.parsed_markdown = ParsedMarkdown { + source: self.source.clone(), + ..Default::default() + }; + self.active_root_block = None; + self.images_by_source_offset.clear(); + self.mermaid_state.clear(); + cx.notify(); + cx.refresh_windows(); return; } @@ -503,6 +556,8 @@ impl Markdown { fn start_background_parse(&self, cx: &Context) -> Task<()> { let source = self.source.clone(); let should_parse_links_only = self.options.parse_links_only; + let should_parse_html = self.options.parse_html; + let should_render_mermaid_diagrams = self.options.render_mermaid_diagrams; let language_registry = self.language_registry.clone(); let fallback = self.fallback_code_block_language.clone(); @@ -514,12 +569,25 @@ impl Markdown { source, languages_by_name: TreeMap::default(), languages_by_path: TreeMap::default(), + root_block_starts: Arc::default(), + html_blocks: BTreeMap::default(), + mermaid_diagrams: BTreeMap::default(), }, Default::default(), ); } - let (events, language_names, paths) = parse_markdown(&source); + let parsed = parse_markdown_with_options(&source, should_parse_html); + let events = parsed.events; + let language_names = parsed.language_names; + let paths = parsed.language_paths; + let root_block_starts = parsed.root_block_starts; + let html_blocks = parsed.html_blocks; + let mermaid_diagrams = if should_render_mermaid_diagrams { + extract_mermaid_diagrams(&source, &events) + } else { + BTreeMap::default() + }; let mut images_by_source_offset = HashMap::default(); let mut languages_by_name = TreeMap::default(); let mut languages_by_path = TreeMap::default(); @@ -578,6 +646,9 @@ impl Markdown { events: Arc::from(events), languages_by_name, languages_by_path, + root_block_starts: Arc::from(root_block_starts), + html_blocks, + mermaid_diagrams, }, images_by_source_offset, ) @@ -589,10 +660,22 @@ impl Markdown { this.update(cx, |this, cx| { this.parsed_markdown = parsed; this.images_by_source_offset = images_by_source_offset; + if this.active_root_block.is_some_and(|block_index| { + block_index >= this.parsed_markdown.root_block_starts.len() + }) { + this.active_root_block = None; + } + if this.options.render_mermaid_diagrams { + let parsed_markdown = this.parsed_markdown.clone(); + this.mermaid_state.update(&parsed_markdown, cx); + } else { + this.mermaid_state.clear(); + } this.pending_parse.take(); if this.should_reparse { this.parse(cx); } + cx.notify(); cx.refresh_windows(); }) .ok(); @@ -686,6 +769,9 @@ pub struct ParsedMarkdown { pub events: Arc<[(Range, MarkdownEvent)]>, pub languages_by_name: TreeMap>, pub languages_by_path: TreeMap, Arc>, + pub root_block_starts: Arc<[usize]>, + pub(crate) html_blocks: BTreeMap, + pub(crate) mermaid_diagrams: BTreeMap, } impl ParsedMarkdown { @@ -696,6 +782,30 @@ impl ParsedMarkdown { pub fn events(&self) -> &Arc<[(Range, MarkdownEvent)]> { &self.events } + + pub fn root_block_starts(&self) -> &Arc<[usize]> { + &self.root_block_starts + } + + pub fn root_block_for_source_index(&self, source_index: usize) -> Option { + if self.root_block_starts.is_empty() { + return None; + } + + let partition = self + .root_block_starts + .partition_point(|block_start| *block_start <= source_index); + + Some(partition.saturating_sub(1)) + } +} + +pub enum AutoscrollBehavior { + /// Propagate the request up the element tree for the nearest + /// scrollable ancestor (e.g. `List`) to handle. + Propagate, + /// Directly control a specific scroll handle. + Controlled(ScrollHandle), } pub struct MarkdownElement { @@ -703,6 +813,11 @@ pub struct MarkdownElement { style: MarkdownStyle, code_block_renderer: CodeBlockRenderer, on_url_click: Option>, + on_source_click: Option, + on_checkbox_toggle: Option, + image_resolver: Option Option>>, + show_root_block_markers: bool, + autoscroll: AutoscrollBehavior, } impl MarkdownElement { @@ -716,6 +831,11 @@ impl MarkdownElement { border: false, }, on_url_click: None, + on_source_click: None, + on_checkbox_toggle: None, + image_resolver: None, + show_root_block_markers: false, + autoscroll: AutoscrollBehavior::Propagate, } } @@ -753,6 +873,147 @@ impl MarkdownElement { self } + pub fn on_source_click( + mut self, + handler: impl Fn(usize, usize, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_source_click = Some(Box::new(handler)); + self + } + + pub fn on_checkbox_toggle( + mut self, + handler: impl Fn(Range, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_checkbox_toggle = Some(Rc::new(handler)); + self + } + + pub fn image_resolver( + mut self, + resolver: impl Fn(&str) -> Option + 'static, + ) -> Self { + self.image_resolver = Some(Box::new(resolver)); + self + } + + pub fn show_root_block_markers(mut self) -> Self { + self.show_root_block_markers = true; + self + } + + pub fn scroll_handle(mut self, scroll_handle: ScrollHandle) -> Self { + self.autoscroll = AutoscrollBehavior::Controlled(scroll_handle); + self + } + + fn push_markdown_image( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + source: ImageSource, + width: Option, + height: Option, + ) { + builder.modify_current_div(|el| { + el.items_center().flex().flex_row().child( + img(source) + .max_w_full() + .when_some(height, |this, height| this.h(height)) + .when_some(width, |this, width| this.w(width)), + ) + }); + let _ = range; + } + + fn push_markdown_paragraph( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + markdown_end: usize, + ) { + builder.push_div( + div().when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_2().line_height(rems(1.3)) + }), + range, + markdown_end, + ); + } + + fn push_markdown_heading( + &self, + builder: &mut MarkdownElementBuilder, + level: pulldown_cmark::HeadingLevel, + range: &Range, + markdown_end: usize, + ) { + let mut heading = div().mb_2(); + heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref()); + + let mut heading_style = self.style.heading.clone(); + let heading_text_style = heading_style.text_style().clone(); + heading.style().refine(&heading_style); + + builder.push_text_style(heading_text_style); + builder.push_div(heading, range, markdown_end); + } + + fn pop_markdown_heading(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_text_style(); + } + + fn push_markdown_block_quote( + &self, + builder: &mut MarkdownElementBuilder, + range: &Range, + markdown_end: usize, + ) { + builder.push_text_style(self.style.block_quote.clone()); + builder.push_div( + div() + .pl_4() + .mb_2() + .border_l_4() + .border_color(self.style.block_quote_border_color), + range, + markdown_end, + ); + } + + fn pop_markdown_block_quote(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_text_style(); + } + + fn push_markdown_list_item( + &self, + builder: &mut MarkdownElementBuilder, + bullet: AnyElement, + range: &Range, + markdown_end: usize, + ) { + builder.push_div( + div() + .when(!self.style.height_is_multiple_of_line_height, |el| { + el.mb_1().gap_1().line_height(rems(1.3)) + }) + .h_flex() + .items_start() + .child(bullet), + range, + markdown_end, + ); + // Without `w_0`, text doesn't wrap to the width of the container. + builder.push_div(div().flex_1().w_0(), range, markdown_end); + } + + fn pop_markdown_list_item(&self, builder: &mut MarkdownElementBuilder) { + builder.pop_div(); + builder.pop_div(); + } + fn paint_selection( &self, bounds: Bounds, @@ -846,6 +1107,7 @@ impl MarkdownElement { } let on_open_url = self.on_url_click.take(); + let on_source_click = self.on_source_click.take(); self.on_mouse_event(window, cx, { let hitbox = hitbox.clone(); @@ -873,6 +1135,16 @@ impl MarkdownElement { match rendered_text.source_index_for_position(event.position) { Ok(ix) | Err(ix) => ix, }; + if let Some(handler) = on_source_click.as_ref() { + let blocked = handler(source_index, event.click_count, window, cx); + if blocked { + markdown.selection = Selection::default(); + markdown.pressed_link = None; + window.prevent_default(); + cx.notify(); + return; + } + } let (range, mode) = match event.click_count { 1 => { let range = source_index..source_index; @@ -980,14 +1252,38 @@ impl MarkdownElement { .update(cx, |markdown, _| markdown.autoscroll_request.take())?; let (position, line_height) = rendered_text.position_for_source_index(autoscroll_index)?; - let text_style = self.style.base_text_style.clone(); - let font_id = window.text_system().resolve_font(&text_style.font()); - let font_size = text_style.font_size.to_pixels(window.rem_size()); - let em_width = window.text_system().em_width(font_id, font_size).unwrap(); - window.request_autoscroll(Bounds::from_corners( - point(position.x - 3. * em_width, position.y - 3. * line_height), - point(position.x + 3. * em_width, position.y + 3. * line_height), - )); + match &self.autoscroll { + AutoscrollBehavior::Controlled(scroll_handle) => { + let viewport = scroll_handle.bounds(); + let margin = line_height * 3.; + let top_goal = viewport.top() + margin; + let bottom_goal = viewport.bottom() - margin; + let current_offset = scroll_handle.offset(); + + let new_offset_y = if position.y < top_goal { + current_offset.y + (top_goal - position.y) + } else if position.y + line_height > bottom_goal { + current_offset.y + (bottom_goal - (position.y + line_height)) + } else { + current_offset.y + }; + + scroll_handle.set_offset(point( + current_offset.x, + new_offset_y.clamp(-scroll_handle.max_offset().y, Pixels::ZERO), + )); + } + AutoscrollBehavior::Propagate => { + let text_style = self.style.base_text_style.clone(); + let font_id = window.text_system().resolve_font(&text_style.font()); + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let em_width = window.text_system().em_width(font_id, font_size).unwrap(); + window.request_autoscroll(Bounds::from_corners( + point(position.x - 3. * em_width, position.y - 3. * line_height), + point(position.x + 3. * em_width, position.y + 3. * line_height), + )); + } + } Some(()) } @@ -1039,11 +1335,14 @@ impl Element for MarkdownElement { self.style.base_text_style.clone(), self.style.syntax.clone(), ); - let (parsed_markdown, images) = { + let (parsed_markdown, images, active_root_block, render_mermaid_diagrams, mermaid_state) = { let markdown = self.markdown.read(cx); ( markdown.parsed_markdown.clone(), markdown.images_by_source_offset.clone(), + markdown.active_root_block, + markdown.options.render_mermaid_diagrams, + markdown.mermaid_state.clone(), ) }; let markdown_end = if let Some(last) = parsed_markdown.events.last() { @@ -1054,6 +1353,8 @@ impl Element for MarkdownElement { let mut code_block_ids = HashSet::default(); let mut current_img_block_range: Option> = None; + let mut handled_html_block = false; + let mut rendered_mermaid_block = false; for (index, (range, event)) in parsed_markdown.events.iter().enumerate() { // Skip alt text for images that rendered if let Some(current_img_block_range) = ¤t_img_block_range @@ -1062,58 +1363,83 @@ impl Element for MarkdownElement { continue; } + if handled_html_block { + if let MarkdownEvent::End(MarkdownTagEnd::HtmlBlock) = event { + handled_html_block = false; + } else { + continue; + } + } + + if rendered_mermaid_block { + if matches!(event, MarkdownEvent::End(MarkdownTagEnd::CodeBlock)) { + rendered_mermaid_block = false; + } + continue; + } + match event { + MarkdownEvent::RootStart => { + if self.show_root_block_markers { + builder.push_root_block(range, markdown_end); + } + } + MarkdownEvent::RootEnd(root_block_index) => { + if self.show_root_block_markers { + builder.pop_root_block( + active_root_block == Some(*root_block_index), + cx.theme().colors().border, + cx.theme().colors().border_variant, + ); + } + } MarkdownEvent::Start(tag) => { match tag { - MarkdownTag::Image { .. } => { + MarkdownTag::Image { dest_url, .. } => { if let Some(image) = images.get(&range.start) { current_img_block_range = Some(range.clone()); - builder.modify_current_div(|el| { - el.items_center() - .flex() - .flex_row() - .child(img(image.clone())) - }); + self.push_markdown_image( + &mut builder, + range, + image.clone().into(), + None, + None, + ); + } else if let Some(source) = self + .image_resolver + .as_ref() + .and_then(|resolve| resolve(dest_url.as_ref())) + { + current_img_block_range = Some(range.clone()); + self.push_markdown_image(&mut builder, range, source, None, None); } } MarkdownTag::Paragraph => { - builder.push_div( - div().when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_2().line_height(rems(1.3)) - }), - range, - markdown_end, - ); + self.push_markdown_paragraph(&mut builder, range, markdown_end); } MarkdownTag::Heading { level, .. } => { - let mut heading = div().mb_2(); - - heading = apply_heading_style( - heading, - *level, - self.style.heading_level_styles.as_ref(), - ); - - heading.style().refine(&self.style.heading); - - let text_style = self.style.heading.text_style().clone(); - - builder.push_text_style(text_style); - builder.push_div(heading, range, markdown_end); + self.push_markdown_heading(&mut builder, *level, range, markdown_end); } MarkdownTag::BlockQuote => { - builder.push_text_style(self.style.block_quote.clone()); - builder.push_div( - div() - .pl_4() - .mb_2() - .border_l_4() - .border_color(self.style.block_quote_border_color), - range, - markdown_end, - ); + self.push_markdown_block_quote(&mut builder, range, markdown_end); } MarkdownTag::CodeBlock { kind, .. } => { + if render_mermaid_diagrams + && let Some(mermaid_diagram) = + parsed_markdown.mermaid_diagrams.get(&range.start) + { + builder.push_sourced_element( + mermaid_diagram.content_range.clone(), + render_mermaid_diagram( + mermaid_diagram, + &mermaid_state, + &self.style, + ), + ); + rendered_mermaid_block = true; + continue; + } + let language = match kind { CodeBlockKind::Fenced => None, CodeBlockKind::FencedLang(language) => { @@ -1197,46 +1523,57 @@ impl Element for MarkdownElement { (CodeBlockRenderer::Custom { .. }, _) => {} } } - MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end), + MarkdownTag::HtmlBlock => { + builder.push_div(div(), range, markdown_end); + if let Some(block) = parsed_markdown.html_blocks.get(&range.start) { + self.render_html_block(block, &mut builder, markdown_end, cx); + handled_html_block = true; + } + } MarkdownTag::List(bullet_index) => { builder.push_list(*bullet_index); builder.push_div(div().pl_2p5(), range, markdown_end); } MarkdownTag::Item => { - let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) = - parsed_markdown.events.get(index.saturating_add(1)) - { - let source = &parsed_markdown.source()[range.clone()]; - - Checkbox::new( - ElementId::Name(source.to_string().into()), - if *checked { + let bullet = + if let Some((task_range, MarkdownEvent::TaskListMarker(checked))) = + parsed_markdown.events.get(index.saturating_add(1)) + { + let source = &parsed_markdown.source()[range.clone()]; + let checked = *checked; + let toggle_state = if checked { ToggleState::Selected } else { ToggleState::Unselected - }, - ) - .fill() - .visualization_only(true) - .into_any_element() - } else if let Some(bullet_index) = builder.next_bullet_index() { - div().child(format!("{}.", bullet_index)).into_any_element() - } else { - div().child("•").into_any_element() - }; - builder.push_div( - div() - .when(!self.style.height_is_multiple_of_line_height, |el| { - el.mb_1().gap_1().line_height(rems(1.3)) - }) - .h_flex() - .items_start() - .child(bullet), - range, - markdown_end, - ); - // Without `w_0`, text doesn't wrap to the width of the container. - builder.push_div(div().flex_1().w_0(), range, markdown_end); + }; + + let checkbox = Checkbox::new( + ElementId::Name(source.to_string().into()), + toggle_state, + ) + .fill(); + + if let Some(on_toggle) = self.on_checkbox_toggle.clone() { + let task_source_range = task_range.clone(); + checkbox + .on_click(move |_state, window, cx| { + on_toggle( + task_source_range.clone(), + !checked, + window, + cx, + ); + }) + .into_any_element() + } else { + checkbox.visualization_only(true).into_any_element() + } + } else if let Some(bullet_index) = builder.next_bullet_index() { + div().child(format!("{}.", bullet_index)).into_any_element() + } else { + div().child("•").into_any_element() + }; + self.push_markdown_list_item(&mut builder, bullet, range, markdown_end); } MarkdownTag::Emphasis => builder.push_text_style(TextStyleRefinement { font_style: Some(FontStyle::Italic), @@ -1341,12 +1678,10 @@ impl Element for MarkdownElement { builder.pop_div(); } MarkdownTagEnd::Heading(_) => { - builder.pop_div(); - builder.pop_text_style() + self.pop_markdown_heading(&mut builder); } MarkdownTagEnd::BlockQuote(_kind) => { - builder.pop_text_style(); - builder.pop_div() + self.pop_markdown_block_quote(&mut builder); } MarkdownTagEnd::CodeBlock => { builder.trim_trailing_newline(); @@ -1424,8 +1759,7 @@ impl Element for MarkdownElement { builder.pop_div(); } MarkdownTagEnd::Item => { - builder.pop_div(); - builder.pop_div(); + self.pop_markdown_list_item(&mut builder); } MarkdownTagEnd::Emphasis => builder.pop_text_style(), MarkdownTagEnd::Strong => builder.pop_text_style(), @@ -1843,6 +2177,15 @@ impl MarkdownElementBuilder { self.div_stack.push(div); } + fn push_root_block(&mut self, range: &Range, markdown_end: usize) { + self.push_div( + div().group("markdown-root-block").relative(), + range, + markdown_end, + ); + self.push_div(div().pl_4(), range, markdown_end); + } + fn modify_current_div(&mut self, f: impl FnOnce(AnyDiv) -> AnyDiv) { self.flush_text(); if let Some(div) = self.div_stack.pop() { @@ -1850,12 +2193,53 @@ impl MarkdownElementBuilder { } } + fn pop_root_block( + &mut self, + is_active: bool, + active_gutter_color: Hsla, + hovered_gutter_color: Hsla, + ) { + self.pop_div(); + self.modify_current_div(|el| { + el.child( + div() + .h_full() + .w(px(4.0)) + .when(is_active, |this| this.bg(active_gutter_color)) + .group_hover("markdown-root-block", |this| { + if is_active { + this + } else { + this.bg(hovered_gutter_color) + } + }) + .rounded_xs() + .absolute() + .left_0() + .top_0(), + ) + }); + self.pop_div(); + } + fn pop_div(&mut self) { self.flush_text(); let div = self.div_stack.pop().unwrap().into_any_element(); self.div_stack.last_mut().unwrap().extend(iter::once(div)); } + fn push_sourced_element(&mut self, source_range: Range, element: impl Into) { + self.flush_text(); + let anchor = self.render_source_anchor(source_range); + self.div_stack.last_mut().unwrap().extend([{ + div() + .relative() + .child(anchor) + .child(element.into()) + .into_any_element() + }]); + } + fn push_list(&mut self, bullet_index: Option) { self.list_stack.push(ListStackEntry { bullet_index }); } @@ -1957,6 +2341,29 @@ impl MarkdownElementBuilder { } } + fn render_source_anchor(&mut self, source_range: Range) -> AnyElement { + let mut text_style = self.base_text_style.clone(); + text_style.color = Hsla::transparent_black(); + let text = "\u{200B}"; + let styled_text = StyledText::new(text).with_runs(vec![text_style.to_run(text.len())]); + self.rendered_lines.push(RenderedLine { + layout: styled_text.layout().clone(), + source_mappings: vec![SourceMapping { + rendered_index: 0, + source_index: source_range.start, + }], + source_end: source_range.end, + language: None, + }); + div() + .absolute() + .top_0() + .left_0() + .opacity(0.) + .child(styled_text) + .into_any_element() + } + fn flush_text(&mut self) { let line = mem::take(&mut self.pending_line); if line.text.is_empty() { @@ -2006,7 +2413,7 @@ impl RenderedLine { Ok(ix) => &self.source_mappings[ix], Err(ix) => &self.source_mappings[ix - 1], }; - mapping.rendered_index + (source_index - mapping.source_index) + (mapping.rendered_index + (source_index - mapping.source_index)).min(self.layout.len()) } fn source_index_for_rendered_index(&self, rendered_index: usize) -> usize { @@ -2334,6 +2741,15 @@ mod tests { markdown: &str, language_registry: Option>, cx: &mut TestAppContext, + ) -> RenderedText { + render_markdown_with_options(markdown, language_registry, MarkdownOptions::default(), cx) + } + + fn render_markdown_with_options( + markdown: &str, + language_registry: Option>, + options: MarkdownOptions, + cx: &mut TestAppContext, ) -> RenderedText { struct TestWindow; @@ -2346,8 +2762,15 @@ mod tests { ensure_theme_initialized(cx); let (_, cx) = cx.add_window_view(|_, _| TestWindow); - let markdown = - cx.new(|cx| Markdown::new(markdown.to_string().into(), language_registry, None, cx)); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + markdown.to_string().into(), + language_registry, + None, + options, + cx, + ) + }); cx.run_until_parked(); let (rendered, _) = cx.draw( Default::default(), @@ -2527,7 +2950,7 @@ mod tests { #[test] fn test_table_checkbox_detection() { let md = "| Done |\n|------|\n| [x] |\n| [ ] |"; - let (events, _, _) = crate::parser::parse_markdown(md); + let events = crate::parser::parse_markdown_with_options(md, false).events; let mut in_table = false; let mut cell_texts: Vec = Vec::new(); diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs new file mode 100644 index 0000000000000000000000000000000000000000..8b39f8c86c7b98c30c8879c362d036a333ad2c63 --- /dev/null +++ b/crates/markdown/src/mermaid.rs @@ -0,0 +1,614 @@ +use collections::HashMap; +use gpui::{ + Animation, AnimationExt, AnyElement, Context, ImageSource, RenderImage, StyledText, Task, img, + pulsating_between, +}; +use std::collections::BTreeMap; +use std::ops::Range; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; +use ui::prelude::*; + +use crate::parser::{CodeBlockKind, MarkdownEvent, MarkdownTag}; + +use super::{Markdown, MarkdownStyle, ParsedMarkdown}; + +type MermaidDiagramCache = HashMap>; + +#[derive(Clone, Debug)] +pub(crate) struct ParsedMarkdownMermaidDiagram { + pub(crate) content_range: Range, + pub(crate) contents: ParsedMarkdownMermaidDiagramContents, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ParsedMarkdownMermaidDiagramContents { + pub(crate) contents: SharedString, + pub(crate) scale: u32, +} + +#[derive(Default, Clone)] +pub(crate) struct MermaidState { + cache: MermaidDiagramCache, + order: Vec, +} + +struct CachedMermaidDiagram { + render_image: Arc>>>, + fallback_image: Option>, + _task: Task<()>, +} + +impl MermaidState { + pub(crate) fn clear(&mut self) { + self.cache.clear(); + self.order.clear(); + } + + fn get_fallback_image( + idx: usize, + old_order: &[ParsedMarkdownMermaidDiagramContents], + new_order_len: usize, + cache: &MermaidDiagramCache, + ) -> Option> { + if old_order.len() != new_order_len { + return None; + } + + old_order.get(idx).and_then(|old_content| { + cache.get(old_content).and_then(|old_cached| { + old_cached + .render_image + .get() + .and_then(|result| result.as_ref().ok().cloned()) + .or_else(|| old_cached.fallback_image.clone()) + }) + }) + } + + pub(crate) fn update(&mut self, parsed: &ParsedMarkdown, cx: &mut Context) { + let mut new_order = Vec::new(); + for mermaid_diagram in parsed.mermaid_diagrams.values() { + new_order.push(mermaid_diagram.contents.clone()); + } + + for (idx, new_content) in new_order.iter().enumerate() { + if !self.cache.contains_key(new_content) { + let fallback = + Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache); + self.cache.insert( + new_content.clone(), + Arc::new(CachedMermaidDiagram::new(new_content.clone(), fallback, cx)), + ); + } + } + + let new_order_set: std::collections::HashSet<_> = new_order.iter().cloned().collect(); + self.cache + .retain(|content, _| new_order_set.contains(content)); + self.order = new_order; + } +} + +impl CachedMermaidDiagram { + fn new( + contents: ParsedMarkdownMermaidDiagramContents, + fallback_image: Option>, + cx: &mut Context, + ) -> Self { + let render_image = Arc::new(OnceLock::>>::new()); + let render_image_clone = render_image.clone(); + let svg_renderer = cx.svg_renderer(); + + let task = cx.spawn(async move |this, cx| { + let value = cx + .background_spawn(async move { + let svg_string = mermaid_rs_renderer::render(&contents.contents)?; + let scale = contents.scale as f32 / 100.0; + svg_renderer + .render_single_frame(svg_string.as_bytes(), scale, true) + .map_err(|error| anyhow::anyhow!("{error}")) + }) + .await; + let _ = render_image_clone.set(value); + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }); + + Self { + render_image, + fallback_image, + _task: task, + } + } + + #[cfg(test)] + fn new_for_test( + render_image: Option>, + fallback_image: Option>, + ) -> Self { + let result = Arc::new(OnceLock::new()); + if let Some(render_image) = render_image { + let _ = result.set(Ok(render_image)); + } + Self { + render_image: result, + fallback_image, + _task: Task::ready(()), + } + } +} + +fn parse_mermaid_info(info: &str) -> Option { + let mut parts = info.split_whitespace(); + if parts.next()? != "mermaid" { + return None; + } + + Some( + parts + .next() + .and_then(|scale| scale.parse().ok()) + .unwrap_or(100) + .clamp(10, 500), + ) +} + +pub(crate) fn extract_mermaid_diagrams( + source: &str, + events: &[(Range, MarkdownEvent)], +) -> BTreeMap { + let mut mermaid_diagrams = BTreeMap::default(); + + for (source_range, event) in events { + let MarkdownEvent::Start(MarkdownTag::CodeBlock { kind, metadata }) = event else { + continue; + }; + let CodeBlockKind::FencedLang(info) = kind else { + continue; + }; + let Some(scale) = parse_mermaid_info(info.as_ref()) else { + continue; + }; + + let contents = source[metadata.content_range.clone()] + .strip_suffix('\n') + .unwrap_or(&source[metadata.content_range.clone()]) + .to_string(); + mermaid_diagrams.insert( + source_range.start, + ParsedMarkdownMermaidDiagram { + content_range: metadata.content_range.clone(), + contents: ParsedMarkdownMermaidDiagramContents { + contents: contents.into(), + scale, + }, + }, + ); + } + + mermaid_diagrams +} + +pub(crate) fn render_mermaid_diagram( + parsed: &ParsedMarkdownMermaidDiagram, + mermaid_state: &MermaidState, + style: &MarkdownStyle, +) -> AnyElement { + let cached = mermaid_state.cache.get(&parsed.contents); + let mut container = div().w_full(); + container.style().refine(&style.code_block); + + if let Some(result) = cached.and_then(|cached| cached.render_image.get()) { + match result { + Ok(render_image) => container + .child( + div().w_full().child( + img(ImageSource::Render(render_image.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ), + ) + .into_any_element(), + Err(_) => container + .child(StyledText::new(parsed.contents.contents.clone())) + .into_any_element(), + } + } else if let Some(fallback) = cached.and_then(|cached| cached.fallback_image.as_ref()) { + container + .child( + div() + .w_full() + .child( + img(ImageSource::Render(fallback.clone())) + .max_w_full() + .with_fallback(|| { + div() + .child(Label::new("Failed to load mermaid diagram")) + .into_any_element() + }), + ) + .with_animation( + "mermaid-fallback-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.6, 1.0)), + |element, delta| element.opacity(delta), + ), + ) + .into_any_element() + } else { + container + .child( + Label::new("Rendering mermaid diagram...") + .color(Color::Muted) + .with_animation( + "mermaid-loading-pulse", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::{ + CachedMermaidDiagram, MermaidDiagramCache, MermaidState, + ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info, + }; + use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle}; + use collections::HashMap; + use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size}; + use std::sync::Arc; + use ui::prelude::*; + + fn ensure_theme_initialized(cx: &mut TestAppContext) { + cx.update(|cx| { + if !cx.has_global::() { + settings::init(cx); + } + if !cx.has_global::() { + theme::init(theme::LoadThemes::JustBase, cx); + } + }); + } + + fn render_markdown_with_options( + markdown: &str, + options: MarkdownOptions, + cx: &mut TestAppContext, + ) -> crate::RenderedText { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options(markdown.to_string().into(), None, None, options, cx) + }); + cx.run_until_parked(); + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer( + CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }, + ) + }, + ); + rendered.text + } + + fn mock_render_image(cx: &mut TestAppContext) -> Arc { + cx.update(|cx| { + cx.svg_renderer() + .render_single_frame( + br#""#, + 1.0, + true, + ) + .unwrap() + }) + } + + fn mermaid_contents(contents: &str) -> ParsedMarkdownMermaidDiagramContents { + ParsedMarkdownMermaidDiagramContents { + contents: contents.to_string().into(), + scale: 100, + } + } + + fn mermaid_sequence(diagrams: &[&str]) -> Vec { + diagrams + .iter() + .map(|diagram| mermaid_contents(diagram)) + .collect() + } + + fn mermaid_fallback( + new_diagram: &str, + new_full_order: &[ParsedMarkdownMermaidDiagramContents], + old_full_order: &[ParsedMarkdownMermaidDiagramContents], + cache: &MermaidDiagramCache, + ) -> Option> { + let new_content = mermaid_contents(new_diagram); + let idx = new_full_order + .iter() + .position(|diagram| diagram == &new_content)?; + MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache) + } + + #[test] + fn test_parse_mermaid_info() { + assert_eq!(parse_mermaid_info("mermaid"), Some(100)); + assert_eq!(parse_mermaid_info("mermaid 150"), Some(150)); + assert_eq!(parse_mermaid_info("mermaid 5"), Some(10)); + assert_eq!(parse_mermaid_info("mermaid 999"), Some(500)); + assert_eq!(parse_mermaid_info("rust"), None); + } + + #[test] + fn test_extract_mermaid_diagrams_parses_scale() { + let markdown = "```mermaid 150\ngraph TD;\n```\n\n```rust\nfn main() {}\n```"; + let events = crate::parser::parse_markdown_with_options(markdown, false).events; + let diagrams = extract_mermaid_diagrams(markdown, &events); + + assert_eq!(diagrams.len(), 1); + let diagram = diagrams.values().next().unwrap(); + assert_eq!(diagram.contents.contents, "graph TD;"); + assert_eq!(diagram.contents.scale, 150); + } + + #[gpui::test] + fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + + let svg_b = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph B"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(svg_b.clone()), + None, + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = + mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache); + + assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_b.id)); + } + + #[gpui::test] + fn test_mermaid_no_fallback_on_add_in_middle(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache); + + assert!(fallback.is_none()); + } + + #[gpui::test] + fn test_mermaid_fallback_chains_on_rapid_edits(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); + let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]); + + let original_svg = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + cache.insert( + mermaid_contents("graph B modified"), + Arc::new(CachedMermaidDiagram::new_for_test( + None, + Some(original_svg.clone()), + )), + ); + cache.insert( + mermaid_contents("graph C"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback( + "graph B modified again", + &new_full_order, + &old_full_order, + &cache, + ); + + assert_eq!( + fallback.as_ref().map(|image| image.id), + Some(original_svg.id) + ); + } + + #[gpui::test] + fn test_mermaid_fallback_with_duplicate_blocks_edit_second(cx: &mut TestAppContext) { + let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); + let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]); + + let svg_a = mock_render_image(cx); + + let mut cache: MermaidDiagramCache = HashMap::default(); + cache.insert( + mermaid_contents("graph A"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(svg_a.clone()), + None, + )), + ); + cache.insert( + mermaid_contents("graph B"), + Arc::new(CachedMermaidDiagram::new_for_test( + Some(mock_render_image(cx)), + None, + )), + ); + + let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); + + assert_eq!(fallback.as_ref().map(|image| image.id), Some(svg_a.id)); + } + + #[gpui::test] + fn test_mermaid_rendering_replaces_code_block_text(cx: &mut TestAppContext) { + let rendered = render_markdown_with_options( + "```mermaid\ngraph TD;\n```", + MarkdownOptions { + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ); + + let text = rendered + .lines + .iter() + .map(|line| line.layout.wrapped_text()) + .collect::>() + .join("\n"); + + assert!(!text.contains("graph TD;")); + } + + #[gpui::test] + fn test_mermaid_source_anchor_maps_inside_block(cx: &mut TestAppContext) { + struct TestWindow; + + impl Render for TestWindow { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } + } + + ensure_theme_initialized(cx); + + let (_, cx) = cx.add_window_view(|_, _| TestWindow); + let markdown = cx.new(|cx| { + Markdown::new_with_options( + "```mermaid\ngraph TD;\n```".into(), + None, + None, + MarkdownOptions { + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ) + }); + cx.run_until_parked(); + let render_image = mock_render_image(cx); + markdown.update(cx, |markdown, _| { + let contents = markdown + .parsed_markdown + .mermaid_diagrams + .values() + .next() + .unwrap() + .contents + .clone(); + markdown.mermaid_state.cache.insert( + contents.clone(), + Arc::new(CachedMermaidDiagram::new_for_test(Some(render_image), None)), + ); + markdown.mermaid_state.order = vec![contents]; + }); + + let (rendered, _) = cx.draw( + Default::default(), + size(px(600.0), px(600.0)), + |_window, _cx| { + MarkdownElement::new(markdown.clone(), MarkdownStyle::default()) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + }, + ); + + let mermaid_diagram = markdown.update(cx, |markdown, _| { + markdown + .parsed_markdown + .mermaid_diagrams + .values() + .next() + .unwrap() + .clone() + }); + assert!( + rendered + .text + .position_for_source_index(mermaid_diagram.content_range.start) + .is_some() + ); + assert!( + rendered + .text + .position_for_source_index(mermaid_diagram.content_range.end.saturating_sub(1)) + .is_some() + ); + } +} diff --git a/crates/markdown/src/parser.rs b/crates/markdown/src/parser.rs index f530b88908380be13de2005bb8b3ec2b7e6e31b5..2c0ca0cdd2e3f383342be5457d127ce7112e330e 100644 --- a/crates/markdown/src/parser.rs +++ b/crates/markdown/src/parser.rs @@ -4,11 +4,11 @@ pub use pulldown_cmark::TagEnd as MarkdownTagEnd; use pulldown_cmark::{ Alignment, CowStr, HeadingLevel, LinkType, MetadataBlockKind, Options, Parser, }; -use std::{ops::Range, sync::Arc}; +use std::{collections::BTreeMap, ops::Range, sync::Arc}; use collections::HashSet; -use crate::path_range::PathWithRange; +use crate::{html, path_range::PathWithRange}; pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES .union(Options::ENABLE_FOOTNOTES) @@ -22,16 +22,69 @@ pub const PARSE_OPTIONS: Options = Options::ENABLE_TABLES .union(Options::ENABLE_SUPERSCRIPT) .union(Options::ENABLE_SUBSCRIPT); -pub fn parse_markdown( - text: &str, -) -> ( - Vec<(Range, MarkdownEvent)>, - HashSet, - HashSet>, -) { - let mut events = Vec::new(); +#[derive(Default)] +struct ParseState { + events: Vec<(Range, MarkdownEvent)>, + root_block_starts: Vec, + depth: usize, +} + +#[derive(Debug, Default)] +#[cfg_attr(test, derive(PartialEq))] +pub(crate) struct ParsedMarkdownData { + pub events: Vec<(Range, MarkdownEvent)>, + pub language_names: HashSet, + pub language_paths: HashSet>, + pub root_block_starts: Vec, + pub html_blocks: BTreeMap, +} + +impl ParseState { + fn push_event(&mut self, range: Range, event: MarkdownEvent) { + match &event { + MarkdownEvent::Start(_) => { + if self.depth == 0 { + self.root_block_starts.push(range.start); + self.events.push((range.clone(), MarkdownEvent::RootStart)); + } + self.depth += 1; + self.events.push((range, event)); + } + MarkdownEvent::End(_) => { + self.events.push((range.clone(), event)); + if self.depth > 0 { + self.depth -= 1; + if self.depth == 0 { + let root_block_index = self.root_block_starts.len() - 1; + self.events + .push((range, MarkdownEvent::RootEnd(root_block_index))); + } + } + } + MarkdownEvent::Rule => { + if self.depth == 0 && !range.is_empty() { + self.root_block_starts.push(range.start); + let root_block_index = self.root_block_starts.len() - 1; + self.events.push((range.clone(), MarkdownEvent::RootStart)); + self.events.push((range.clone(), event)); + self.events + .push((range, MarkdownEvent::RootEnd(root_block_index))); + } else { + self.events.push((range, event)); + } + } + _ => { + self.events.push((range, event)); + } + } + } +} + +pub(crate) fn parse_markdown_with_options(text: &str, parse_html: bool) -> ParsedMarkdownData { + let mut state = ParseState::default(); let mut language_names = HashSet::default(); let mut language_paths = HashSet::default(); + let mut html_blocks = BTreeMap::default(); let mut within_link = false; let mut within_metadata = false; let mut parser = Parser::new_ext(text, PARSE_OPTIONS) @@ -48,6 +101,32 @@ pub fn parse_markdown( } match pulldown_event { pulldown_cmark::Event::Start(tag) => { + if let pulldown_cmark::Tag::HtmlBlock = &tag { + state.push_event(range.clone(), MarkdownEvent::Start(MarkdownTag::HtmlBlock)); + + if parse_html { + if let Some(block) = + html::html_parser::parse_html_block(&text[range.clone()], range.clone()) + { + html_blocks.insert(range.start, block); + + while let Some((event, end_range)) = parser.next() { + if let pulldown_cmark::Event::End( + pulldown_cmark::TagEnd::HtmlBlock, + ) = event + { + state.push_event( + end_range, + MarkdownEvent::End(MarkdownTagEnd::HtmlBlock), + ); + break; + } + } + } + } + continue; + } + let tag = match tag { pulldown_cmark::Tag::Link { link_type, @@ -63,9 +142,9 @@ pub fn parse_markdown( id: SharedString::from(id.into_string()), } } - pulldown_cmark::Tag::MetadataBlock(kind) => { + pulldown_cmark::Tag::MetadataBlock(_kind) => { within_metadata = true; - MarkdownTag::MetadataBlock(kind) + continue; } pulldown_cmark::Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Indented) => { MarkdownTag::CodeBlock { @@ -164,20 +243,20 @@ pub fn parse_markdown( title: SharedString::from(title.into_string()), id: SharedString::from(id.into_string()), }, - pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, + pulldown_cmark::Tag::HtmlBlock => MarkdownTag::HtmlBlock, // this is handled above separately pulldown_cmark::Tag::DefinitionList => MarkdownTag::DefinitionList, pulldown_cmark::Tag::DefinitionListTitle => MarkdownTag::DefinitionListTitle, pulldown_cmark::Tag::DefinitionListDefinition => { MarkdownTag::DefinitionListDefinition } }; - events.push((range, MarkdownEvent::Start(tag))) + state.push_event(range, MarkdownEvent::Start(tag)) } pulldown_cmark::Event::End(tag) => { if let pulldown_cmark::TagEnd::Link = tag { within_link = false; } - events.push((range, MarkdownEvent::End(tag))); + state.push_event(range, MarkdownEvent::End(tag)); } pulldown_cmark::Event::Text(parsed) => { fn event_for( @@ -205,16 +284,26 @@ pub fn parse_markdown( parsed, }]; - while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) { - let Some((pulldown_cmark::Event::Text(next_event), next_range)) = parser.next() - else { + while matches!(parser.peek(), Some((pulldown_cmark::Event::Text(_), _))) + || (parse_html + && matches!( + parser.peek(), + Some((pulldown_cmark::Event::InlineHtml(_), _)) + )) + { + let Some((next_event, next_range)) = parser.next() else { unreachable!() }; - let next_len = last_len + next_event.len(); + let next_text = match next_event { + pulldown_cmark::Event::Text(next_event) => next_event, + pulldown_cmark::Event::InlineHtml(_) => CowStr::Borrowed(""), + _ => unreachable!(), + }; + let next_len = last_len + next_text.len(); ranges.push(TextRange { source_range: next_range.clone(), merged_range: last_len..next_len, - parsed: next_event, + parsed: next_text, }); last_len = next_len; } @@ -241,7 +330,8 @@ pub fn parse_markdown( .is_some_and(|range| range.merged_range.end <= link_start_in_merged) { let range = ranges.next().unwrap(); - events.push(event_for(text, range.source_range, &range.parsed)); + let (range, event) = event_for(text, range.source_range, &range.parsed); + state.push_event(range, event); } let Some(range) = ranges.peek_mut() else { @@ -250,11 +340,12 @@ pub fn parse_markdown( let prefix_len = link_start_in_merged - range.merged_range.start; if prefix_len > 0 { let (head, tail) = range.parsed.split_at(prefix_len); - events.push(event_for( + let (event_range, event) = event_for( text, range.source_range.start..range.source_range.start + prefix_len, head, - )); + ); + state.push_event(event_range, event); range.parsed = CowStr::Boxed(tail.into()); range.merged_range.start += prefix_len; range.source_range.start += prefix_len; @@ -290,7 +381,7 @@ pub fn parse_markdown( } let link_range = link_start_in_source..link_end_in_source; - events.push(( + state.push_event( link_range.clone(), MarkdownEvent::Start(MarkdownTag::Link { link_type: LinkType::Autolink, @@ -298,37 +389,52 @@ pub fn parse_markdown( title: SharedString::default(), id: SharedString::default(), }), - )); - events.extend(link_events); - events.push((link_range.clone(), MarkdownEvent::End(MarkdownTagEnd::Link))); + ); + for (range, event) in link_events { + state.push_event(range, event); + } + state.push_event( + link_range.clone(), + MarkdownEvent::End(MarkdownTagEnd::Link), + ); } } for range in ranges { - events.push(event_for(text, range.source_range, &range.parsed)); + let (range, event) = event_for(text, range.source_range, &range.parsed); + state.push_event(range, event); } } pulldown_cmark::Event::Code(_) => { let content_range = extract_code_content_range(&text[range.clone()]); let content_range = content_range.start + range.start..content_range.end + range.start; - events.push((content_range, MarkdownEvent::Code)) + state.push_event(content_range, MarkdownEvent::Code) + } + pulldown_cmark::Event::Html(_) => state.push_event(range, MarkdownEvent::Html), + pulldown_cmark::Event::InlineHtml(_) => { + state.push_event(range, MarkdownEvent::InlineHtml) } - pulldown_cmark::Event::Html(_) => events.push((range, MarkdownEvent::Html)), - pulldown_cmark::Event::InlineHtml(_) => events.push((range, MarkdownEvent::InlineHtml)), pulldown_cmark::Event::FootnoteReference(_) => { - events.push((range, MarkdownEvent::FootnoteReference)) + state.push_event(range, MarkdownEvent::FootnoteReference) } - pulldown_cmark::Event::SoftBreak => events.push((range, MarkdownEvent::SoftBreak)), - pulldown_cmark::Event::HardBreak => events.push((range, MarkdownEvent::HardBreak)), - pulldown_cmark::Event::Rule => events.push((range, MarkdownEvent::Rule)), + pulldown_cmark::Event::SoftBreak => state.push_event(range, MarkdownEvent::SoftBreak), + pulldown_cmark::Event::HardBreak => state.push_event(range, MarkdownEvent::HardBreak), + pulldown_cmark::Event::Rule => state.push_event(range, MarkdownEvent::Rule), pulldown_cmark::Event::TaskListMarker(checked) => { - events.push((range, MarkdownEvent::TaskListMarker(checked))) + state.push_event(range, MarkdownEvent::TaskListMarker(checked)) } pulldown_cmark::Event::InlineMath(_) | pulldown_cmark::Event::DisplayMath(_) => {} } } - (events, language_names, language_paths) + + ParsedMarkdownData { + events: state.events, + language_names, + language_paths, + root_block_starts: state.root_block_starts, + html_blocks, + } } pub fn parse_links_only(text: &str) -> Vec<(Range, MarkdownEvent)> { @@ -401,6 +507,10 @@ pub enum MarkdownEvent { Rule, /// A task list marker, rendered as a checkbox in HTML. Contains a true when it is checked. TaskListMarker(bool), + /// Start of a root-level block (a top-level structural element like a paragraph, heading, list, etc.). + RootStart, + /// End of a root-level block. Contains the root block index. + RootEnd(usize), } /// Tags for elements that can contain other elements. @@ -575,31 +685,39 @@ mod tests { #[test] fn test_html_comments() { assert_eq!( - parse_markdown(" \nReturns"), - ( - vec![ + parse_markdown_with_options(" \nReturns", false), + ParsedMarkdownData { + events: vec![ + (2..30, RootStart), (2..30, Start(HtmlBlock)), (2..2, SubstitutedText(" ".into())), (2..7, Html), (7..26, Html), (26..30, Html), (2..30, End(MarkdownTagEnd::HtmlBlock)), + (2..30, RootEnd(0)), + (30..37, RootStart), (30..37, Start(Paragraph)), (30..37, Text), - (30..37, End(MarkdownTagEnd::Paragraph)) + (30..37, End(MarkdownTagEnd::Paragraph)), + (30..37, RootEnd(1)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![2, 30], + ..Default::default() + } ) } #[test] fn test_plain_urls_and_escaped_text() { assert_eq!( - parse_markdown("   https://some.url some \\`►\\` text"), - ( - vec![ + parse_markdown_with_options( + "   https://some.url some \\`►\\` text", + false + ), + ParsedMarkdownData { + events: vec![ + (0..51, RootStart), (0..51, Start(Paragraph)), (0..6, SubstitutedText("\u{a0}".into())), (6..12, SubstitutedText("\u{a0}".into())), @@ -620,19 +738,25 @@ mod tests { (37..44, SubstitutedText("►".into())), (45..46, Text), // Escaped backtick (46..51, Text), - (0..51, End(MarkdownTagEnd::Paragraph)) + (0..51, End(MarkdownTagEnd::Paragraph)), + (0..51, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ); } #[test] fn test_incomplete_link() { assert_eq!( - parse_markdown("You can use the [GitHub Search API](https://docs.github.com/en").0, + parse_markdown_with_options( + "You can use the [GitHub Search API](https://docs.github.com/en", + false + ) + .events, vec![ + (0..62, RootStart), (0..62, Start(Paragraph)), (0..16, Text), (16..17, Text), @@ -650,7 +774,8 @@ mod tests { ), (36..62, Text), (36..62, End(MarkdownTagEnd::Link)), - (0..62, End(MarkdownTagEnd::Paragraph)) + (0..62, End(MarkdownTagEnd::Paragraph)), + (0..62, RootEnd(0)), ], ); } @@ -658,9 +783,13 @@ mod tests { #[test] fn test_smart_punctuation() { assert_eq!( - parse_markdown("-- --- ... \"double quoted\" 'single quoted' ----------"), - ( - vec![ + parse_markdown_with_options( + "-- --- ... \"double quoted\" 'single quoted' ----------", + false + ), + ParsedMarkdownData { + events: vec![ + (0..53, RootStart), (0..53, Start(Paragraph)), (0..2, SubstitutedText("–".into())), (2..3, Text), @@ -668,29 +797,31 @@ mod tests { (6..7, Text), (7..10, SubstitutedText("…".into())), (10..11, Text), - (11..12, SubstitutedText("“".into())), + (11..12, SubstitutedText("\u{201c}".into())), (12..25, Text), - (25..26, SubstitutedText("”".into())), + (25..26, SubstitutedText("\u{201d}".into())), (26..27, Text), - (27..28, SubstitutedText("‘".into())), + (27..28, SubstitutedText("\u{2018}".into())), (28..41, Text), - (41..42, SubstitutedText("’".into())), + (41..42, SubstitutedText("\u{2019}".into())), (42..43, Text), (43..53, SubstitutedText("–––––".into())), - (0..53, End(MarkdownTagEnd::Paragraph)) + (0..53, End(MarkdownTagEnd::Paragraph)), + (0..53, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ) } #[test] fn test_code_block_metadata() { assert_eq!( - parse_markdown("```rust\nfn main() {\n let a = 1;\n}\n```"), - ( - vec![ + parse_markdown_with_options("```rust\nfn main() {\n let a = 1;\n}\n```", false), + ParsedMarkdownData { + events: vec![ + (0..37, RootStart), ( 0..37, Start(CodeBlock { @@ -703,19 +834,22 @@ mod tests { ), (8..34, Text), (0..37, End(MarkdownTagEnd::CodeBlock)), + (0..37, RootEnd(0)), ], - { + language_names: { let mut h = HashSet::default(); h.insert("rust".into()); h }, - HashSet::default() - ) + root_block_starts: vec![0], + ..Default::default() + } ); assert_eq!( - parse_markdown(" fn main() {}"), - ( - vec![ + parse_markdown_with_options(" fn main() {}", false), + ParsedMarkdownData { + events: vec![ + (4..16, RootStart), ( 4..16, Start(CodeBlock { @@ -727,14 +861,76 @@ mod tests { }) ), (4..16, Text), - (4..16, End(MarkdownTagEnd::CodeBlock)) + (4..16, End(MarkdownTagEnd::CodeBlock)), + (4..16, RootEnd(0)), ], - HashSet::default(), - HashSet::default() - ) + root_block_starts: vec![4], + ..Default::default() + } ); } + #[test] + fn test_metadata_blocks_do_not_affect_root_blocks() { + assert_eq!( + parse_markdown_with_options("+++\ntitle = \"Example\"\n+++\n\nParagraph", false), + ParsedMarkdownData { + events: vec![ + (27..36, RootStart), + (27..36, Start(Paragraph)), + (27..36, Text), + (27..36, End(MarkdownTagEnd::Paragraph)), + (27..36, RootEnd(0)), + ], + root_block_starts: vec![27], + ..Default::default() + } + ); + } + + #[test] + fn test_table_checkboxes_remain_text_in_cells() { + let markdown = "\ +| Done | Task | +|------|---------| +| [x] | Fix bug | +| [ ] | Add feature |"; + let parsed = parse_markdown_with_options(markdown, false); + + let mut in_table = false; + let mut saw_task_list_marker = false; + let mut cell_texts = Vec::new(); + let mut current_cell = String::new(); + + for (range, event) in &parsed.events { + match event { + Start(Table(_)) => in_table = true, + End(MarkdownTagEnd::Table) => in_table = false, + Start(TableCell) => current_cell.clear(), + End(MarkdownTagEnd::TableCell) => { + if in_table { + cell_texts.push(current_cell.clone()); + } + } + Text if in_table => current_cell.push_str(&markdown[range.clone()]), + TaskListMarker(_) if in_table => saw_task_list_marker = true, + _ => {} + } + } + + let checkbox_cells: Vec<&str> = cell_texts + .iter() + .map(|cell| cell.trim()) + .filter(|cell| *cell == "[x]" || *cell == "[X]" || *cell == "[ ]") + .collect(); + + assert!( + !saw_task_list_marker, + "Table checkboxes should remain text, not task-list markers" + ); + assert_eq!(checkbox_cells, vec!["[x]", "[ ]"]); + } + #[test] fn test_extract_code_content_range() { let input = "```let x = 5;```"; @@ -776,8 +972,13 @@ mod tests { // Note: In real usage, pulldown_cmark creates separate text events for the escaped character // We're verifying our parser can handle this correctly assert_eq!( - parse_markdown("https:/\\/example.com is equivalent to https://example.com!").0, + parse_markdown_with_options( + "https:/\\/example.com is equivalent to https://example.com!", + false + ) + .events, vec![ + (0..62, RootStart), (0..62, Start(Paragraph)), ( 0..20, @@ -806,13 +1007,19 @@ mod tests { (58..61, Text), (38..61, End(MarkdownTagEnd::Link)), (61..62, Text), - (0..62, End(MarkdownTagEnd::Paragraph)) + (0..62, End(MarkdownTagEnd::Paragraph)), + (0..62, RootEnd(0)), ], ); assert_eq!( - parse_markdown("Visit https://example.com/cat\\/é‍☕ for coffee!").0, + parse_markdown_with_options( + "Visit https://example.com/cat\\/é‍☕ for coffee!", + false + ) + .events, [ + (0..55, RootStart), (0..55, Start(Paragraph)), (0..6, Text), ( @@ -830,7 +1037,8 @@ mod tests { (40..43, Text), (6..43, End(MarkdownTagEnd::Link)), (43..55, Text), - (0..55, End(MarkdownTagEnd::Paragraph)) + (0..55, End(MarkdownTagEnd::Paragraph)), + (0..55, RootEnd(0)), ] ); } diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 782de627ec26273820bb3505b778a862659f315f..558b57b769953b572678c3d997ae771462f51896 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -16,28 +16,18 @@ test-support = [] [dependencies] anyhow.workspace = true -async-recursion.workspace = true -collections.workspace = true editor.workspace = true gpui.workspace = true -html5ever.workspace = true language.workspace = true -linkify.workspace = true log.workspace = true markdown.workspace = true -markup5ever_rcdom.workspace = true -pretty_assertions.workspace = true -pulldown-cmark.workspace = true settings.workspace = true -stacksafe.workspace = true theme.workspace = true ui.workspace = true urlencoding.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true -mermaid-rs-renderer.workspace = true [dev-dependencies] -editor = { workspace = true, features = ["test-support"] } -language = { workspace = true, features = ["test-support"] } +tempfile.workspace = true diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs deleted file mode 100644 index e8d9fd0ab8e3ea8583548f5abb3168e07119a4d9..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_elements.rs +++ /dev/null @@ -1,374 +0,0 @@ -use gpui::{ - DefiniteLength, FontStyle, FontWeight, HighlightStyle, SharedString, StrikethroughStyle, - UnderlineStyle, px, -}; -use language::HighlightId; - -use std::{fmt::Display, ops::Range, path::PathBuf}; -use urlencoding; - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownElement { - Heading(ParsedMarkdownHeading), - ListItem(ParsedMarkdownListItem), - Table(ParsedMarkdownTable), - BlockQuote(ParsedMarkdownBlockQuote), - CodeBlock(ParsedMarkdownCodeBlock), - MermaidDiagram(ParsedMarkdownMermaidDiagram), - /// A paragraph of text and other inline elements. - Paragraph(MarkdownParagraph), - HorizontalRule(Range), - Image(Image), -} - -impl ParsedMarkdownElement { - pub fn source_range(&self) -> Option> { - Some(match self { - Self::Heading(heading) => heading.source_range.clone(), - Self::ListItem(list_item) => list_item.source_range.clone(), - Self::Table(table) => table.source_range.clone(), - Self::BlockQuote(block_quote) => block_quote.source_range.clone(), - Self::CodeBlock(code_block) => code_block.source_range.clone(), - Self::MermaidDiagram(mermaid) => mermaid.source_range.clone(), - Self::Paragraph(text) => match text.get(0)? { - MarkdownParagraphChunk::Text(t) => t.source_range.clone(), - MarkdownParagraphChunk::Image(image) => image.source_range.clone(), - }, - Self::HorizontalRule(range) => range.clone(), - Self::Image(image) => image.source_range.clone(), - }) - } - - pub fn is_list_item(&self) -> bool { - matches!(self, Self::ListItem(_)) - } -} - -pub type MarkdownParagraph = Vec; - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum MarkdownParagraphChunk { - Text(ParsedMarkdownText), - Image(Image), -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdown { - pub children: Vec, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownListItem { - pub source_range: Range, - /// How many indentations deep this item is. - pub depth: u16, - pub item_type: ParsedMarkdownListItemType, - pub content: Vec, - /// Whether we can expect nested list items inside of this items `content`. - pub nested: bool, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownListItemType { - Ordered(u64), - Task(bool, Range), - Unordered, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownCodeBlock { - pub source_range: Range, - pub language: Option, - pub contents: SharedString, - pub highlights: Option, HighlightId)>>, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownMermaidDiagram { - pub source_range: Range, - pub contents: ParsedMarkdownMermaidDiagramContents, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ParsedMarkdownMermaidDiagramContents { - pub contents: SharedString, - pub scale: u32, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownHeading { - pub source_range: Range, - pub level: HeadingLevel, - pub contents: MarkdownParagraph, -} - -#[derive(Debug, PartialEq)] -pub enum HeadingLevel { - H1, - H2, - H3, - H4, - H5, - H6, -} - -#[derive(Debug)] -pub struct ParsedMarkdownTable { - pub source_range: Range, - pub header: Vec, - pub body: Vec, - pub caption: Option, -} - -#[derive(Debug, Clone, Copy, Default)] -#[cfg_attr(test, derive(PartialEq))] -pub enum ParsedMarkdownTableAlignment { - #[default] - None, - Left, - Center, - Right, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownTableColumn { - pub col_span: usize, - pub row_span: usize, - pub is_header: bool, - pub children: MarkdownParagraph, - pub alignment: ParsedMarkdownTableAlignment, -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownTableRow { - pub columns: Vec, -} - -impl Default for ParsedMarkdownTableRow { - fn default() -> Self { - Self::new() - } -} - -impl ParsedMarkdownTableRow { - pub fn new() -> Self { - Self { - columns: Vec::new(), - } - } - - pub fn with_columns(columns: Vec) -> Self { - Self { columns } - } -} - -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownBlockQuote { - pub source_range: Range, - pub children: Vec, -} - -#[derive(Debug, Clone)] -pub struct ParsedMarkdownText { - /// Where the text is located in the source Markdown document. - pub source_range: Range, - /// The text content stripped of any formatting symbols. - pub contents: SharedString, - /// The list of highlights contained in the Markdown document. - pub highlights: Vec<(Range, MarkdownHighlight)>, - /// The regions of the Markdown document. - pub regions: Vec<(Range, ParsedRegion)>, -} - -/// A run of highlighted Markdown text. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum MarkdownHighlight { - /// A styled Markdown highlight. - Style(MarkdownHighlightStyle), - /// A highlighted code block. - Code(HighlightId), -} - -impl MarkdownHighlight { - /// Converts this [`MarkdownHighlight`] to a [`HighlightStyle`]. - pub fn to_highlight_style(&self, theme: &theme::SyntaxTheme) -> Option { - match self { - MarkdownHighlight::Style(style) => { - let mut highlight = HighlightStyle::default(); - - if style.italic { - highlight.font_style = Some(FontStyle::Italic); - } - - if style.underline { - highlight.underline = Some(UnderlineStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.strikethrough { - highlight.strikethrough = Some(StrikethroughStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.weight != FontWeight::default() { - highlight.font_weight = Some(style.weight); - } - - if style.link { - highlight.underline = Some(UnderlineStyle { - thickness: px(1.), - ..Default::default() - }); - } - - if style.oblique { - highlight.font_style = Some(FontStyle::Oblique) - } - - Some(highlight) - } - - MarkdownHighlight::Code(id) => theme.get(*id).cloned(), - } - } -} - -/// The style for a Markdown highlight. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct MarkdownHighlightStyle { - /// Whether the text should be italicized. - pub italic: bool, - /// Whether the text should be underlined. - pub underline: bool, - /// Whether the text should be struck through. - pub strikethrough: bool, - /// The weight of the text. - pub weight: FontWeight, - /// Whether the text should be stylized as link. - pub link: bool, - // Whether the text should be obliqued. - pub oblique: bool, -} - -/// A parsed region in a Markdown document. -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedRegion { - /// Whether the region is a code block. - pub code: bool, - /// The link contained in this region, if it has one. - pub link: Option, -} - -/// A Markdown link. -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub enum Link { - /// A link to a webpage. - Web { - /// The URL of the webpage. - url: String, - }, - /// A link to a path on the filesystem. - Path { - /// The path as provided in the Markdown document. - display_path: PathBuf, - /// The absolute path to the item. - path: PathBuf, - }, -} - -impl Link { - pub fn identify(file_location_directory: Option, text: String) -> Option { - if text.starts_with("http") { - return Some(Link::Web { url: text }); - } - - // URL decode the text to handle spaces and other special characters - let decoded_text = urlencoding::decode(&text) - .map(|s| s.into_owned()) - .unwrap_or(text); - - let path = PathBuf::from(&decoded_text); - if path.is_absolute() && path.exists() { - return Some(Link::Path { - display_path: path.clone(), - path, - }); - } - - if let Some(file_location_directory) = file_location_directory { - let display_path = path; - let path = file_location_directory.join(decoded_text); - if path.exists() { - return Some(Link::Path { display_path, path }); - } - } - - None - } -} - -impl Display for Link { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Link::Web { url } => write!(f, "{}", url), - Link::Path { display_path, .. } => write!(f, "{}", display_path.display()), - } - } -} - -/// A Markdown Image -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(PartialEq))] -pub struct Image { - pub link: Link, - pub source_range: Range, - pub alt_text: Option, - pub width: Option, - pub height: Option, -} - -impl Image { - pub fn identify( - text: String, - source_range: Range, - file_location_directory: Option, - ) -> Option { - let link = Link::identify(file_location_directory, text)?; - Some(Self { - source_range, - link, - alt_text: None, - width: None, - height: None, - }) - } - - pub fn set_alt_text(&mut self, alt_text: SharedString) { - self.alt_text = Some(alt_text); - } - - pub fn set_width(&mut self, width: DefiniteLength) { - self.width = Some(width); - } - - pub fn set_height(&mut self, height: DefiniteLength) { - self.height = Some(height); - } -} diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs deleted file mode 100644 index 40a1ed804f750a7e3173a76643ad1f6b1a362bd3..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_parser.rs +++ /dev/null @@ -1,3320 +0,0 @@ -use crate::{ - markdown_elements::*, - markdown_minifier::{Minifier, MinifierOptions}, -}; -use async_recursion::async_recursion; -use collections::FxHashMap; -use gpui::{DefiniteLength, FontWeight, px, relative}; -use html5ever::{ParseOpts, local_name, parse_document, tendril::TendrilSink}; -use language::LanguageRegistry; -use markdown::parser::PARSE_OPTIONS; -use markup5ever_rcdom::RcDom; -use pulldown_cmark::{Alignment, Event, Parser, Tag, TagEnd}; -use stacksafe::stacksafe; -use std::{ - cell::RefCell, collections::HashMap, mem, ops::Range, path::PathBuf, rc::Rc, sync::Arc, vec, -}; -use ui::SharedString; - -pub async fn parse_markdown( - markdown_input: &str, - file_location_directory: Option, - language_registry: Option>, -) -> ParsedMarkdown { - let parser = Parser::new_ext(markdown_input, PARSE_OPTIONS); - let parser = MarkdownParser::new( - parser.into_offset_iter().collect(), - file_location_directory, - language_registry, - ); - let renderer = parser.parse_document().await; - ParsedMarkdown { - children: renderer.parsed, - } -} - -fn cleanup_html(source: &str) -> Vec { - let mut writer = std::io::Cursor::new(Vec::new()); - let mut reader = std::io::Cursor::new(source); - let mut minify = Minifier::new( - &mut writer, - MinifierOptions { - omit_doctype: true, - collapse_whitespace: true, - ..Default::default() - }, - ); - if let Ok(()) = minify.minify(&mut reader) { - writer.into_inner() - } else { - source.bytes().collect() - } -} - -struct MarkdownParser<'a> { - tokens: Vec<(Event<'a>, Range)>, - /// The current index in the tokens array - cursor: usize, - /// The blocks that we have successfully parsed so far - parsed: Vec, - file_location_directory: Option, - language_registry: Option>, -} - -#[derive(Debug)] -struct ParseHtmlNodeContext { - list_item_depth: u16, -} - -impl Default for ParseHtmlNodeContext { - fn default() -> Self { - Self { list_item_depth: 1 } - } -} - -struct MarkdownListItem { - content: Vec, - item_type: ParsedMarkdownListItemType, -} - -impl Default for MarkdownListItem { - fn default() -> Self { - Self { - content: Vec::new(), - item_type: ParsedMarkdownListItemType::Unordered, - } - } -} - -impl<'a> MarkdownParser<'a> { - fn new( - tokens: Vec<(Event<'a>, Range)>, - file_location_directory: Option, - language_registry: Option>, - ) -> Self { - Self { - tokens, - file_location_directory, - language_registry, - cursor: 0, - parsed: vec![], - } - } - - fn eof(&self) -> bool { - if self.tokens.is_empty() { - return true; - } - self.cursor >= self.tokens.len() - 1 - } - - fn peek(&self, steps: usize) -> Option<&(Event<'_>, Range)> { - if self.eof() || (steps + self.cursor) >= self.tokens.len() { - return self.tokens.last(); - } - self.tokens.get(self.cursor + steps) - } - - fn previous(&self) -> Option<&(Event<'_>, Range)> { - if self.cursor == 0 || self.cursor > self.tokens.len() { - return None; - } - self.tokens.get(self.cursor - 1) - } - - fn current(&self) -> Option<&(Event<'_>, Range)> { - self.peek(0) - } - - fn current_event(&self) -> Option<&Event<'_>> { - self.current().map(|(event, _)| event) - } - - fn is_text_like(event: &Event) -> bool { - match event { - Event::Text(_) - // Represent an inline code block - | Event::Code(_) - | Event::Html(_) - | Event::InlineHtml(_) - | Event::FootnoteReference(_) - | Event::Start(Tag::Link { .. }) - | Event::Start(Tag::Emphasis) - | Event::Start(Tag::Strong) - | Event::Start(Tag::Strikethrough) - | Event::Start(Tag::Image { .. }) => { - true - } - _ => false, - } - } - - async fn parse_document(mut self) -> Self { - while !self.eof() { - if let Some(block) = self.parse_block().await { - self.parsed.extend(block); - } else { - self.cursor += 1; - } - } - self - } - - #[async_recursion] - async fn parse_block(&mut self) -> Option> { - let (current, source_range) = self.current().unwrap(); - let source_range = source_range.clone(); - match current { - Event::Start(tag) => match tag { - Tag::Paragraph => { - self.cursor += 1; - let text = self.parse_text(false, Some(source_range)); - Some(vec![ParsedMarkdownElement::Paragraph(text)]) - } - Tag::Heading { level, .. } => { - let level = *level; - self.cursor += 1; - let heading = self.parse_heading(level); - Some(vec![ParsedMarkdownElement::Heading(heading)]) - } - Tag::Table(alignment) => { - let alignment = alignment.clone(); - self.cursor += 1; - let table = self.parse_table(alignment); - Some(vec![ParsedMarkdownElement::Table(table)]) - } - Tag::List(order) => { - let order = *order; - self.cursor += 1; - let list = self.parse_list(order).await; - Some(list) - } - Tag::BlockQuote(_kind) => { - self.cursor += 1; - let block_quote = self.parse_block_quote().await; - Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)]) - } - Tag::CodeBlock(kind) => { - let (language, scale) = match kind { - pulldown_cmark::CodeBlockKind::Indented => (None, None), - pulldown_cmark::CodeBlockKind::Fenced(language) => { - if language.is_empty() { - (None, None) - } else { - let parts: Vec<&str> = language.split_whitespace().collect(); - let lang = parts.first().map(|s| s.to_string()); - let scale = parts.get(1).and_then(|s| s.parse::().ok()); - (lang, scale) - } - } - }; - - self.cursor += 1; - - if language.as_deref() == Some("mermaid") { - let mermaid_diagram = self.parse_mermaid_diagram(scale).await?; - Some(vec![ParsedMarkdownElement::MermaidDiagram(mermaid_diagram)]) - } else { - let code_block = self.parse_code_block(language).await?; - Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) - } - } - Tag::HtmlBlock => { - self.cursor += 1; - - Some(self.parse_html_block().await) - } - _ => None, - }, - Event::Rule => { - self.cursor += 1; - Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)]) - } - _ => None, - } - } - - fn parse_text( - &mut self, - should_complete_on_soft_break: bool, - source_range: Option>, - ) -> MarkdownParagraph { - let source_range = source_range.unwrap_or_else(|| { - self.current() - .map(|(_, range)| range.clone()) - .unwrap_or_default() - }); - - let mut markdown_text_like = Vec::new(); - let mut text = String::new(); - let mut bold_depth = 0; - let mut italic_depth = 0; - let mut strikethrough_depth = 0; - let mut link: Option = None; - let mut image: Option = None; - let mut regions: Vec<(Range, ParsedRegion)> = vec![]; - let mut highlights: Vec<(Range, MarkdownHighlight)> = vec![]; - let mut link_urls: Vec = vec![]; - let mut link_ranges: Vec> = vec![]; - - loop { - if self.eof() { - break; - } - - let (current, _) = self.current().unwrap(); - let prev_len = text.len(); - match current { - Event::SoftBreak => { - if should_complete_on_soft_break { - break; - } - text.push(' '); - } - - Event::HardBreak => { - text.push('\n'); - } - - // We want to ignore any inline HTML tags in the text but keep - // the text between them - Event::InlineHtml(_) => {} - - Event::Text(t) => { - text.push_str(t.as_ref()); - let mut style = MarkdownHighlightStyle::default(); - - if bold_depth > 0 { - style.weight = FontWeight::BOLD; - } - - if italic_depth > 0 { - style.italic = true; - } - - if strikethrough_depth > 0 { - style.strikethrough = true; - } - - let last_run_len = if let Some(link) = link.clone() { - regions.push(( - prev_len..text.len(), - ParsedRegion { - code: false, - link: Some(link), - }, - )); - style.link = true; - prev_len - } else { - // Manually scan for links - let mut finder = linkify::LinkFinder::new(); - finder.kinds(&[linkify::LinkKind::Url]); - let mut last_link_len = prev_len; - for link in finder.links(t) { - let start = prev_len + link.start(); - let end = prev_len + link.end(); - let range = start..end; - link_ranges.push(range.clone()); - link_urls.push(link.as_str().to_string()); - - // If there is a style before we match a link, we have to add this to the highlighted ranges - if style != MarkdownHighlightStyle::default() && last_link_len < start { - highlights.push(( - last_link_len..start, - MarkdownHighlight::Style(style.clone()), - )); - } - - highlights.push(( - range.clone(), - MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - ..style - }), - )); - - regions.push(( - range.clone(), - ParsedRegion { - code: false, - link: Some(Link::Web { - url: link.as_str().to_string(), - }), - }, - )); - last_link_len = end; - } - last_link_len - }; - - if style != MarkdownHighlightStyle::default() && last_run_len < text.len() { - let mut new_highlight = true; - if let Some((last_range, last_style)) = highlights.last_mut() - && last_range.end == last_run_len - && last_style == &MarkdownHighlight::Style(style.clone()) - { - last_range.end = text.len(); - new_highlight = false; - } - if new_highlight { - highlights.push(( - last_run_len..text.len(), - MarkdownHighlight::Style(style.clone()), - )); - } - } - } - Event::Code(t) => { - text.push_str(t.as_ref()); - let range = prev_len..text.len(); - - if link.is_some() { - highlights.push(( - range.clone(), - MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - }), - )); - } - regions.push(( - range, - ParsedRegion { - code: true, - link: link.clone(), - }, - )); - } - Event::Start(tag) => match tag { - Tag::Emphasis => italic_depth += 1, - Tag::Strong => bold_depth += 1, - Tag::Strikethrough => strikethrough_depth += 1, - Tag::Link { dest_url, .. } => { - link = Link::identify( - self.file_location_directory.clone(), - dest_url.to_string(), - ); - } - Tag::Image { dest_url, .. } => { - if !text.is_empty() { - let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: source_range.clone(), - contents: mem::take(&mut text).into(), - highlights: mem::take(&mut highlights), - regions: mem::take(&mut regions), - }); - markdown_text_like.push(parsed_regions); - } - image = Image::identify( - dest_url.to_string(), - source_range.clone(), - self.file_location_directory.clone(), - ); - } - _ => { - break; - } - }, - - Event::End(tag) => match tag { - TagEnd::Emphasis => italic_depth -= 1, - TagEnd::Strong => bold_depth -= 1, - TagEnd::Strikethrough => strikethrough_depth -= 1, - TagEnd::Link => { - link = None; - } - TagEnd::Image => { - if let Some(mut image) = image.take() { - if !text.is_empty() { - image.set_alt_text(std::mem::take(&mut text).into()); - mem::take(&mut highlights); - mem::take(&mut regions); - } - markdown_text_like.push(MarkdownParagraphChunk::Image(image)); - } - } - TagEnd::Paragraph => { - self.cursor += 1; - break; - } - _ => { - break; - } - }, - _ => { - break; - } - } - - self.cursor += 1; - } - if !text.is_empty() { - markdown_text_like.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - contents: text.into(), - highlights, - regions, - })); - } - markdown_text_like - } - - fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let text = self.parse_text(true, None); - - // Advance past the heading end tag - self.cursor += 1; - - ParsedMarkdownHeading { - source_range, - level: match level { - pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1, - pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2, - pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3, - pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4, - pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5, - pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6, - }, - contents: text, - } - } - - fn parse_table(&mut self, alignment: Vec) -> ParsedMarkdownTable { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let mut header = vec![]; - let mut body = vec![]; - let mut row_columns = vec![]; - let mut in_header = true; - let column_alignments = alignment - .iter() - .map(Self::convert_alignment) - .collect::>(); - - loop { - if self.eof() { - break; - } - - let (current, source_range) = self.current().unwrap(); - let source_range = source_range.clone(); - match current { - Event::Start(Tag::TableHead) - | Event::Start(Tag::TableRow) - | Event::End(TagEnd::TableCell) => { - self.cursor += 1; - } - Event::Start(Tag::TableCell) => { - self.cursor += 1; - let cell_contents = self.parse_text(false, Some(source_range)); - row_columns.push(ParsedMarkdownTableColumn { - col_span: 1, - row_span: 1, - is_header: in_header, - children: cell_contents, - alignment: column_alignments - .get(row_columns.len()) - .copied() - .unwrap_or_default(), - }); - } - Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => { - self.cursor += 1; - let columns = std::mem::take(&mut row_columns); - if in_header { - header.push(ParsedMarkdownTableRow { columns: columns }); - in_header = false; - } else { - body.push(ParsedMarkdownTableRow::with_columns(columns)); - } - } - Event::End(TagEnd::Table) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - ParsedMarkdownTable { - source_range, - header, - body, - caption: None, - } - } - - fn convert_alignment(alignment: &Alignment) -> ParsedMarkdownTableAlignment { - match alignment { - Alignment::None => ParsedMarkdownTableAlignment::None, - Alignment::Left => ParsedMarkdownTableAlignment::Left, - Alignment::Center => ParsedMarkdownTableAlignment::Center, - Alignment::Right => ParsedMarkdownTableAlignment::Right, - } - } - - async fn parse_list(&mut self, order: Option) -> Vec { - let (_, list_source_range) = self.previous().unwrap(); - - let mut items = Vec::new(); - let mut items_stack = vec![MarkdownListItem::default()]; - let mut depth = 1; - let mut order = order; - let mut order_stack = Vec::new(); - - let mut insertion_indices = FxHashMap::default(); - let mut source_ranges = FxHashMap::default(); - let mut start_item_range = list_source_range.clone(); - - while !self.eof() { - let (current, source_range) = self.current().unwrap(); - match current { - Event::Start(Tag::List(new_order)) => { - if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) { - insertion_indices.insert(depth, items.len()); - } - - // We will use the start of the nested list as the end for the current item's range, - // because we don't care about the hierarchy of list items - if let collections::hash_map::Entry::Vacant(e) = source_ranges.entry(depth) { - e.insert(start_item_range.start..source_range.start); - } - - order_stack.push(order); - order = *new_order; - self.cursor += 1; - depth += 1; - } - Event::End(TagEnd::List(_)) => { - order = order_stack.pop().flatten(); - self.cursor += 1; - depth -= 1; - - if depth == 0 { - break; - } - } - Event::Start(Tag::Item) => { - start_item_range = source_range.clone(); - - self.cursor += 1; - items_stack.push(MarkdownListItem::default()); - - let mut task_list = None; - // Check for task list marker (`- [ ]` or `- [x]`) - if let Some(event) = self.current_event() { - // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph - if event == &Event::Start(Tag::Paragraph) { - self.cursor += 1; - } - - if let Some((Event::TaskListMarker(checked), range)) = self.current() { - task_list = Some((*checked, range.clone())); - self.cursor += 1; - } - } - - if let Some((event, range)) = self.current() { - // This is a plain list item. - // For example `- some text` or `1. [Docs](./docs.md)` - if MarkdownParser::is_text_like(event) { - let text = self.parse_text(false, Some(range.clone())); - let block = ParsedMarkdownElement::Paragraph(text); - if let Some(content) = items_stack.last_mut() { - let item_type = if let Some((checked, range)) = task_list { - ParsedMarkdownListItemType::Task(checked, range) - } else if let Some(order) = order { - ParsedMarkdownListItemType::Ordered(order) - } else { - ParsedMarkdownListItemType::Unordered - }; - content.item_type = item_type; - content.content.push(block); - } - } else { - let block = self.parse_block().await; - if let Some(block) = block - && let Some(list_item) = items_stack.last_mut() - { - list_item.content.extend(block); - } - } - } - - // If there is a linebreak in between two list items the task list marker will actually be the first element of the paragraph - if self.current_event() == Some(&Event::End(TagEnd::Paragraph)) { - self.cursor += 1; - } - } - Event::End(TagEnd::Item) => { - self.cursor += 1; - - if let Some(current) = order { - order = Some(current + 1); - } - - if let Some(list_item) = items_stack.pop() { - let source_range = source_ranges - .remove(&depth) - .unwrap_or(start_item_range.clone()); - - // We need to remove the last character of the source range, because it includes the newline character - let source_range = source_range.start..source_range.end - 1; - let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - content: list_item.content, - depth, - item_type: list_item.item_type, - nested: false, - }); - - if let Some(index) = insertion_indices.get(&depth) { - items.insert(*index, item); - insertion_indices.remove(&depth); - } else { - items.push(item); - } - } - } - _ => { - if depth == 0 { - break; - } - // This can only happen if a list item starts with more then one paragraph, - // or the list item contains blocks that should be rendered after the nested list items - let block = self.parse_block().await; - if let Some(block) = block { - if let Some(list_item) = items_stack.last_mut() { - // If we did not insert any nested items yet (in this case insertion index is set), we can append the block to the current list item - if !insertion_indices.contains_key(&depth) { - list_item.content.extend(block); - continue; - } - } - - // Otherwise we need to insert the block after all the nested items - // that have been parsed so far - items.extend(block); - } else { - self.cursor += 1; - } - } - } - } - - items - } - - #[async_recursion] - async fn parse_block_quote(&mut self) -> ParsedMarkdownBlockQuote { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let mut nested_depth = 1; - - let mut children: Vec = vec![]; - - while !self.eof() { - let block = self.parse_block().await; - - if let Some(block) = block { - children.extend(block); - } else { - break; - } - - if self.eof() { - break; - } - - let (current, _source_range) = self.current().unwrap(); - match current { - // This is a nested block quote. - // Record that we're in a nested block quote and continue parsing. - // We don't need to advance the cursor since the next - // call to `parse_block` will handle it. - Event::Start(Tag::BlockQuote(_kind)) => { - nested_depth += 1; - } - Event::End(TagEnd::BlockQuote(_kind)) => { - nested_depth -= 1; - if nested_depth == 0 { - self.cursor += 1; - break; - } - } - _ => {} - }; - } - - ParsedMarkdownBlockQuote { - source_range, - children, - } - } - - async fn parse_code_block( - &mut self, - language: Option, - ) -> Option { - let Some((_event, source_range)) = self.previous() else { - return None; - }; - - let source_range = source_range.clone(); - let mut code = String::new(); - - while !self.eof() { - let Some((current, _source_range)) = self.current() else { - break; - }; - - match current { - Event::Text(text) => { - code.push_str(text); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - code = code.strip_suffix('\n').unwrap_or(&code).to_string(); - - let highlights = if let Some(language) = &language { - if let Some(registry) = &self.language_registry { - let rope: language::Rope = code.as_str().into(); - registry - .language_for_name_or_extension(language) - .await - .map(|l| l.highlight_text(&rope, 0..code.len())) - .ok() - } else { - None - } - } else { - None - }; - - Some(ParsedMarkdownCodeBlock { - source_range, - contents: code.into(), - language, - highlights, - }) - } - - async fn parse_mermaid_diagram( - &mut self, - scale: Option, - ) -> Option { - let Some((_event, source_range)) = self.previous() else { - return None; - }; - - let source_range = source_range.clone(); - let mut code = String::new(); - - while !self.eof() { - let Some((current, _source_range)) = self.current() else { - break; - }; - - match current { - Event::Text(text) => { - code.push_str(text); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - code = code.strip_suffix('\n').unwrap_or(&code).to_string(); - - let scale = scale.unwrap_or(100).clamp(10, 500); - - Some(ParsedMarkdownMermaidDiagram { - source_range, - contents: ParsedMarkdownMermaidDiagramContents { - contents: code.into(), - scale, - }, - }) - } - - async fn parse_html_block(&mut self) -> Vec { - let mut elements = Vec::new(); - let Some((_event, _source_range)) = self.previous() else { - return elements; - }; - - let mut html_source_range_start = None; - let mut html_source_range_end = None; - let mut html_buffer = String::new(); - - while !self.eof() { - let Some((current, source_range)) = self.current() else { - break; - }; - let source_range = source_range.clone(); - match current { - Event::Html(html) => { - html_source_range_start.get_or_insert(source_range.start); - html_source_range_end = Some(source_range.end); - html_buffer.push_str(html); - self.cursor += 1; - } - Event::End(TagEnd::CodeBlock) => { - self.cursor += 1; - break; - } - _ => { - break; - } - } - } - - let bytes = cleanup_html(&html_buffer); - - let mut cursor = std::io::Cursor::new(bytes); - if let Ok(dom) = parse_document(RcDom::default(), ParseOpts::default()) - .from_utf8() - .read_from(&mut cursor) - && let Some((start, end)) = html_source_range_start.zip(html_source_range_end) - { - self.parse_html_node( - start..end, - &dom.document, - &mut elements, - &ParseHtmlNodeContext::default(), - ); - } - - elements - } - - #[stacksafe] - fn parse_html_node( - &self, - source_range: Range, - node: &Rc, - elements: &mut Vec, - context: &ParseHtmlNodeContext, - ) { - match &node.data { - markup5ever_rcdom::NodeData::Document => { - self.consume_children(source_range, node, elements, context); - } - markup5ever_rcdom::NodeData::Text { contents } => { - elements.push(ParsedMarkdownElement::Paragraph(vec![ - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - regions: Vec::default(), - highlights: Vec::default(), - contents: contents.borrow().to_string().into(), - }), - ])); - } - markup5ever_rcdom::NodeData::Comment { .. } => {} - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - let mut styles = if let Some(styles) = Self::markdown_style_from_html_styles( - Self::extract_styles_from_attributes(attrs), - ) { - vec![MarkdownHighlight::Style(styles)] - } else { - Vec::default() - }; - - if local_name!("img") == name.local { - if let Some(image) = self.extract_image(source_range, attrs) { - elements.push(ParsedMarkdownElement::Image(image)); - } - } else if local_name!("p") == name.local { - let mut paragraph = MarkdownParagraph::new(); - self.parse_paragraph( - source_range, - node, - &mut paragraph, - &mut styles, - &mut Vec::new(), - ); - - if !paragraph.is_empty() { - elements.push(ParsedMarkdownElement::Paragraph(paragraph)); - } - } else if matches!( - name.local, - local_name!("h1") - | local_name!("h2") - | local_name!("h3") - | local_name!("h4") - | local_name!("h5") - | local_name!("h6") - ) { - let mut paragraph = MarkdownParagraph::new(); - self.consume_paragraph( - source_range.clone(), - node, - &mut paragraph, - &mut styles, - &mut Vec::new(), - ); - - if !paragraph.is_empty() { - elements.push(ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: match name.local { - local_name!("h1") => HeadingLevel::H1, - local_name!("h2") => HeadingLevel::H2, - local_name!("h3") => HeadingLevel::H3, - local_name!("h4") => HeadingLevel::H4, - local_name!("h5") => HeadingLevel::H5, - local_name!("h6") => HeadingLevel::H6, - _ => unreachable!(), - }, - contents: paragraph, - })); - } - } else if local_name!("ul") == name.local || local_name!("ol") == name.local { - if let Some(list_items) = self.extract_html_list( - node, - local_name!("ol") == name.local, - context.list_item_depth, - source_range, - ) { - elements.extend(list_items); - } - } else if local_name!("blockquote") == name.local { - if let Some(blockquote) = self.extract_html_blockquote(node, source_range) { - elements.push(ParsedMarkdownElement::BlockQuote(blockquote)); - } - } else if local_name!("table") == name.local { - if let Some(table) = self.extract_html_table(node, source_range) { - elements.push(ParsedMarkdownElement::Table(table)); - } - } else { - self.consume_children(source_range, node, elements, context); - } - } - _ => {} - } - } - - #[stacksafe] - fn parse_paragraph( - &self, - source_range: Range, - node: &Rc, - paragraph: &mut MarkdownParagraph, - highlights: &mut Vec, - regions: &mut Vec<(Range, ParsedRegion)>, - ) { - fn items_with_range( - range: Range, - items: impl IntoIterator, - ) -> Vec<(Range, T)> { - items - .into_iter() - .map(|item| (range.clone(), item)) - .collect() - } - - match &node.data { - markup5ever_rcdom::NodeData::Text { contents } => { - // append the text to the last chunk, so we can have a hacky version - // of inline text with highlighting - if let Some(text) = paragraph.iter_mut().last().and_then(|p| match p { - MarkdownParagraphChunk::Text(text) => Some(text), - _ => None, - }) { - let mut new_text = text.contents.to_string(); - new_text.push_str(&contents.borrow()); - - text.highlights.extend(items_with_range( - text.contents.len()..new_text.len(), - std::mem::take(highlights), - )); - text.regions.extend(items_with_range( - text.contents.len()..new_text.len(), - std::mem::take(regions) - .into_iter() - .map(|(_, region)| region), - )); - text.contents = SharedString::from(new_text); - } else { - let contents = contents.borrow().to_string(); - paragraph.push(MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range, - highlights: items_with_range(0..contents.len(), std::mem::take(highlights)), - regions: items_with_range( - 0..contents.len(), - std::mem::take(regions) - .into_iter() - .map(|(_, region)| region), - ), - contents: contents.into(), - })); - } - } - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - if local_name!("img") == name.local { - if let Some(image) = self.extract_image(source_range, attrs) { - paragraph.push(MarkdownParagraphChunk::Image(image)); - } - } else if local_name!("b") == name.local || local_name!("strong") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("i") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - italic: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("em") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - oblique: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("del") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - strikethrough: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("ins") == name.local { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - ..Default::default() - })); - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else if local_name!("a") == name.local { - if let Some(url) = Self::attr_value(attrs, local_name!("href")) - && let Some(link) = - Link::identify(self.file_location_directory.clone(), url) - { - highlights.push(MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - })); - - regions.push(( - source_range.clone(), - ParsedRegion { - code: false, - link: Some(link), - }, - )); - } - - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } else { - self.consume_paragraph(source_range, node, paragraph, highlights, regions); - } - } - _ => {} - } - } - - fn consume_paragraph( - &self, - source_range: Range, - node: &Rc, - paragraph: &mut MarkdownParagraph, - highlights: &mut Vec, - regions: &mut Vec<(Range, ParsedRegion)>, - ) { - for node in node.children.borrow().iter() { - self.parse_paragraph(source_range.clone(), node, paragraph, highlights, regions); - } - } - - fn parse_table_row( - &self, - source_range: Range, - node: &Rc, - ) -> Option { - let mut columns = Vec::new(); - - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("tr") != name.local { - return None; - } - - for node in node.children.borrow().iter() { - if let Some(column) = self.parse_table_column(source_range.clone(), node) { - columns.push(column); - } - } - } - _ => {} - } - - if columns.is_empty() { - None - } else { - Some(ParsedMarkdownTableRow { columns }) - } - } - - fn parse_table_column( - &self, - source_range: Range, - node: &Rc, - ) -> Option { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, attrs, .. } => { - if !matches!(name.local, local_name!("th") | local_name!("td")) { - return None; - } - - let mut children = MarkdownParagraph::new(); - self.consume_paragraph( - source_range, - node, - &mut children, - &mut Vec::new(), - &mut Vec::new(), - ); - - let is_header = matches!(name.local, local_name!("th")); - - Some(ParsedMarkdownTableColumn { - col_span: std::cmp::max( - Self::attr_value(attrs, local_name!("colspan")) - .and_then(|span| span.parse().ok()) - .unwrap_or(1), - 1, - ), - row_span: std::cmp::max( - Self::attr_value(attrs, local_name!("rowspan")) - .and_then(|span| span.parse().ok()) - .unwrap_or(1), - 1, - ), - is_header, - children, - alignment: Self::attr_value(attrs, local_name!("align")) - .and_then(|align| match align.as_str() { - "left" => Some(ParsedMarkdownTableAlignment::Left), - "center" => Some(ParsedMarkdownTableAlignment::Center), - "right" => Some(ParsedMarkdownTableAlignment::Right), - _ => None, - }) - .unwrap_or_else(|| { - if is_header { - ParsedMarkdownTableAlignment::Center - } else { - ParsedMarkdownTableAlignment::default() - } - }), - }) - } - _ => None, - } - } - - fn consume_children( - &self, - source_range: Range, - node: &Rc, - elements: &mut Vec, - context: &ParseHtmlNodeContext, - ) { - for node in node.children.borrow().iter() { - self.parse_html_node(source_range.clone(), node, elements, context); - } - } - - fn attr_value( - attrs: &RefCell>, - name: html5ever::LocalName, - ) -> Option { - attrs.borrow().iter().find_map(|attr| { - if attr.name.local == name { - Some(attr.value.to_string()) - } else { - None - } - }) - } - - fn markdown_style_from_html_styles( - styles: HashMap, - ) -> Option { - let mut markdown_style = MarkdownHighlightStyle::default(); - - if let Some(text_decoration) = styles.get("text-decoration") { - match text_decoration.to_lowercase().as_str() { - "underline" => { - markdown_style.underline = true; - } - "line-through" => { - markdown_style.strikethrough = true; - } - _ => {} - } - } - - if let Some(font_style) = styles.get("font-style") { - match font_style.to_lowercase().as_str() { - "italic" => { - markdown_style.italic = true; - } - "oblique" => { - markdown_style.oblique = true; - } - _ => {} - } - } - - if let Some(font_weight) = styles.get("font-weight") { - match font_weight.to_lowercase().as_str() { - "bold" => { - markdown_style.weight = FontWeight::BOLD; - } - "lighter" => { - markdown_style.weight = FontWeight::THIN; - } - _ => { - if let Some(weight) = font_weight.parse::().ok() { - markdown_style.weight = FontWeight(weight); - } - } - } - } - - if markdown_style != MarkdownHighlightStyle::default() { - Some(markdown_style) - } else { - None - } - } - - fn extract_styles_from_attributes( - attrs: &RefCell>, - ) -> HashMap { - let mut styles = HashMap::new(); - - if let Some(style) = Self::attr_value(attrs, local_name!("style")) { - for decl in style.split(';') { - let mut parts = decl.splitn(2, ':'); - if let Some((key, value)) = parts.next().zip(parts.next()) { - styles.insert( - key.trim().to_lowercase().to_string(), - value.trim().to_string(), - ); - } - } - } - - styles - } - - fn extract_image( - &self, - source_range: Range, - attrs: &RefCell>, - ) -> Option { - let src = Self::attr_value(attrs, local_name!("src"))?; - - let mut image = Image::identify(src, source_range, self.file_location_directory.clone())?; - - if let Some(alt) = Self::attr_value(attrs, local_name!("alt")) { - image.set_alt_text(alt.into()); - } - - let styles = Self::extract_styles_from_attributes(attrs); - - if let Some(width) = Self::attr_value(attrs, local_name!("width")) - .or_else(|| styles.get("width").cloned()) - .and_then(|width| Self::parse_html_element_dimension(&width)) - { - image.set_width(width); - } - - if let Some(height) = Self::attr_value(attrs, local_name!("height")) - .or_else(|| styles.get("height").cloned()) - .and_then(|height| Self::parse_html_element_dimension(&height)) - { - image.set_height(height); - } - - Some(image) - } - - fn extract_html_list( - &self, - node: &Rc, - ordered: bool, - depth: u16, - source_range: Range, - ) -> Option> { - let mut list_items = Vec::with_capacity(node.children.borrow().len()); - - for (index, node) in node.children.borrow().iter().enumerate() { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("li") != name.local { - continue; - } - - let mut content = Vec::new(); - self.consume_children( - source_range.clone(), - node, - &mut content, - &ParseHtmlNodeContext { - list_item_depth: depth + 1, - }, - ); - - if !content.is_empty() { - list_items.push(ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - depth, - source_range: source_range.clone(), - item_type: if ordered { - ParsedMarkdownListItemType::Ordered(index as u64 + 1) - } else { - ParsedMarkdownListItemType::Unordered - }, - content, - nested: true, - })); - } - } - _ => {} - } - } - - if list_items.is_empty() { - None - } else { - Some(list_items) - } - } - - fn parse_html_element_dimension(value: &str) -> Option { - if value.ends_with("%") { - value - .trim_end_matches("%") - .parse::() - .ok() - .map(|value| relative(value / 100.)) - } else { - value - .trim_end_matches("px") - .parse() - .ok() - .map(|value| px(value).into()) - } - } - - fn extract_html_blockquote( - &self, - node: &Rc, - source_range: Range, - ) -> Option { - let mut children = Vec::new(); - self.consume_children( - source_range.clone(), - node, - &mut children, - &ParseHtmlNodeContext::default(), - ); - - if children.is_empty() { - None - } else { - Some(ParsedMarkdownBlockQuote { - children, - source_range, - }) - } - } - - fn extract_html_table( - &self, - node: &Rc, - source_range: Range, - ) -> Option { - let mut header_rows = Vec::new(); - let mut body_rows = Vec::new(); - let mut caption = None; - - // node should be a thead, tbody or caption element - for node in node.children.borrow().iter() { - match &node.data { - markup5ever_rcdom::NodeData::Element { name, .. } => { - if local_name!("caption") == name.local { - let mut paragraph = MarkdownParagraph::new(); - self.parse_paragraph( - source_range.clone(), - node, - &mut paragraph, - &mut Vec::new(), - &mut Vec::new(), - ); - caption = Some(paragraph); - } - if local_name!("thead") == name.local { - // node should be a tr element - for node in node.children.borrow().iter() { - if let Some(row) = self.parse_table_row(source_range.clone(), node) { - header_rows.push(row); - } - } - } else if local_name!("tbody") == name.local { - // node should be a tr element - for node in node.children.borrow().iter() { - if let Some(row) = self.parse_table_row(source_range.clone(), node) { - body_rows.push(row); - } - } - } - } - _ => {} - } - } - - if !header_rows.is_empty() || !body_rows.is_empty() { - Some(ParsedMarkdownTable { - source_range, - body: body_rows, - header: header_rows, - caption, - }) - } else { - None - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ParsedMarkdownListItemType::*; - use core::panic; - use gpui::{AbsoluteLength, BackgroundExecutor, DefiniteLength}; - use language::{HighlightId, LanguageRegistry}; - use pretty_assertions::assert_eq; - - async fn parse(input: &str) -> ParsedMarkdown { - parse_markdown(input, None, None).await - } - - #[gpui::test] - async fn test_headings() { - let parsed = parse("# Heading one\n## Heading two\n### Heading three").await; - - assert_eq!( - parsed.children, - vec![ - h1(text("Heading one", 2..13), 0..14), - h2(text("Heading two", 17..28), 14..29), - h3(text("Heading three", 33..46), 29..46), - ] - ); - } - - #[gpui::test] - async fn test_newlines_dont_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n and *italicized*").await; - - assert_eq!( - parsed.children, - vec![p("Some text that is bolded and italicized", 0..46)] - ); - } - - #[gpui::test] - async fn test_heading_with_paragraph() { - let parsed = parse("# Zed\nThe editor").await; - - assert_eq!( - parsed.children, - vec![h1(text("Zed", 2..5), 0..6), p("The editor", 6..16),] - ); - } - - #[gpui::test] - async fn test_double_newlines_do_new_paragraphs() { - let parsed = parse("Some text **that is bolded**\n\n and *italicized*").await; - - assert_eq!( - parsed.children, - vec![ - p("Some text that is bolded", 0..29), - p("and italicized", 31..47), - ] - ); - } - - #[gpui::test] - async fn test_bold_italic_text() { - let parsed = parse("Some text **that is bolded** and *italicized*").await; - - assert_eq!( - parsed.children, - vec![p("Some text that is bolded and italicized", 0..45)] - ); - } - - #[gpui::test] - async fn test_nested_bold_strikethrough_text() { - let parsed = parse("Some **bo~~strikethrough~~ld** text").await; - - assert_eq!(parsed.children.len(), 1); - assert_eq!( - parsed.children[0], - ParsedMarkdownElement::Paragraph(vec![MarkdownParagraphChunk::Text( - ParsedMarkdownText { - source_range: 0..35, - contents: "Some bostrikethroughld text".into(), - highlights: Vec::new(), - regions: Vec::new(), - } - )]) - ); - - let new_text = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - let paragraph = if let MarkdownParagraphChunk::Text(text) = &new_text[0] { - text - } else { - panic!("Expected a text"); - }; - - assert_eq!( - paragraph.highlights, - vec![ - ( - 5..7, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - }), - ), - ( - 7..20, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - strikethrough: true, - ..Default::default() - }), - ), - ( - 20..22, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight::BOLD, - ..Default::default() - }), - ), - ] - ); - } - - #[gpui::test] - async fn test_html_inline_style_elements() { - let parsed = - parse("

Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text

").await; - - assert_eq!(1, parsed.children.len()); - let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] { - chunks - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(1, chunks.len()); - let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(0..205, text.source_range); - assert_eq!( - "Some text strong text more text bold text more text italic text more text emphasized text more text deleted text more text inserted text", - text.contents.as_str(), - ); - assert_eq!( - vec![ - ( - 10..21, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(700.0), - ..Default::default() - },), - ), - ( - 32..41, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(700.0), - ..Default::default() - },), - ), - ( - 52..63, - MarkdownHighlight::Style(MarkdownHighlightStyle { - italic: true, - weight: FontWeight(400.0), - ..Default::default() - },), - ), - ( - 74..89, - MarkdownHighlight::Style(MarkdownHighlightStyle { - weight: FontWeight(400.0), - oblique: true, - ..Default::default() - },), - ), - ( - 100..112, - MarkdownHighlight::Style(MarkdownHighlightStyle { - strikethrough: true, - weight: FontWeight(400.0), - ..Default::default() - },), - ), - ( - 123..136, - MarkdownHighlight::Style(MarkdownHighlightStyle { - underline: true, - weight: FontWeight(400.0,), - ..Default::default() - },), - ), - ], - text.highlights - ); - } - - #[gpui::test] - async fn test_html_href_element() { - let parsed = - parse("

Some text link more text

").await; - - assert_eq!(1, parsed.children.len()); - let chunks = if let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] { - chunks - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(1, chunks.len()); - let text = if let MarkdownParagraphChunk::Text(text) = &chunks[0] { - text - } else { - panic!("Expected a paragraph"); - }; - - assert_eq!(0..65, text.source_range); - assert_eq!("Some text link more text", text.contents.as_str(),); - assert_eq!( - vec![( - 10..14, - MarkdownHighlight::Style(MarkdownHighlightStyle { - link: true, - ..Default::default() - },), - )], - text.highlights - ); - assert_eq!( - vec![( - 10..14, - ParsedRegion { - code: false, - link: Some(Link::Web { - url: "https://example.com".into() - }) - } - )], - text.regions - ) - } - - #[gpui::test] - async fn test_text_with_inline_html() { - let parsed = parse("This is a paragraph with an inline HTML tag.").await; - - assert_eq!( - parsed.children, - vec![p("This is a paragraph with an inline HTML tag.", 0..63),], - ); - } - - #[gpui::test] - async fn test_raw_links_detection() { - let parsed = parse("Checkout this https://zed.dev link").await; - - assert_eq!( - parsed.children, - vec![p("Checkout this https://zed.dev link", 0..34)] - ); - } - - #[gpui::test] - async fn test_empty_image() { - let parsed = parse("![]()").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!(paragraph.len(), 0); - } - - #[gpui::test] - async fn test_image_links_detection() { - let parsed = parse("![test](https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png)").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..111, - link: Link::Web { - url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), - }, - alt_text: Some("test".into()), - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_alt_text() { - let parsed = parse("[![Zed](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json)](https://zed.dev)\n ").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..142, - link: Link::Web { - url: "https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/zed-industries/zed/main/assets/badge/v0.json".to_string(), - }, - alt_text: Some("Zed".into()), - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_without_alt_text() { - let parsed = parse("![](http://example.com/foo.png)").await; - - let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - paragraph[0], - MarkdownParagraphChunk::Image(Image { - source_range: 0..31, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - },) - ); - } - - #[gpui::test] - async fn test_image_with_alt_text_containing_formatting() { - let parsed = parse("![foo *bar* baz](http://example.com/foo.png)").await; - - let ParsedMarkdownElement::Paragraph(chunks) = &parsed.children[0] else { - panic!("Expected a paragraph"); - }; - assert_eq!( - chunks, - &[MarkdownParagraphChunk::Image(Image { - source_range: 0..44, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("foo bar baz".into()), - height: None, - width: None, - }),], - ); - } - - #[gpui::test] - async fn test_images_with_text_in_between() { - let parsed = parse( - "![foo](http://example.com/foo.png)\nLorem Ipsum\n![bar](http://example.com/bar.png)", - ) - .await; - - let chunks = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { - text - } else { - panic!("Expected a paragraph"); - }; - assert_eq!( - chunks, - &vec![ - MarkdownParagraphChunk::Image(Image { - source_range: 0..81, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("foo".into()), - height: None, - width: None, - }), - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..81, - contents: " Lorem Ipsum ".into(), - highlights: Vec::new(), - regions: Vec::new(), - }), - MarkdownParagraphChunk::Image(Image { - source_range: 0..81, - link: Link::Web { - url: "http://example.com/bar.png".to_string(), - }, - alt_text: Some("bar".into()), - height: None, - width: None, - }) - ] - ); - } - - #[test] - fn test_parse_html_element_dimension() { - // Test percentage values - assert_eq!( - MarkdownParser::parse_html_element_dimension("50%"), - Some(DefiniteLength::Fraction(0.5)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("100%"), - Some(DefiniteLength::Fraction(1.0)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("25%"), - Some(DefiniteLength::Fraction(0.25)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("0%"), - Some(DefiniteLength::Fraction(0.0)) - ); - - // Test pixel values - assert_eq!( - MarkdownParser::parse_html_element_dimension("100px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("50px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(50.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("0px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(0.0)))) - ); - - // Test values without units (should be treated as pixels) - assert_eq!( - MarkdownParser::parse_html_element_dimension("100"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.0)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("42"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) - ); - - // Test invalid values - assert_eq!( - MarkdownParser::parse_html_element_dimension("invalid"), - None - ); - assert_eq!(MarkdownParser::parse_html_element_dimension("px"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("%"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension(""), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("abc%"), None); - assert_eq!(MarkdownParser::parse_html_element_dimension("abcpx"), None); - - // Test decimal values - assert_eq!( - MarkdownParser::parse_html_element_dimension("50.5%"), - Some(DefiniteLength::Fraction(0.505)) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("100.25px"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.25)))) - ); - assert_eq!( - MarkdownParser::parse_html_element_dimension("42.0"), - Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(42.0)))) - ); - } - - #[gpui::test] - async fn test_html_unordered_list() { - let parsed = parse( - "
    -
  • Item 1
  • -
  • Item 2
  • -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] - ), - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_ordered_list() { - let parsed = parse( - "
    -
  1. Item 1
  2. -
  3. Item 2
  4. -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..82))] - ), - nested_list_item( - 0..82, - 1, - ParsedMarkdownListItemType::Ordered(2), - vec![ParsedMarkdownElement::Paragraph(text("Item 2", 0..82))] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_ordered_list() { - let parsed = parse( - "
    -
  1. Item 1
  2. -
  3. Item 2 -
      -
    1. Sub-Item 1
    2. -
    3. Sub-Item 2
    4. -
    -
  4. -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Ordered(2), - vec![ - ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Ordered(1), - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Ordered(2), - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] - ), - ] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_unordered_list() { - let parsed = parse( - "
    -
  • Item 1
  • -
  • Item 2 -
      -
    • Sub-Item 1
    • -
    • Sub-Item 2
    • -
    -
  • -
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 1, - ParsedMarkdownListItemType::Unordered, - vec![ - ParsedMarkdownElement::Paragraph(text("Item 2", 0..216)), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 1", 0..216))] - ), - nested_list_item( - 0..216, - 2, - ParsedMarkdownListItemType::Unordered, - vec![ParsedMarkdownElement::Paragraph(text("Sub-Item 2", 0..216))] - ), - ] - ), - ] - }, - parsed - ); - } - - #[gpui::test] - async fn test_inline_html_image_tag() { - let parsed = - parse("

Some text some more text

") - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Paragraph(vec![ - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..71, - contents: "Some text".into(), - highlights: Default::default(), - regions: Default::default() - }), - MarkdownParagraphChunk::Image(Image { - source_range: 0..71, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - }), - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..71, - contents: " some more text".into(), - highlights: Default::default(), - regions: Default::default() - }), - ])] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_block_quote() { - let parsed = parse( - "
-

some description

-
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![block_quote( - vec![ParsedMarkdownElement::Paragraph(text( - "some description", - 0..78 - ))], - 0..78, - )] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_nested_block_quote() { - let parsed = parse( - "
-

some description

-
-

second description

-
-
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![block_quote( - vec![ - ParsedMarkdownElement::Paragraph(text("some description", 0..179)), - block_quote( - vec![ParsedMarkdownElement::Paragraph(text( - "second description", - 0..179 - ))], - 0..179, - ) - ], - 0..179, - )] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table() { - let parsed = parse( - " - - - - - - - - - - - - - - - - -
IdName
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..366, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Id", 0..366), - ParsedMarkdownTableAlignment::Center - ), - column( - 1, - 1, - true, - text("Name ", 0..366), - ParsedMarkdownTableAlignment::Center - ) - ])], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..366), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..366), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..366), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..366), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_with_caption() { - let parsed = parse( - " - - - - - - - - - - - -
My Table
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..280, - Some(vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..280, - contents: "My Table".into(), - highlights: Default::default(), - regions: Default::default() - })]), - vec![], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..280), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..280), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..280), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..280), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_without_headings() { - let parsed = parse( - " - - - - - - - - - - -
1Chris
2Dennis
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..240, - None, - vec![], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("1", 0..240), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Chris", 0..240), - ParsedMarkdownTableAlignment::None - ) - ]), - row(vec![ - column( - 1, - 1, - false, - text("2", 0..240), - ParsedMarkdownTableAlignment::None - ), - column( - 1, - 1, - false, - text("Dennis", 0..240), - ParsedMarkdownTableAlignment::None - ) - ]), - ], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_table_without_body() { - let parsed = parse( - " - - - - - - -
IdName
", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Table(table( - 0..150, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Id", 0..150), - ParsedMarkdownTableAlignment::Center - ), - column( - 1, - 1, - true, - text("Name", 0..150), - ParsedMarkdownTableAlignment::Center - ) - ])], - vec![], - ))], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_heading_tags() { - let parsed = parse("

Heading

Heading

Heading

Heading

Heading
Heading
").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H1, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H2, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H3, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H4, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H5, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - level: HeadingLevel::H6, - source_range: 0..96, - contents: vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..96, - contents: "Heading".into(), - highlights: Vec::default(), - regions: Vec::default() - })], - }), - ], - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag() { - let parsed = parse("").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..40, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: None, - width: None, - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag_with_alt_text() { - let parsed = parse("\"Foo\"").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..50, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: Some("Foo".into()), - height: None, - width: None, - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_tag_with_height_and_width() { - let parsed = - parse("").await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..65, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), - width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_html_image_style_tag_with_height_and_width() { - let parsed = parse( - "", - ) - .await; - - assert_eq!( - ParsedMarkdown { - children: vec![ParsedMarkdownElement::Image(Image { - source_range: 0..75, - link: Link::Web { - url: "http://example.com/foo.png".to_string(), - }, - alt_text: None, - height: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(100.)))), - width: Some(DefiniteLength::Absolute(AbsoluteLength::Pixels(px(200.)))), - })] - }, - parsed - ); - } - - #[gpui::test] - async fn test_header_only_table() { - let markdown = "\ -| Header 1 | Header 2 | -|----------|----------| - -Some other content -"; - - let expected_table = table( - 0..48, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Header 1", 1..11), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - true, - text("Header 2", 12..22), - ParsedMarkdownTableAlignment::None, - ), - ])], - vec![], - ); - - assert_eq!( - parse(markdown).await.children[0], - ParsedMarkdownElement::Table(expected_table) - ); - } - - #[gpui::test] - async fn test_basic_table() { - let markdown = "\ -| Header 1 | Header 2 | -|----------|----------| -| Cell 1 | Cell 2 | -| Cell 3 | Cell 4 |"; - - let expected_table = table( - 0..95, - None, - vec![row(vec![ - column( - 1, - 1, - true, - text("Header 1", 1..11), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - true, - text("Header 2", 12..22), - ParsedMarkdownTableAlignment::None, - ), - ])], - vec![ - row(vec![ - column( - 1, - 1, - false, - text("Cell 1", 49..59), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - false, - text("Cell 2", 60..70), - ParsedMarkdownTableAlignment::None, - ), - ]), - row(vec![ - column( - 1, - 1, - false, - text("Cell 3", 73..83), - ParsedMarkdownTableAlignment::None, - ), - column( - 1, - 1, - false, - text("Cell 4", 84..94), - ParsedMarkdownTableAlignment::None, - ), - ]), - ], - ); - - assert_eq!( - parse(markdown).await.children[0], - ParsedMarkdownElement::Table(expected_table) - ); - } - - #[gpui::test] - async fn test_table_with_checkboxes() { - let markdown = "\ -| Done | Task | -|------|---------| -| [x] | Fix bug | -| [ ] | Add feature |"; - - let parsed = parse(markdown).await; - let table = match &parsed.children[0] { - ParsedMarkdownElement::Table(table) => table, - other => panic!("Expected table, got: {:?}", other), - }; - - let first_cell = &table.body[0].columns[0]; - let first_cell_text = match &first_cell.children[0] { - MarkdownParagraphChunk::Text(t) => t.contents.to_string(), - other => panic!("Expected text chunk, got: {:?}", other), - }; - assert_eq!(first_cell_text.trim(), "[x]"); - - let second_cell = &table.body[1].columns[0]; - let second_cell_text = match &second_cell.children[0] { - MarkdownParagraphChunk::Text(t) => t.contents.to_string(), - other => panic!("Expected text chunk, got: {:?}", other), - }; - assert_eq!(second_cell_text.trim(), "[ ]"); - } - - #[gpui::test] - async fn test_list_basic() { - let parsed = parse( - "\ -* Item 1 -* Item 2 -* Item 3 -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), - list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), - list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_tasks() { - let parsed = parse( - "\ -- [ ] TODO -- [x] Checked -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]), - list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_indented_task() { - let parsed = parse( - "\ -- [ ] TODO - - [x] Checked - - Unordered - 1. Number 1 - 1. Number 2 -1. Number A -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..12, 1, Task(false, 2..5), vec![p("TODO", 6..10)]), - list_item(13..26, 2, Task(true, 15..18), vec![p("Checked", 19..26)]), - list_item(29..40, 2, Unordered, vec![p("Unordered", 31..40)]), - list_item(43..54, 2, Ordered(1), vec![p("Number 1", 46..54)]), - list_item(57..68, 2, Ordered(2), vec![p("Number 2", 60..68)]), - list_item(69..80, 1, Ordered(1), vec![p("Number A", 72..80)]), - ], - ); - } - - #[gpui::test] - async fn test_list_with_linebreak_is_handled_correctly() { - let parsed = parse( - "\ -- [ ] Task 1 - -- [x] Task 2 -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]), - list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]), - ], - ); - } - - #[gpui::test] - async fn test_list_nested() { - let parsed = parse( - "\ -* Item 1 -* Item 2 -* Item 3 - -1. Hello -1. Two - 1. Three -2. Four -3. Five - -* First - 1. Hello - 1. Goodbyte - - Inner - - Inner - 2. Goodbyte - - Next item empty - - -* Last -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), - list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), - list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]), - list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]), - list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]), - list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]), - list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]), - list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]), - list_item(73..82, 1, Unordered, vec![p("First", 75..80)]), - list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]), - list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]), - list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]), - list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]), - list_item(143..159, 2, Ordered(2), vec![p("Goodbyte", 146..154)]), - list_item(160..180, 3, Unordered, vec![p("Next item empty", 165..180)]), - list_item(186..190, 3, Unordered, vec![]), - list_item(191..197, 1, Unordered, vec![p("Last", 193..197)]), - ] - ); - } - - #[gpui::test] - async fn test_list_with_nested_content() { - let parsed = parse( - "\ -* This is a list item with two paragraphs. - - This is the second paragraph in the list item. -", - ) - .await; - - assert_eq!( - parsed.children, - vec![list_item( - 0..96, - 1, - Unordered, - vec![ - p("This is a list item with two paragraphs.", 4..44), - p("This is the second paragraph in the list item.", 50..97) - ], - ),], - ); - } - - #[gpui::test] - async fn test_list_item_with_inline_html() { - let parsed = parse( - "\ -* This is a list item with an inline HTML tag. -", - ) - .await; - - assert_eq!( - parsed.children, - vec![list_item( - 0..67, - 1, - Unordered, - vec![p("This is a list item with an inline HTML tag.", 4..44),], - ),], - ); - } - - #[gpui::test] - async fn test_nested_list_with_paragraph_inside() { - let parsed = parse( - "\ -1. a - 1. b - 1. c - - text - - 1. d -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],), - list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],), - list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],), - p("text", 32..37), - list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],), - ], - ); - } - - #[gpui::test] - async fn test_list_with_leading_text() { - let parsed = parse( - "\ -* `code` -* **bold** -* [link](https://example.com) -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - list_item(0..8, 1, Unordered, vec![p("code", 2..8)]), - list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]), - list_item(20..49, 1, Unordered, vec![p("link", 22..49)],), - ], - ); - } - - #[gpui::test] - async fn test_simple_block_quote() { - let parsed = parse("> Simple block quote with **styled text**").await; - - assert_eq!( - parsed.children, - vec![block_quote( - vec![p("Simple block quote with styled text", 2..41)], - 0..41 - )] - ); - } - - #[gpui::test] - async fn test_simple_block_quote_with_multiple_lines() { - let parsed = parse( - "\ -> # Heading -> More -> text -> -> More text -", - ) - .await; - - assert_eq!( - parsed.children, - vec![block_quote( - vec![ - h1(text("Heading", 4..11), 2..12), - p("More text", 14..26), - p("More text", 30..40) - ], - 0..40 - )] - ); - } - - #[gpui::test] - async fn test_nested_block_quote() { - let parsed = parse( - "\ -> A -> -> > # B -> -> C - -More text -", - ) - .await; - - assert_eq!( - parsed.children, - vec![ - block_quote( - vec![ - p("A", 2..4), - block_quote(vec![h1(text("B", 12..13), 10..14)], 8..14), - p("C", 18..20) - ], - 0..20 - ), - p("More text", 21..31) - ] - ); - } - - #[gpui::test] - async fn test_dollar_signs_are_plain_text() { - // Dollar signs should be preserved as plain text, not treated as math delimiters. - // Regression test for https://github.com/zed-industries/zed/issues/50170 - let parsed = parse("$100$ per unit").await; - assert_eq!(parsed.children, vec![p("$100$ per unit", 0..14)]); - } - - #[gpui::test] - async fn test_dollar_signs_in_list_items() { - let parsed = parse("- $18,000 budget\n- $20,000 budget\n").await; - assert_eq!( - parsed.children, - vec![ - list_item(0..16, 1, Unordered, vec![p("$18,000 budget", 2..16)]), - list_item(17..33, 1, Unordered, vec![p("$20,000 budget", 19..33)]), - ] - ); - } - - #[gpui::test] - async fn test_code_block() { - let parsed = parse( - "\ -``` -fn main() { - return 0; -} -``` -", - ) - .await; - - assert_eq!( - parsed.children, - vec![code_block( - None, - "fn main() {\n return 0;\n}", - 0..35, - None - )] - ); - } - - #[gpui::test] - async fn test_code_block_with_language(executor: BackgroundExecutor) { - let language_registry = Arc::new(LanguageRegistry::test(executor.clone())); - language_registry.add(language::rust_lang()); - - let parsed = parse_markdown( - "\ -```rust -fn main() { - return 0; -} -``` -", - None, - Some(language_registry), - ) - .await; - - assert_eq!( - parsed.children, - vec![code_block( - Some("rust".to_string()), - "fn main() {\n return 0;\n}", - 0..39, - Some(vec![]) - )] - ); - } - - fn h1(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H1, - contents, - }) - } - - fn h2(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H2, - contents, - }) - } - - fn h3(contents: MarkdownParagraph, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Heading(ParsedMarkdownHeading { - source_range, - level: HeadingLevel::H3, - contents, - }) - } - - fn p(contents: &str, source_range: Range) -> ParsedMarkdownElement { - ParsedMarkdownElement::Paragraph(text(contents, source_range)) - } - - fn text(contents: &str, source_range: Range) -> MarkdownParagraph { - vec![MarkdownParagraphChunk::Text(ParsedMarkdownText { - highlights: Vec::new(), - regions: Vec::new(), - source_range, - contents: contents.to_string().into(), - })] - } - - fn block_quote( - children: Vec, - source_range: Range, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote { - source_range, - children, - }) - } - - fn code_block( - language: Option, - code: &str, - source_range: Range, - highlights: Option, HighlightId)>>, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::CodeBlock(ParsedMarkdownCodeBlock { - source_range, - language, - contents: code.to_string().into(), - highlights, - }) - } - - fn list_item( - source_range: Range, - depth: u16, - item_type: ParsedMarkdownListItemType, - content: Vec, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - item_type, - depth, - content, - nested: false, - }) - } - - fn nested_list_item( - source_range: Range, - depth: u16, - item_type: ParsedMarkdownListItemType, - content: Vec, - ) -> ParsedMarkdownElement { - ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { - source_range, - item_type, - depth, - content, - nested: true, - }) - } - - fn table( - source_range: Range, - caption: Option, - header: Vec, - body: Vec, - ) -> ParsedMarkdownTable { - ParsedMarkdownTable { - source_range, - header, - body, - caption, - } - } - - fn row(columns: Vec) -> ParsedMarkdownTableRow { - ParsedMarkdownTableRow { columns } - } - - fn column( - col_span: usize, - row_span: usize, - is_header: bool, - children: MarkdownParagraph, - alignment: ParsedMarkdownTableAlignment, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header, - children, - alignment, - } - } - - impl PartialEq for ParsedMarkdownTable { - fn eq(&self, other: &Self) -> bool { - self.source_range == other.source_range - && self.header == other.header - && self.body == other.body - } - } - - impl PartialEq for ParsedMarkdownText { - fn eq(&self, other: &Self) -> bool { - self.source_range == other.source_range && self.contents == other.contents - } - } -} diff --git a/crates/markdown_preview/src/markdown_preview.rs b/crates/markdown_preview/src/markdown_preview.rs index 0a657d27bc1416995d3c4df7f6793c017356fa0d..982eff7c74513cb29b368d49ecd454162f2c3913 100644 --- a/crates/markdown_preview/src/markdown_preview.rs +++ b/crates/markdown_preview/src/markdown_preview.rs @@ -1,11 +1,7 @@ use gpui::{App, actions}; use workspace::Workspace; -pub mod markdown_elements; -mod markdown_minifier; -pub mod markdown_parser; pub mod markdown_preview_view; -pub mod markdown_renderer; pub use zed_actions::preview::markdown::{OpenPreview, OpenPreviewToTheSide}; diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index b5213504e72a8e99c6405df85001fa615257dc0e..93ae57520d28e38d6ac843d33ab01581d3b8e890 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -1,46 +1,45 @@ use std::cmp::min; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use std::{ops::Range, path::PathBuf}; use anyhow::Result; use editor::scroll::Autoscroll; use editor::{Editor, EditorEvent, MultiBufferOffset, SelectionEffects}; use gpui::{ - App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, IsZero, ListOffset, ListState, ParentElement, Render, RetainAllImageCache, Styled, - Subscription, Task, WeakEntity, Window, list, + App, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageSource, InteractiveElement, + IntoElement, IsZero, Pixels, Render, Resource, RetainAllImageCache, ScrollHandle, SharedString, + SharedUri, Subscription, Task, WeakEntity, Window, point, }; use language::LanguageRegistry; +use markdown::{ + CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle, +}; use settings::Settings; use theme::ThemeSettings; use ui::{WithScrollbar, prelude::*}; +use util::normalize_path; use workspace::item::{Item, ItemHandle}; -use workspace::{Pane, Workspace}; +use workspace::{OpenOptions, OpenVisible, Pane, Workspace}; -use crate::markdown_elements::ParsedMarkdownElement; -use crate::markdown_renderer::{CheckboxClickedEvent, MermaidState}; use crate::{ - OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollPageDown, ScrollPageUp, - markdown_elements::ParsedMarkdown, - markdown_parser::parse_markdown, - markdown_renderer::{RenderContext, render_markdown_block}, + OpenFollowingPreview, OpenPreview, OpenPreviewToTheSide, ScrollDown, ScrollDownByItem, }; -use crate::{ScrollDown, ScrollDownByItem, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; +use crate::{ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ScrollUp, ScrollUpByItem}; const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); pub struct MarkdownPreviewView { workspace: WeakEntity, - image_cache: Entity, active_editor: Option, focus_handle: FocusHandle, - contents: Option, - selected_block: usize, - list_state: ListState, - language_registry: Arc, - mermaid_state: MermaidState, - parsing_markdown_task: Option>>, + markdown: Entity, + _markdown_subscription: Subscription, + active_source_index: Option, + scroll_handle: ScrollHandle, + image_cache: Entity, + base_directory: Option, + pending_update_task: Option>>, mode: MarkdownPreviewMode, } @@ -205,19 +204,35 @@ impl MarkdownPreviewView { cx: &mut Context, ) -> Entity { cx.new(|cx| { - let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); - + let markdown = cx.new(|cx| { + Markdown::new_with_options( + SharedString::default(), + Some(language_registry), + None, + MarkdownOptions { + parse_html: true, + render_mermaid_diagrams: true, + ..Default::default() + }, + cx, + ) + }); let mut this = Self { - selected_block: 0, active_editor: None, focus_handle: cx.focus_handle(), workspace: workspace.clone(), - contents: None, - list_state, - language_registry, - mermaid_state: Default::default(), - parsing_markdown_task: None, + _markdown_subscription: cx.observe( + &markdown, + |this: &mut Self, _: Entity, cx| { + this.sync_active_root_block(cx); + }, + ), + markdown, + active_source_index: None, + scroll_handle: ScrollHandle::new(), image_cache: RetainAllImageCache::new(cx), + base_directory: None, + pending_update_task: None, mode, }; @@ -280,17 +295,16 @@ impl MarkdownPreviewView { | EditorEvent::BufferEdited { .. } | EditorEvent::DirtyChanged | EditorEvent::ExcerptsEdited { .. } => { - this.parse_markdown_from_active_editor(true, window, cx); + this.update_markdown_from_active_editor(true, false, window, cx); } EditorEvent::SelectionsChanged { .. } => { - let selection_range = editor.update(cx, |editor, cx| { - editor - .selections - .last::(&editor.display_snapshot(cx)) - .range() - }); - this.selected_block = this.get_block_index_under_cursor(selection_range); - this.list_state.scroll_to_reveal_item(this.selected_block); + let (selection_start, editor_is_focused) = + editor.update(cx, |editor, cx| { + let index = Self::selected_source_index(editor, cx); + let focused = editor.focus_handle(cx).is_focused(window); + (index, focused) + }); + this.sync_preview_to_source_index(selection_start, editor_is_focused, cx); cx.notify(); } _ => {} @@ -298,27 +312,30 @@ impl MarkdownPreviewView { }, ); + self.base_directory = Self::get_folder_for_active_editor(editor.read(cx), cx); self.active_editor = Some(EditorState { editor, _subscription: subscription, }); - self.parse_markdown_from_active_editor(false, window, cx); + self.update_markdown_from_active_editor(false, true, window, cx); } - fn parse_markdown_from_active_editor( + fn update_markdown_from_active_editor( &mut self, wait_for_debounce: bool, + should_reveal: bool, window: &mut Window, cx: &mut Context, ) { if let Some(state) = &self.active_editor { // if there is already a task to update the ui and the current task is also debounced (not high priority), do nothing - if wait_for_debounce && self.parsing_markdown_task.is_some() { + if wait_for_debounce && self.pending_update_task.is_some() { return; } - self.parsing_markdown_task = Some(self.parse_markdown_in_background( + self.pending_update_task = Some(self.schedule_markdown_update( wait_for_debounce, + should_reveal, state.editor.clone(), window, cx, @@ -326,63 +343,97 @@ impl MarkdownPreviewView { } } - fn parse_markdown_in_background( + fn schedule_markdown_update( &mut self, wait_for_debounce: bool, + should_reveal_selection: bool, editor: Entity, window: &mut Window, cx: &mut Context, ) -> Task> { - let language_registry = self.language_registry.clone(); - cx.spawn_in(window, async move |view, cx| { if wait_for_debounce { // Wait for the user to stop typing cx.background_executor().timer(REPARSE_DEBOUNCE).await; } - let (contents, file_location) = view.update(cx, |_, cx| { - let editor = editor.read(cx); - let contents = editor.buffer().read(cx).snapshot(cx).text(); - let file_location = MarkdownPreviewView::get_folder_for_active_editor(editor, cx); - (contents, file_location) - })?; + let editor_clone = editor.clone(); + let update = view.update(cx, |view, cx| { + let is_active_editor = view + .active_editor + .as_ref() + .is_some_and(|active_editor| active_editor.editor == editor_clone); + if !is_active_editor { + return None; + } - let parsing_task = cx.background_spawn(async move { - parse_markdown(&contents, file_location, Some(language_registry)).await - }); - let contents = parsing_task.await; + let (contents, selection_start) = editor_clone.update(cx, |editor, cx| { + let contents = editor.buffer().read(cx).snapshot(cx).text(); + let selection_start = Self::selected_source_index(editor, cx); + (contents, selection_start) + }); + Some((SharedString::from(contents), selection_start)) + })?; view.update(cx, move |view, cx| { - view.mermaid_state.update(&contents, cx); - let markdown_blocks_count = contents.children.len(); - view.contents = Some(contents); - let scroll_top = view.list_state.logical_scroll_top(); - view.list_state.reset(markdown_blocks_count); - view.list_state.scroll_to(scroll_top); - view.parsing_markdown_task = None; + if let Some((contents, selection_start)) = update { + view.markdown.update(cx, |markdown, cx| { + markdown.reset(contents, cx); + }); + view.sync_preview_to_source_index(selection_start, should_reveal_selection, cx); + } + view.pending_update_task = None; cx.notify(); }) }) } - fn move_cursor_to_block( - &self, - window: &mut Window, + fn selected_source_index(editor: &Editor, cx: &mut App) -> usize { + editor + .selections + .last::(&editor.display_snapshot(cx)) + .range() + .start + .0 + } + + fn sync_preview_to_source_index( + &mut self, + source_index: usize, + reveal: bool, cx: &mut Context, - selection: Range, ) { - if let Some(state) = &self.active_editor { - state.editor.update(cx, |editor, cx| { - editor.change_selections( - SelectionEffects::scroll(Autoscroll::center()), - window, - cx, - |selections| selections.select_ranges(vec![selection]), - ); - window.focus(&editor.focus_handle(cx), cx); - }); - } + self.active_source_index = Some(source_index); + self.sync_active_root_block(cx); + self.markdown.update(cx, |markdown, cx| { + if reveal { + markdown.request_autoscroll_to_source_index(source_index, cx); + } + }); + } + + fn sync_active_root_block(&mut self, cx: &mut Context) { + self.markdown.update(cx, |markdown, cx| { + markdown.set_active_root_for_source_index(self.active_source_index, cx); + }); + } + + fn move_cursor_to_source_index( + editor: &Entity, + source_index: usize, + window: &mut Window, + cx: &mut App, + ) { + editor.update(cx, |editor, cx| { + let selection = MultiBufferOffset(source_index)..MultiBufferOffset(source_index); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges(vec![selection]), + ); + window.focus(&editor.focus_handle(cx), cx); + }); } /// The absolute path of the file that is currently being previewed. @@ -398,52 +449,24 @@ impl MarkdownPreviewView { } } - fn get_block_index_under_cursor(&self, selection_range: Range) -> usize { - let mut block_index = None; - let cursor = selection_range.start.0; - - let mut last_end = 0; - if let Some(content) = &self.contents { - for (i, block) in content.children.iter().enumerate() { - let Some(Range { start, end }) = block.source_range() else { - continue; - }; - - // Check if the cursor is between the last block and the current block - if last_end <= cursor && cursor < start { - block_index = Some(i.saturating_sub(1)); - break; - } - - if start <= cursor && end >= cursor { - block_index = Some(i); - break; - } - last_end = end; - } - - if block_index.is_none() && last_end < cursor { - block_index = Some(content.children.len().saturating_sub(1)); - } - } - - block_index.unwrap_or_default() + fn line_scroll_amount(&self, cx: &App) -> Pixels { + let settings = ThemeSettings::get_global(cx); + settings.buffer_font_size(cx) * settings.buffer_line_height.value() } - fn should_apply_padding_between( - current_block: &ParsedMarkdownElement, - next_block: Option<&ParsedMarkdownElement>, - ) -> bool { - !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false)) + fn scroll_by_amount(&self, distance: Pixels) { + let offset = self.scroll_handle.offset(); + self.scroll_handle + .set_offset(point(offset.x, offset.y - distance)); } fn scroll_page_up(&mut self, _: &ScrollPageUp, _window: &mut Window, cx: &mut Context) { - let viewport_height = self.list_state.viewport_bounds().size.height; + let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } - self.list_state.scroll_by(-viewport_height); + self.scroll_by_amount(-viewport_height); cx.notify(); } @@ -453,35 +476,49 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let viewport_height = self.list_state.viewport_bounds().size.height; + let viewport_height = self.scroll_handle.bounds().size.height; if viewport_height.is_zero() { return; } - self.list_state.scroll_by(viewport_height); + self.scroll_by_amount(viewport_height); cx.notify(); } fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { let item_height = bounds.size.height; // Scroll no more than the rough equivalent of a large headline let max_height = window.rem_size() * 2; let scroll_height = min(item_height, max_height); - self.list_state.scroll_by(-scroll_height); + self.scroll_by_amount(-scroll_height); + } else { + let scroll_height = self.line_scroll_amount(cx); + if !scroll_height.is_zero() { + self.scroll_by_amount(-scroll_height); + } } cx.notify(); } fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { let item_height = bounds.size.height; // Scroll no more than the rough equivalent of a large headline let max_height = window.rem_size() * 2; let scroll_height = min(item_height, max_height); - self.list_state.scroll_by(scroll_height); + self.scroll_by_amount(scroll_height); + } else { + let scroll_height = self.line_scroll_amount(cx); + if !scroll_height.is_zero() { + self.scroll_by_amount(scroll_height); + } } cx.notify(); } @@ -492,9 +529,11 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { - self.list_state.scroll_by(-bounds.size.height); + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { + self.scroll_by_amount(-bounds.size.height); } cx.notify(); } @@ -505,18 +544,17 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let scroll_top = self.list_state.logical_scroll_top(); - if let Some(bounds) = self.list_state.bounds_for_item(scroll_top.item_ix) { - self.list_state.scroll_by(bounds.size.height); + if let Some(bounds) = self + .scroll_handle + .bounds_for_item(self.scroll_handle.top_item()) + { + self.scroll_by_amount(bounds.size.height); } cx.notify(); } fn scroll_to_top(&mut self, _: &ScrollToTop, _window: &mut Window, cx: &mut Context) { - self.list_state.scroll_to(ListOffset { - item_ix: 0, - offset_in_item: px(0.), - }); + self.scroll_handle.scroll_to_item(0); cx.notify(); } @@ -526,19 +564,157 @@ impl MarkdownPreviewView { _window: &mut Window, cx: &mut Context, ) { - let count = self.list_state.item_count(); - if count > 0 { - self.list_state.scroll_to(ListOffset { - item_ix: count - 1, - offset_in_item: px(0.), - }); - } + self.scroll_handle.scroll_to_bottom(); cx.notify(); } + + fn render_markdown_element( + &self, + window: &mut Window, + cx: &mut Context, + ) -> MarkdownElement { + let workspace = self.workspace.clone(); + let base_directory = self.base_directory.clone(); + let active_editor = self + .active_editor + .as_ref() + .map(|state| state.editor.clone()); + + let mut markdown_element = MarkdownElement::new( + self.markdown.clone(), + MarkdownStyle::themed(MarkdownFont::Editor, window, cx), + ) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: true, + border: false, + }) + .scroll_handle(self.scroll_handle.clone()) + .show_root_block_markers() + .image_resolver({ + let base_directory = self.base_directory.clone(); + move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref()) + }) + .on_url_click(move |url, window, cx| { + open_preview_url(url, base_directory.clone(), &workspace, window, cx); + }); + + if let Some(active_editor) = active_editor { + let editor_for_checkbox = active_editor.clone(); + let view_handle = cx.entity().downgrade(); + markdown_element = markdown_element + .on_source_click(move |source_index, click_count, window, cx| { + if click_count == 2 { + Self::move_cursor_to_source_index(&active_editor, source_index, window, cx); + true + } else { + false + } + }) + .on_checkbox_toggle(move |source_range, new_checked, window, cx| { + let task_marker = if new_checked { "[x]" } else { "[ ]" }; + editor_for_checkbox.update(cx, |editor, cx| { + editor.edit( + [( + MultiBufferOffset(source_range.start) + ..MultiBufferOffset(source_range.end), + task_marker, + )], + cx, + ); + }); + if let Some(view) = view_handle.upgrade() { + cx.update_entity(&view, |this, cx| { + this.update_markdown_from_active_editor(false, false, window, cx); + }); + } + }); + } + + markdown_element + } +} + +fn open_preview_url( + url: SharedString, + base_directory: Option, + workspace: &WeakEntity, + window: &mut Window, + cx: &mut App, +) { + if let Some(path) = resolve_preview_path(url.as_ref(), base_directory.as_deref()) + && let Some(workspace) = workspace.upgrade() + { + let _ = workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path( + normalize_path(path.as_path()), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) + .detach(); + }); + return; + } + + cx.open_url(url.as_ref()); +} + +fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option { + if url.starts_with("http://") || url.starts_with("https://") { + return None; + } + + let decoded_url = urlencoding::decode(url) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| url.to_string()); + let candidate = PathBuf::from(&decoded_url); + + if candidate.is_absolute() && candidate.exists() { + return Some(candidate); + } + + let base_directory = base_directory?; + let resolved = base_directory.join(decoded_url); + if resolved.exists() { + Some(resolved) + } else { + None + } +} + +fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option { + if dest_url.starts_with("data:") { + return None; + } + + if dest_url.starts_with("http://") || dest_url.starts_with("https://") { + return Some(ImageSource::Resource(Resource::Uri(SharedUri::from( + dest_url.to_string(), + )))); + } + + let decoded = urlencoding::decode(dest_url) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| dest_url.to_string()); + + let path = if Path::new(&decoded).is_absolute() { + PathBuf::from(decoded) + } else { + base_directory?.join(decoded) + }; + + Some(ImageSource::Resource(Resource::Path(Arc::from( + path.as_path(), + )))) } impl Focusable for MarkdownPreviewView { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } @@ -572,10 +748,7 @@ impl Item for MarkdownPreviewView { impl Render for MarkdownPreviewView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let buffer_size = ThemeSettings::get_global(cx).buffer_font_size(cx); - let buffer_line_height = ThemeSettings::get_global(cx).buffer_line_height; - - v_flex() + div() .image_cache(self.image_cache.clone()) .id("MarkdownPreview") .key_context("MarkdownPreview") @@ -590,113 +763,65 @@ impl Render for MarkdownPreviewView { .on_action(cx.listener(MarkdownPreviewView::scroll_to_bottom)) .size_full() .bg(cx.theme().colors().editor_background) - .p_4() - .text_size(buffer_size) - .line_height(relative(buffer_line_height.value())) - .child(div().flex_grow().map(|this| { - this.child( - list( - self.list_state.clone(), - cx.processor(|this, ix, window, cx| { - let Some(contents) = &this.contents else { - return div().into_any(); - }; - - let mut render_cx = RenderContext::new( - Some(this.workspace.clone()), - &this.mermaid_state, - window, - cx, - ) - .with_checkbox_clicked_callback(cx.listener( - move |this, e: &CheckboxClickedEvent, window, cx| { - if let Some(editor) = - this.active_editor.as_ref().map(|s| s.editor.clone()) - { - editor.update(cx, |editor, cx| { - let task_marker = - if e.checked() { "[x]" } else { "[ ]" }; - - editor.edit( - [( - MultiBufferOffset(e.source_range().start) - ..MultiBufferOffset(e.source_range().end), - task_marker, - )], - cx, - ); - }); - this.parse_markdown_from_active_editor(false, window, cx); - cx.notify(); - } - }, - )); - - let block = contents.children.get(ix).unwrap(); - let rendered_block = render_markdown_block(block, &mut render_cx); - - let should_apply_padding = Self::should_apply_padding_between( - block, - contents.children.get(ix + 1), - ); - - let selected_block = this.selected_block; - let scaled_rems = render_cx.scaled_rems(1.0); - div() - .id(ix) - .when(should_apply_padding, |this| { - this.pb(render_cx.scaled_rems(0.75)) - }) - .group("markdown-block") - .on_click(cx.listener( - move |this, event: &ClickEvent, window, cx| { - if event.click_count() == 2 - && let Some(source_range) = this - .contents - .as_ref() - .and_then(|c| c.children.get(ix)) - .and_then(|block: &ParsedMarkdownElement| { - block.source_range() - }) - { - this.move_cursor_to_block( - window, - cx, - MultiBufferOffset(source_range.start) - ..MultiBufferOffset(source_range.start), - ); - } - }, - )) - .map(move |container| { - let indicator = div() - .h_full() - .w(px(4.0)) - .when(ix == selected_block, |this| { - this.bg(cx.theme().colors().border) - }) - .group_hover("markdown-block", |s| { - if ix == selected_block { - s - } else { - s.bg(cx.theme().colors().border_variant) - } - }) - .rounded_xs(); - - container.child( - div() - .relative() - .child(div().pl(scaled_rems).child(rendered_block)) - .child(indicator.absolute().left_0().top_0()), - ) - }) - .into_any() - }), - ) - .size_full(), - ) - })) - .vertical_scrollbar_for(&self.list_state, window, cx) + .child( + div() + .id("markdown-preview-scroll-container") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .p_4() + .child(self.render_markdown_element(window, cx)), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } +} + +#[cfg(test)] +mod tests { + use anyhow::Result; + use std::fs; + use tempfile::TempDir; + + use super::resolve_preview_path; + + #[test] + fn resolves_relative_preview_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let base_directory = temp_dir.path(); + let file = base_directory.join("notes.md"); + fs::write(&file, "# Notes")?; + + assert_eq!( + resolve_preview_path("notes.md", Some(base_directory)), + Some(file) + ); + assert_eq!( + resolve_preview_path("nonexistent.md", Some(base_directory)), + None + ); + assert_eq!(resolve_preview_path("notes.md", None), None); + + Ok(()) + } + + #[test] + fn resolves_urlencoded_preview_paths() -> Result<()> { + let temp_dir = TempDir::new()?; + let base_directory = temp_dir.path(); + let file = base_directory.join("release notes.md"); + fs::write(&file, "# Release Notes")?; + + assert_eq!( + resolve_preview_path("release%20notes.md", Some(base_directory)), + Some(file) + ); + + Ok(()) + } + + #[test] + fn does_not_treat_web_links_as_preview_paths() { + assert_eq!(resolve_preview_path("https://zed.dev", None), None); + assert_eq!(resolve_preview_path("http://example.com", None), None); } } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs deleted file mode 100644 index 6f7a28db775868e185fe183a1e35d1f7b8eaa662..0000000000000000000000000000000000000000 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ /dev/null @@ -1,1517 +0,0 @@ -use crate::{ - markdown_elements::{ - HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, - ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, - ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, - ParsedMarkdownMermaidDiagram, ParsedMarkdownMermaidDiagramContents, ParsedMarkdownTable, - ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, - }, - markdown_preview_view::MarkdownPreviewView, -}; -use collections::HashMap; -use gpui::{ - AbsoluteLength, Animation, AnimationExt, AnyElement, App, AppContext as _, Context, Div, - Element, ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, - Keystroke, Modifiers, ParentElement, Render, RenderImage, Resource, SharedString, Styled, - StyledText, Task, TextStyle, WeakEntity, Window, div, img, pulsating_between, rems, -}; - -use settings::Settings; -use std::{ - ops::{Mul, Range}, - sync::{Arc, OnceLock}, - time::Duration, - vec, -}; -use theme::{ActiveTheme, SyntaxTheme, ThemeSettings}; -use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container}; -use util::normalize_path; -use workspace::{OpenOptions, OpenVisible, Workspace}; - -pub struct CheckboxClickedEvent { - pub checked: bool, - pub source_range: Range, -} - -impl CheckboxClickedEvent { - pub fn source_range(&self) -> Range { - self.source_range.clone() - } - - pub fn checked(&self) -> bool { - self.checked - } -} - -type CheckboxClickedCallback = Arc>; - -type MermaidDiagramCache = HashMap; - -#[derive(Default)] -pub(crate) struct MermaidState { - cache: MermaidDiagramCache, - order: Vec, -} - -impl MermaidState { - fn get_fallback_image( - idx: usize, - old_order: &[ParsedMarkdownMermaidDiagramContents], - new_order_len: usize, - cache: &MermaidDiagramCache, - ) -> Option> { - // When the diagram count changes e.g. addition or removal, positional matching - // is unreliable since a new diagram at index i likely doesn't correspond to the - // old diagram at index i. We only allow fallbacks when counts match, which covers - // the common case of editing a diagram in-place. - // - // Swapping two diagrams would briefly show the stale fallback, but that's an edge - // case we don't handle. - if old_order.len() != new_order_len { - return None; - } - old_order.get(idx).and_then(|old_content| { - cache.get(old_content).and_then(|old_cached| { - old_cached - .render_image - .get() - .and_then(|result| result.as_ref().ok().cloned()) - // Chain fallbacks for rapid edits. - .or_else(|| old_cached.fallback_image.clone()) - }) - }) - } - - pub(crate) fn update( - &mut self, - parsed: &ParsedMarkdown, - cx: &mut Context, - ) { - use crate::markdown_elements::ParsedMarkdownElement; - use std::collections::HashSet; - - let mut new_order = Vec::new(); - for element in parsed.children.iter() { - if let ParsedMarkdownElement::MermaidDiagram(mermaid_diagram) = element { - new_order.push(mermaid_diagram.contents.clone()); - } - } - - for (idx, new_content) in new_order.iter().enumerate() { - if !self.cache.contains_key(new_content) { - let fallback = - Self::get_fallback_image(idx, &self.order, new_order.len(), &self.cache); - self.cache.insert( - new_content.clone(), - CachedMermaidDiagram::new(new_content.clone(), fallback, cx), - ); - } - } - - let new_order_set: HashSet<_> = new_order.iter().cloned().collect(); - self.cache - .retain(|content, _| new_order_set.contains(content)); - self.order = new_order; - } -} - -pub(crate) struct CachedMermaidDiagram { - pub(crate) render_image: Arc>>>, - pub(crate) fallback_image: Option>, - _task: Task<()>, -} - -impl CachedMermaidDiagram { - pub(crate) fn new( - contents: ParsedMarkdownMermaidDiagramContents, - fallback_image: Option>, - cx: &mut Context, - ) -> Self { - let result = Arc::new(OnceLock::>>::new()); - let result_clone = result.clone(); - let svg_renderer = cx.svg_renderer(); - - let _task = cx.spawn(async move |this, cx| { - let value = cx - .background_spawn(async move { - let svg_string = mermaid_rs_renderer::render(&contents.contents)?; - let scale = contents.scale as f32 / 100.0; - svg_renderer - .render_single_frame(svg_string.as_bytes(), scale, true) - .map_err(|e| anyhow::anyhow!("{}", e)) - }) - .await; - let _ = result_clone.set(value); - this.update(cx, |_, cx| { - cx.notify(); - }) - .ok(); - }); - - Self { - render_image: result, - fallback_image, - _task, - } - } - - #[cfg(test)] - fn new_for_test( - render_image: Option>, - fallback_image: Option>, - ) -> Self { - let result = Arc::new(OnceLock::new()); - if let Some(img) = render_image { - let _ = result.set(Ok(img)); - } - Self { - render_image: result, - fallback_image, - _task: Task::ready(()), - } - } -} -#[derive(Clone)] -pub struct RenderContext<'a> { - workspace: Option>, - next_id: usize, - buffer_font_family: SharedString, - buffer_text_style: TextStyle, - text_style: TextStyle, - border_color: Hsla, - title_bar_background_color: Hsla, - panel_background_color: Hsla, - text_color: Hsla, - link_color: Hsla, - window_rem_size: Pixels, - text_muted_color: Hsla, - code_block_background_color: Hsla, - code_span_background_color: Hsla, - syntax_theme: Arc, - indent: usize, - checkbox_clicked_callback: Option, - is_last_child: bool, - mermaid_state: &'a MermaidState, -} - -impl<'a> RenderContext<'a> { - pub(crate) fn new( - workspace: Option>, - mermaid_state: &'a MermaidState, - window: &mut Window, - cx: &mut App, - ) -> Self { - let theme = cx.theme().clone(); - - let settings = ThemeSettings::get_global(cx); - let buffer_font_family = settings.buffer_font.family.clone(); - let buffer_font_features = settings.buffer_font.features.clone(); - let mut buffer_text_style = window.text_style(); - buffer_text_style.font_family = buffer_font_family.clone(); - buffer_text_style.font_features = buffer_font_features; - buffer_text_style.font_size = AbsoluteLength::from(settings.buffer_font_size(cx)); - - RenderContext { - workspace, - next_id: 0, - indent: 0, - buffer_font_family, - buffer_text_style, - text_style: window.text_style(), - syntax_theme: theme.syntax().clone(), - border_color: theme.colors().border, - title_bar_background_color: theme.colors().title_bar_background, - panel_background_color: theme.colors().panel_background, - text_color: theme.colors().text, - link_color: theme.colors().text_accent, - window_rem_size: window.rem_size(), - text_muted_color: theme.colors().text_muted, - code_block_background_color: theme.colors().surface_background, - code_span_background_color: theme.colors().editor_document_highlight_read_background, - checkbox_clicked_callback: None, - is_last_child: false, - mermaid_state, - } - } - - pub fn with_checkbox_clicked_callback( - mut self, - callback: impl Fn(&CheckboxClickedEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.checkbox_clicked_callback = Some(Arc::new(Box::new(callback))); - self - } - - fn next_id(&mut self, span: &Range) -> ElementId { - let id = format!("markdown-{}-{}-{}", self.next_id, span.start, span.end); - self.next_id += 1; - ElementId::from(SharedString::from(id)) - } - - /// HACK: used to have rems relative to buffer font size, so that things scale appropriately as - /// buffer font size changes. The callees of this function should be reimplemented to use real - /// relative sizing once that is implemented in GPUI - pub fn scaled_rems(&self, rems: f32) -> Rems { - self.buffer_text_style - .font_size - .to_rems(self.window_rem_size) - .mul(rems) - } - - /// This ensures that children inside of block quotes - /// have padding between them. - /// - /// For example, for this markdown: - /// - /// ```markdown - /// > This is a block quote. - /// > - /// > And this is the next paragraph. - /// ``` - /// - /// We give padding between "This is a block quote." - /// and "And this is the next paragraph." - fn with_common_p(&self, element: Div) -> Div { - if self.indent > 0 && !self.is_last_child { - element.pb(self.scaled_rems(0.75)) - } else { - element - } - } - - /// The is used to indicate that the current element is the last child or not of its parent. - /// - /// Then we can avoid adding padding to the bottom of the last child. - fn with_last_child(&mut self, is_last: bool, render: R) -> AnyElement - where - R: FnOnce(&mut Self) -> AnyElement, - { - self.is_last_child = is_last; - let element = render(self); - self.is_last_child = false; - element - } -} - -pub fn render_parsed_markdown( - parsed: &ParsedMarkdown, - workspace: Option>, - window: &mut Window, - cx: &mut App, -) -> Div { - let cache = Default::default(); - let mut cx = RenderContext::new(workspace, &cache, window, cx); - - v_flex().gap_3().children( - parsed - .children - .iter() - .map(|block| render_markdown_block(block, &mut cx)), - ) -} -pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderContext) -> AnyElement { - use ParsedMarkdownElement::*; - match block { - Paragraph(text) => render_markdown_paragraph(text, cx), - Heading(heading) => render_markdown_heading(heading, cx), - ListItem(list_item) => render_markdown_list_item(list_item, cx), - Table(table) => render_markdown_table(table, cx), - BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), - CodeBlock(code_block) => render_markdown_code_block(code_block, cx), - MermaidDiagram(mermaid) => render_mermaid_diagram(mermaid, cx), - HorizontalRule(_) => render_markdown_rule(cx), - Image(image) => render_markdown_image(image, cx), - } -} - -fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContext) -> AnyElement { - let size = match parsed.level { - HeadingLevel::H1 => 2., - HeadingLevel::H2 => 1.5, - HeadingLevel::H3 => 1.25, - HeadingLevel::H4 => 1., - HeadingLevel::H5 => 0.875, - HeadingLevel::H6 => 0.85, - }; - - let text_size = cx.scaled_rems(size); - - // was `DefiniteLength::from(text_size.mul(1.25))` - // let line_height = DefiniteLength::from(text_size.mul(1.25)); - let line_height = text_size * 1.25; - - // was `rems(0.15)` - // let padding_top = cx.scaled_rems(0.15); - let padding_top = rems(0.15); - - // was `.pb_1()` = `rems(0.25)` - // let padding_bottom = cx.scaled_rems(0.25); - let padding_bottom = rems(0.25); - - let color = match parsed.level { - HeadingLevel::H6 => cx.text_muted_color, - _ => cx.text_color, - }; - div() - .line_height(line_height) - .text_size(text_size) - .text_color(color) - .pt(padding_top) - .pb(padding_bottom) - .children(render_markdown_text(&parsed.contents, cx)) - .whitespace_normal() - .into_any() -} - -fn render_markdown_list_item( - parsed: &ParsedMarkdownListItem, - cx: &mut RenderContext, -) -> AnyElement { - use ParsedMarkdownListItemType::*; - let depth = parsed.depth.saturating_sub(1) as usize; - - let bullet = match &parsed.item_type { - Ordered(order) => list_item_prefix(*order as usize, true, depth).into_any_element(), - Unordered => list_item_prefix(1, false, depth).into_any_element(), - Task(checked, range) => div() - .id(cx.next_id(range)) - .mt(cx.scaled_rems(3.0 / 16.0)) - .child( - MarkdownCheckbox::new( - "checkbox", - if *checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - cx.clone(), - ) - .when_some( - cx.checkbox_clicked_callback.clone(), - |this, callback| { - this.on_click({ - let range = range.clone(); - move |selection, window, cx| { - let checked = match selection { - ToggleState::Selected => true, - ToggleState::Unselected => false, - _ => return, - }; - - if window.modifiers().secondary() { - callback( - &CheckboxClickedEvent { - checked, - source_range: range.clone(), - }, - window, - cx, - ); - } - } - }) - }, - ), - ) - .hover(|s| s.cursor_pointer()) - .tooltip(|_, cx| { - InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into() - }) - .into_any_element(), - }; - let bullet = div().mr(cx.scaled_rems(0.5)).child(bullet); - - let contents: Vec = parsed - .content - .iter() - .map(|c| render_markdown_block(c, cx)) - .collect(); - - let item = h_flex() - .when(!parsed.nested, |this| this.pl(cx.scaled_rems(depth as f32))) - .when(parsed.nested && depth > 0, |this| this.ml_neg_1p5()) - .items_start() - .children(vec![ - bullet, - v_flex() - .children(contents) - .when(!parsed.nested, |this| this.gap(cx.scaled_rems(1.0))) - .pr(cx.scaled_rems(1.0)) - .w_full(), - ]); - - cx.with_common_p(item).into_any() -} - -/// # MarkdownCheckbox /// -/// HACK: Copied from `ui/src/components/toggle.rs` to deal with scaling issues in markdown preview -/// changes should be integrated into `Checkbox` in `toggle.rs` while making sure checkboxes elsewhere in the -/// app are not visually affected -#[derive(gpui::IntoElement)] -struct MarkdownCheckbox { - id: ElementId, - toggle_state: ToggleState, - disabled: bool, - placeholder: bool, - on_click: Option>, - filled: bool, - style: ui::ToggleStyle, - tooltip: Option gpui::AnyView>>, - label: Option, - base_rem: Rems, -} - -impl MarkdownCheckbox { - /// Creates a new [`Checkbox`]. - fn new(id: impl Into, checked: ToggleState, render_cx: RenderContext) -> Self { - Self { - id: id.into(), - toggle_state: checked, - disabled: false, - on_click: None, - filled: false, - style: ui::ToggleStyle::default(), - tooltip: None, - label: None, - placeholder: false, - base_rem: render_cx.scaled_rems(1.0), - } - } - - /// Binds a handler to the [`Checkbox`] that will be called when clicked. - fn on_click(mut self, handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static) -> Self { - self.on_click = Some(Box::new(handler)); - self - } - - fn bg_color(&self, cx: &App) -> Hsla { - let style = self.style.clone(); - match (style, self.filled) { - (ui::ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background, - (ui::ToggleStyle::Ghost, true) => cx.theme().colors().element_background, - (ui::ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(), - (ui::ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx), - (ui::ToggleStyle::Custom(_), false) => gpui::transparent_black(), - (ui::ToggleStyle::Custom(color), true) => color.opacity(0.2), - } - } - - fn border_color(&self, cx: &App) -> Hsla { - if self.disabled { - return cx.theme().colors().border_variant; - } - - match self.style.clone() { - ui::ToggleStyle::Ghost => cx.theme().colors().border, - ui::ToggleStyle::ElevationBased(_) => cx.theme().colors().border, - ui::ToggleStyle::Custom(color) => color.opacity(0.3), - } - } -} - -impl gpui::RenderOnce for MarkdownCheckbox { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let group_id = format!("checkbox_group_{:?}", self.id); - let color = if self.disabled { - Color::Disabled - } else { - Color::Selected - }; - let icon_size_small = IconSize::Custom(self.base_rem.mul(14. / 16.)); // was IconSize::Small - let icon = match self.toggle_state { - ToggleState::Selected => { - if self.placeholder { - None - } else { - Some( - ui::Icon::new(IconName::Check) - .size(icon_size_small) - .color(color), - ) - } - } - ToggleState::Indeterminate => Some( - ui::Icon::new(IconName::Dash) - .size(icon_size_small) - .color(color), - ), - ToggleState::Unselected => None, - }; - - let bg_color = self.bg_color(cx); - let border_color = self.border_color(cx); - let hover_border_color = border_color.alpha(0.7); - - let size = self.base_rem.mul(1.25); // was Self::container_size(); (20px) - - let checkbox = h_flex() - .id(self.id.clone()) - .justify_center() - .items_center() - .size(size) - .group(group_id.clone()) - .child( - div() - .flex() - .flex_none() - .justify_center() - .items_center() - .m(self.base_rem.mul(0.25)) // was .m_1 - .size(self.base_rem.mul(1.0)) // was .size_4 - .rounded(self.base_rem.mul(0.125)) // was .rounded_xs - .border_1() - .bg(bg_color) - .border_color(border_color) - .when(self.disabled, |this| this.cursor_not_allowed()) - .when(self.disabled, |this| { - this.bg(cx.theme().colors().element_disabled.opacity(0.6)) - }) - .when(!self.disabled, |this| { - this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color)) - }) - .when(self.placeholder, |this| { - this.child( - div() - .flex_none() - .rounded_full() - .bg(color.color(cx).alpha(0.5)) - .size(self.base_rem.mul(0.25)), // was .size_1 - ) - }) - .children(icon), - ); - - h_flex() - .id(self.id) - .gap(ui::DynamicSpacing::Base06.rems(cx)) - .child(checkbox) - .when_some( - self.on_click.filter(|_| !self.disabled), - |this, on_click| { - this.on_click(move |_, window, cx| { - on_click(&self.toggle_state.inverse(), window, cx) - }) - }, - ) - // TODO: Allow label size to be different from default. - // TODO: Allow label color to be different from muted. - .when_some(self.label, |this, label| { - this.child(Label::new(label).color(Color::Muted)) - }) - .when_some(self.tooltip, |this, tooltip| { - this.tooltip(move |window, cx| tooltip(window, cx)) - }) - } -} - -fn calculate_table_columns_count(rows: &Vec) -> usize { - let mut actual_column_count = 0; - for row in rows { - actual_column_count = actual_column_count.max( - row.columns - .iter() - .map(|column| column.col_span) - .sum::(), - ); - } - actual_column_count -} - -fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement { - let actual_header_column_count = calculate_table_columns_count(&parsed.header); - let actual_body_column_count = calculate_table_columns_count(&parsed.body); - let max_column_count = std::cmp::max(actual_header_column_count, actual_body_column_count); - - let total_rows = parsed.header.len() + parsed.body.len(); - - // Track which grid cells are occupied by spanning cells - let mut grid_occupied = vec![vec![false; max_column_count]; total_rows]; - - let mut cells = Vec::with_capacity(total_rows * max_column_count); - - for (row_idx, row) in parsed.header.iter().chain(parsed.body.iter()).enumerate() { - let mut col_idx = 0; - - for cell in row.columns.iter() { - // Skip columns occupied by row-spanning cells from previous rows - while col_idx < max_column_count && grid_occupied[row_idx][col_idx] { - col_idx += 1; - } - - if col_idx >= max_column_count { - break; - } - - let container = match cell.alignment { - ParsedMarkdownTableAlignment::Left | ParsedMarkdownTableAlignment::None => div(), - ParsedMarkdownTableAlignment::Center => v_flex().items_center(), - ParsedMarkdownTableAlignment::Right => v_flex().items_end(), - }; - - let cell_element = container - .col_span(cell.col_span.min(max_column_count - col_idx) as u16) - .row_span(cell.row_span.min(total_rows - row_idx) as u16) - .children(render_markdown_text(&cell.children, cx)) - .px_2() - .py_1() - .when(col_idx > 0, |this| this.border_l_1()) - .when(row_idx > 0, |this| this.border_t_1()) - .border_color(cx.border_color) - .when(cell.is_header, |this| { - this.bg(cx.title_bar_background_color) - }) - .when(cell.row_span > 1, |this| this.justify_center()) - .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); - - cells.push(cell_element); - - // Mark grid positions as occupied for row-spanning cells - for r in 0..cell.row_span { - for c in 0..cell.col_span { - if row_idx + r < total_rows && col_idx + c < max_column_count { - grid_occupied[row_idx + r][col_idx + c] = true; - } - } - } - - col_idx += cell.col_span; - } - - // Fill remaining columns with empty cells if needed - while col_idx < max_column_count { - if grid_occupied[row_idx][col_idx] { - col_idx += 1; - continue; - } - - let empty_cell = div() - .when(col_idx > 0, |this| this.border_l_1()) - .when(row_idx > 0, |this| this.border_t_1()) - .border_color(cx.border_color) - .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); - - cells.push(empty_cell); - col_idx += 1; - } - } - - cx.with_common_p(v_flex().items_start()) - .when_some(parsed.caption.as_ref(), |this, caption| { - this.children(render_markdown_text(caption, cx)) - }) - .child( - div() - .rounded_sm() - .overflow_hidden() - .border_1() - .border_color(cx.border_color) - .min_w_0() - .grid() - .grid_cols_max_content(max_column_count as u16) - .children(cells), - ) - .into_any() -} - -fn render_markdown_block_quote( - parsed: &ParsedMarkdownBlockQuote, - cx: &mut RenderContext, -) -> AnyElement { - cx.indent += 1; - - let children: Vec = parsed - .children - .iter() - .enumerate() - .map(|(ix, child)| { - cx.with_last_child(ix + 1 == parsed.children.len(), |cx| { - render_markdown_block(child, cx) - }) - }) - .collect(); - - cx.indent -= 1; - - cx.with_common_p(div()) - .child( - div() - .border_l_4() - .border_color(cx.border_color) - .pl_3() - .children(children), - ) - .into_any() -} - -fn render_markdown_code_block( - parsed: &ParsedMarkdownCodeBlock, - cx: &mut RenderContext, -) -> AnyElement { - let body = if let Some(highlights) = parsed.highlights.as_ref() { - StyledText::new(parsed.contents.clone()).with_default_highlights( - &cx.buffer_text_style, - highlights.iter().filter_map(|(range, highlight_id)| { - cx.syntax_theme - .get(*highlight_id) - .cloned() - .map(|style| (range.clone(), style)) - }), - ) - } else { - StyledText::new(parsed.contents.clone()) - }; - - let copy_block_button = CopyButton::new("copy-codeblock", parsed.contents.clone()) - .tooltip_label("Copy Codeblock") - .visible_on_hover("markdown-block"); - - let font = gpui::Font { - family: cx.buffer_font_family.clone(), - features: cx.buffer_text_style.font_features.clone(), - ..Default::default() - }; - - cx.with_common_p(div()) - .font(font) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child(body) - .child( - div() - .h_flex() - .absolute() - .right_1() - .top_1() - .child(copy_block_button), - ) - .into_any() -} - -fn render_mermaid_diagram( - parsed: &ParsedMarkdownMermaidDiagram, - cx: &mut RenderContext, -) -> AnyElement { - let cached = cx.mermaid_state.cache.get(&parsed.contents); - - if let Some(result) = cached.and_then(|c| c.render_image.get()) { - match result { - Ok(render_image) => cx - .with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - div().w_full().child( - img(ImageSource::Render(render_image.clone())) - .max_w_full() - .with_fallback(|| { - div() - .child(Label::new("Failed to load mermaid diagram")) - .into_any_element() - }), - ), - ) - .into_any(), - Err(_) => cx - .with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child(StyledText::new(parsed.contents.contents.clone())) - .into_any(), - } - } else if let Some(fallback) = cached.and_then(|c| c.fallback_image.as_ref()) { - cx.with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - div() - .w_full() - .child( - img(ImageSource::Render(fallback.clone())) - .max_w_full() - .with_fallback(|| { - div() - .child(Label::new("Failed to load mermaid diagram")) - .into_any_element() - }), - ) - .with_animation( - "mermaid-fallback-pulse", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.6, 1.0)), - |el, delta| el.opacity(delta), - ), - ) - .into_any() - } else { - cx.with_common_p(div()) - .px_3() - .py_3() - .bg(cx.code_block_background_color) - .rounded_sm() - .child( - Label::new("Rendering mermaid diagram...") - .color(Color::Muted) - .with_animation( - "mermaid-loading-pulse", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between(0.4, 0.8)), - |label, delta| label.alpha(delta), - ), - ) - .into_any() - } -} - -fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) -> AnyElement { - cx.with_common_p(div()) - .children(render_markdown_text(parsed, cx)) - .flex() - .flex_col() - .into_any_element() -} - -fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) -> Vec { - let mut any_element = Vec::with_capacity(parsed_new.len()); - // these values are cloned in-order satisfy borrow checker - let syntax_theme = cx.syntax_theme.clone(); - let workspace_clone = cx.workspace.clone(); - let code_span_bg_color = cx.code_span_background_color; - let text_style = cx.text_style.clone(); - let link_color = cx.link_color; - - for parsed_region in parsed_new { - match parsed_region { - MarkdownParagraphChunk::Text(parsed) => { - let trimmed = parsed.contents.trim(); - if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" { - let checked = trimmed != "[ ]"; - let element = div() - .child(MarkdownCheckbox::new( - cx.next_id(&parsed.source_range), - if checked { - ToggleState::Selected - } else { - ToggleState::Unselected - }, - cx.clone(), - )) - .into_any(); - any_element.push(element); - continue; - } - - let element_id = cx.next_id(&parsed.source_range); - - let highlights = gpui::combine_highlights( - parsed.highlights.iter().filter_map(|(range, highlight)| { - highlight - .to_highlight_style(&syntax_theme) - .map(|style| (range.clone(), style)) - }), - parsed.regions.iter().filter_map(|(range, region)| { - if region.code { - Some(( - range.clone(), - HighlightStyle { - background_color: Some(code_span_bg_color), - ..Default::default() - }, - )) - } else if region.link.is_some() { - Some(( - range.clone(), - HighlightStyle { - color: Some(link_color), - ..Default::default() - }, - )) - } else { - None - } - }), - ); - let mut links = Vec::new(); - let mut link_ranges = Vec::new(); - for (range, region) in parsed.regions.iter() { - if let Some(link) = region.link.clone() { - links.push(link); - link_ranges.push(range.clone()); - } - } - let workspace = workspace_clone.clone(); - let element = div() - .child( - InteractiveText::new( - element_id, - StyledText::new(parsed.contents.clone()) - .with_default_highlights(&text_style, highlights), - ) - .tooltip({ - let links = links.clone(); - let link_ranges = link_ranges.clone(); - move |idx, _, cx| { - for (ix, range) in link_ranges.iter().enumerate() { - if range.contains(&idx) { - return Some(LinkPreview::new(&links[ix].to_string(), cx)); - } - } - None - } - }) - .on_click( - link_ranges, - move |clicked_range_ix, window, cx| match &links[clicked_range_ix] { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - normalize_path(path.clone().as_path()), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - }, - ), - ) - .into_any(); - any_element.push(element); - } - - MarkdownParagraphChunk::Image(image) => { - any_element.push(render_markdown_image(image, cx)); - } - } - } - - any_element -} - -fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { - let rule = div().w_full().h(cx.scaled_rems(0.125)).bg(cx.border_color); - div().py(cx.scaled_rems(0.5)).child(rule).into_any() -} - -fn render_markdown_image(image: &Image, cx: &mut RenderContext) -> AnyElement { - let image_resource = match image.link.clone() { - Link::Web { url } => Resource::Uri(url.into()), - Link::Path { path, .. } => Resource::Path(Arc::from(path)), - }; - - let element_id = cx.next_id(&image.source_range); - let workspace = cx.workspace.clone(); - - div() - .id(element_id) - .cursor_pointer() - .child( - img(ImageSource::Resource(image_resource)) - .max_w_full() - .with_fallback({ - let alt_text = image.alt_text.clone(); - move || div().children(alt_text.clone()).into_any_element() - }) - .when_some(image.height, |this, height| this.h(height)) - .when_some(image.width, |this, width| this.w(width)), - ) - .tooltip({ - let link = image.link.clone(); - let alt_text = image.alt_text.clone(); - move |_, cx| { - InteractiveMarkdownElementTooltip::new( - Some(alt_text.clone().unwrap_or(link.to_string().into())), - "open image", - cx, - ) - .into() - } - }) - .on_click({ - let link = image.link.clone(); - move |_, window, cx| { - if window.modifiers().secondary() { - match &link { - Link::Web { url } => cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(cx, |workspace, cx| { - workspace - .open_abs_path( - path.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - .detach(); - }); - } - } - } - } - } - }) - .into_any() -} - -struct InteractiveMarkdownElementTooltip { - tooltip_text: Option, - action_text: SharedString, -} - -impl InteractiveMarkdownElementTooltip { - pub fn new( - tooltip_text: Option, - action_text: impl Into, - cx: &mut App, - ) -> Entity { - let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into()); - - cx.new(|_cx| Self { - tooltip_text, - action_text: action_text.into(), - }) - } -} - -impl Render for InteractiveMarkdownElementTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |el, _| { - let secondary_modifier = Keystroke { - modifiers: Modifiers::secondary_key(), - ..Default::default() - }; - - el.child( - v_flex() - .gap_1() - .when_some(self.tooltip_text.clone(), |this, text| { - this.child(Label::new(text).size(LabelSize::Small)) - }) - .child( - Label::new(format!( - "{}-click to {}", - secondary_modifier, self.action_text - )) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - }) - } -} - -/// Returns the prefix for a list item. -fn list_item_prefix(order: usize, ordered: bool, depth: usize) -> String { - let ix = order.saturating_sub(1); - const NUMBERED_PREFIXES_1: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - const NUMBERED_PREFIXES_2: &str = "abcdefghijklmnopqrstuvwxyz"; - const BULLETS: [&str; 5] = ["•", "◦", "▪", "‣", "⁃"]; - - if ordered { - match depth { - 0 => format!("{}. ", order), - 1 => format!( - "{}. ", - NUMBERED_PREFIXES_1 - .chars() - .nth(ix % NUMBERED_PREFIXES_1.len()) - .unwrap() - ), - _ => format!( - "{}. ", - NUMBERED_PREFIXES_2 - .chars() - .nth(ix % NUMBERED_PREFIXES_2.len()) - .unwrap() - ), - } - } else { - let depth = depth.min(BULLETS.len() - 1); - let bullet = BULLETS[depth]; - return format!("{} ", bullet); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::markdown_elements::ParsedMarkdownMermaidDiagramContents; - use crate::markdown_elements::ParsedMarkdownTableColumn; - use crate::markdown_elements::ParsedMarkdownText; - - fn text(text: &str) -> MarkdownParagraphChunk { - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: 0..text.len(), - contents: SharedString::new(text), - highlights: Default::default(), - regions: Default::default(), - }) - } - - fn column( - col_span: usize, - row_span: usize, - children: Vec, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header: false, - children, - alignment: ParsedMarkdownTableAlignment::None, - } - } - - fn column_with_row_span( - col_span: usize, - row_span: usize, - children: Vec, - ) -> ParsedMarkdownTableColumn { - ParsedMarkdownTableColumn { - col_span, - row_span, - is_header: false, - children, - alignment: ParsedMarkdownTableAlignment::None, - } - } - - #[test] - fn test_calculate_table_columns_count() { - assert_eq!(0, calculate_table_columns_count(&vec![])); - - assert_eq!( - 1, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]) - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(2, 1, vec![text("column1")]) - ])]) - ); - - assert_eq!( - 3, - calculate_table_columns_count(&vec![ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(2, 1, vec![text("column2")]), - ])]) - ); - - assert_eq!( - 2, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![column(1, 1, vec![text("column1")]),]) - ]) - ); - - assert_eq!( - 3, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column(1, 1, vec![text("column1")]), - column(1, 1, vec![text("column2")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![column(3, 3, vec![text("column1")]),]) - ]) - ); - } - - #[test] - fn test_row_span_support() { - assert_eq!( - 3, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column_with_row_span(1, 2, vec![text("spans 2 rows")]), - column(1, 1, vec![text("column2")]), - column(1, 1, vec![text("column3")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column is covered by row span from above - column(1, 1, vec![text("column2 row2")]), - column(1, 1, vec![text("column3 row2")]), - ]) - ]) - ); - - assert_eq!( - 4, - calculate_table_columns_count(&vec![ - ParsedMarkdownTableRow::with_columns(vec![ - column_with_row_span(1, 3, vec![text("spans 3 rows")]), - column_with_row_span(2, 1, vec![text("spans 2 cols")]), - column(1, 1, vec![text("column4")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column covered by row span - column(1, 1, vec![text("column2")]), - column(1, 1, vec![text("column3")]), - column(1, 1, vec![text("column4")]), - ]), - ParsedMarkdownTableRow::with_columns(vec![ - // First column still covered by row span - column(3, 1, vec![text("spans 3 cols")]), - ]) - ]) - ); - } - - #[test] - fn test_list_item_prefix() { - assert_eq!(list_item_prefix(1, true, 0), "1. "); - assert_eq!(list_item_prefix(2, true, 0), "2. "); - assert_eq!(list_item_prefix(3, true, 0), "3. "); - assert_eq!(list_item_prefix(11, true, 0), "11. "); - assert_eq!(list_item_prefix(1, true, 1), "A. "); - assert_eq!(list_item_prefix(2, true, 1), "B. "); - assert_eq!(list_item_prefix(3, true, 1), "C. "); - assert_eq!(list_item_prefix(1, true, 2), "a. "); - assert_eq!(list_item_prefix(2, true, 2), "b. "); - assert_eq!(list_item_prefix(7, true, 2), "g. "); - assert_eq!(list_item_prefix(1, true, 1), "A. "); - assert_eq!(list_item_prefix(1, true, 2), "a. "); - assert_eq!(list_item_prefix(1, false, 0), "• "); - assert_eq!(list_item_prefix(1, false, 1), "◦ "); - assert_eq!(list_item_prefix(1, false, 2), "▪ "); - assert_eq!(list_item_prefix(1, false, 3), "‣ "); - assert_eq!(list_item_prefix(1, false, 4), "⁃ "); - } - - fn mermaid_contents(s: &str) -> ParsedMarkdownMermaidDiagramContents { - ParsedMarkdownMermaidDiagramContents { - contents: SharedString::from(s.to_string()), - scale: 1, - } - } - - fn mermaid_sequence(diagrams: &[&str]) -> Vec { - diagrams - .iter() - .map(|diagram| mermaid_contents(diagram)) - .collect() - } - - fn mermaid_fallback( - new_diagram: &str, - new_full_order: &[ParsedMarkdownMermaidDiagramContents], - old_full_order: &[ParsedMarkdownMermaidDiagramContents], - cache: &MermaidDiagramCache, - ) -> Option> { - let new_content = mermaid_contents(new_diagram); - let idx = new_full_order - .iter() - .position(|content| content == &new_content)?; - MermaidState::get_fallback_image(idx, old_full_order, new_full_order.len(), cache) - } - - fn mock_render_image() -> Arc { - Arc::new(RenderImage::new(Vec::new())) - } - - #[test] - fn test_mermaid_fallback_on_edit() { - let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); - - let svg_b = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(svg_b.clone()), None), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = - mermaid_fallback("graph B modified", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_b), - "Fallback should be the old diagram's SVG" - ); - } - - #[test] - fn test_mermaid_no_fallback_on_add_in_middle() { - let old_full_order = mermaid_sequence(&["graph A", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph NEW", "graph C"]); - - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph NEW", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_none(), - "Should NOT use fallback when adding new diagram" - ); - } - - #[test] - fn test_mermaid_fallback_chains_on_rapid_edits() { - let old_full_order = mermaid_sequence(&["graph A", "graph B modified", "graph C"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B modified again", "graph C"]); - - let original_svg = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - cache.insert( - mermaid_contents("graph B modified"), - // Still rendering, but has fallback from original "graph B" - CachedMermaidDiagram::new_for_test(None, Some(original_svg.clone())), - ); - cache.insert( - mermaid_contents("graph C"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback( - "graph B modified again", - &new_full_order, - &old_full_order, - &cache, - ); - - assert!( - fallback.is_some(), - "Should chain fallback when previous render not complete" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &original_svg), - "Fallback should chain through to the original SVG" - ); - } - - #[test] - fn test_mermaid_no_fallback_when_no_old_diagram_at_index() { - let old_full_order = mermaid_sequence(&["graph A"]); - let new_full_order = mermaid_sequence(&["graph A", "graph B"]); - - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph B", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_none(), - "Should NOT have fallback when adding diagram at end" - ); - } - - #[test] - fn test_mermaid_fallback_with_duplicate_blocks_edit_first() { - let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); - let new_full_order = mermaid_sequence(&["graph A edited", "graph A", "graph B"]); - - let svg_a = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing one of duplicate blocks" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_a), - "Fallback should be the old duplicate diagram's image" - ); - } - - #[test] - fn test_mermaid_fallback_with_duplicate_blocks_edit_second() { - let old_full_order = mermaid_sequence(&["graph A", "graph A", "graph B"]); - let new_full_order = mermaid_sequence(&["graph A", "graph A edited", "graph B"]); - - let svg_a = mock_render_image(); - let mut cache: MermaidDiagramCache = HashMap::default(); - cache.insert( - mermaid_contents("graph A"), - CachedMermaidDiagram::new_for_test(Some(svg_a.clone()), None), - ); - cache.insert( - mermaid_contents("graph B"), - CachedMermaidDiagram::new_for_test(Some(mock_render_image()), None), - ); - - let fallback = mermaid_fallback("graph A edited", &new_full_order, &old_full_order, &cache); - - assert!( - fallback.is_some(), - "Should use old diagram as fallback when editing the second duplicate block" - ); - assert!( - Arc::ptr_eq(&fallback.unwrap(), &svg_a), - "Fallback should be the old duplicate diagram's image" - ); - } -} From c3d1f7981b9d2fe5cb3d4c2710edc99af5592dcf Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 26 Mar 2026 10:08:06 +0100 Subject: [PATCH 25/45] ci: Update workflows to prepare for Node.js 20 deprecation (#52443) The workflow run at https://github.com/zed-industries/zed/actions/runs/23557683707 succeeded but threw some warnings for a rather-soon Node.js 20 deprecation (June 2nd). Hence, this PR updates in that context mentioned workflows to newer versions from which on the actions will use Node.js 24. Namely, this updates - `actions/checkout` - `actions/create-github-app-token` and - `peter-evans/create-pull-request` to their latest version which includes said updates. As for their most recent versions, all of these actions just updated their versions to account for said deprecation. Release Notes: - N/A --- .../add_commented_closed_issue_to_project.yml | 2 +- .github/workflows/after_release.yml | 4 +- .github/workflows/assign-reviewers.yml | 8 +- .github/workflows/autofix_pr.yml | 14 +- .github/workflows/background_agent_mvp.yml | 2 +- .github/workflows/bump_collab_staging.yml | 2 +- .github/workflows/bump_patch_version.yml | 10 +- .github/workflows/catch_blank_issues.yml | 2 +- .github/workflows/cherry_pick.yml | 8 +- .../comment_on_potential_duplicate_issues.yml | 4 +- ...ommunity_update_all_top_ranking_issues.yml | 2 +- ...unity_update_weekly_top_ranking_issues.yml | 2 +- .github/workflows/compare_perf.yml | 2 +- .github/workflows/danger.yml | 2 +- .github/workflows/deploy_cloudflare.yml | 2 +- .github/workflows/deploy_collab.yml | 8 +- .github/workflows/docs_suggestions.yml | 4 +- .github/workflows/extension_auto_bump.yml | 2 +- .github/workflows/extension_bump.yml | 22 +-- .github/workflows/extension_tests.yml | 6 +- .../workflows/extension_workflow_rollout.yml | 16 +-- .../workflows/good_first_issue_notifier.yml | 2 +- .github/workflows/pr_labeler.yml | 2 +- .github/workflows/publish_extension_cli.yml | 16 +-- .github/workflows/randomized_tests.yml | 2 +- .github/workflows/release.yml | 34 ++--- .github/workflows/release_nightly.yml | 24 ++-- .github/workflows/run_agent_evals.yml | 2 +- .github/workflows/run_bundling.yml | 16 +-- .github/workflows/run_cron_unit_evals.yml | 2 +- .github/workflows/run_tests.yml | 34 ++--- .github/workflows/run_unit_evals.yml | 2 +- .../track_duplicate_bot_effectiveness.yml | 8 +- .../workflows/update_duplicate_magnets.yml | 2 +- .../src/tasks/workflows/extension_bump.rs | 98 ++----------- .../workflows/extension_workflow_rollout.rs | 66 ++++----- .../tasks/workflows/publish_extension_cli.rs | 22 ++- tooling/xtask/src/tasks/workflows/steps.rs | 132 ++++++++++++++++-- 38 files changed, 310 insertions(+), 278 deletions(-) diff --git a/.github/workflows/add_commented_closed_issue_to_project.yml b/.github/workflows/add_commented_closed_issue_to_project.yml index bd84eaa9446e57c5482ab818df3dbcfe587e040e..27315e7160200dc323899b58d5c307aae656d5c6 100644 --- a/.github/workflows/add_commented_closed_issue_to_project.yml +++ b/.github/workflows/add_commented_closed_issue_to_project.yml @@ -35,7 +35,7 @@ jobs: - if: steps.is-post-close-comment.outputs.result == 'true' id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/after_release.yml b/.github/workflows/after_release.yml index 95229f9f46bbd34ffe02832114b2b39da1b7e090..ab2220764861b17317f1fa3971ecf2aa9b645c8d 100644 --- a/.github/workflows/after_release.yml +++ b/.github/workflows/after_release.yml @@ -27,7 +27,7 @@ jobs: - name: after_release::rebuild_releases_page::refresh_cloud_releases run: curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag=${{ github.event.release.tag_name || inputs.tag_name }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: after_release::rebuild_releases_page::redeploy_zed_dev @@ -110,7 +110,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: release::create_sentry_release diff --git a/.github/workflows/assign-reviewers.yml b/.github/workflows/assign-reviewers.yml index 1a21879b639736232f965863a31b9a8d3a2c2b35..c16a363db18c9ac11f000ad65961a165db43c982 100644 --- a/.github/workflows/assign-reviewers.yml +++ b/.github/workflows/assign-reviewers.yml @@ -51,7 +51,7 @@ jobs: steps: - name: Generate app token id: app-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ vars.COORDINATOR_APP_ID }} private-key: ${{ secrets.COORDINATOR_APP_PRIVATE_KEY }} @@ -60,7 +60,7 @@ jobs: # SECURITY: checks out the coordinator repo at ref: main, NOT the PR branch. # persist-credentials: false prevents the token from leaking into .git/config. - name: Checkout coordinator repo - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: repository: zed-industries/codeowner-coordinator ref: main @@ -69,7 +69,7 @@ jobs: persist-credentials: false - name: Setup Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" @@ -95,7 +95,7 @@ jobs: - name: Upload output if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: assign-reviewers-output path: /tmp/assign-reviewers-output.txt diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 1f9e6320700d14cab69662e317c30fa7206eb655..36a459c94b9ea2e35b683bb957d33db362bee262 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -18,7 +18,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: autofix_pr::run_autofix::checkout_pr @@ -91,22 +91,22 @@ jobs: if: needs.run_autofix.outputs.has_changes == 'true' runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - id: get-app-token + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - token: ${{ steps.get-app-token.outputs.token }} + token: ${{ steps.generate-token.outputs.token }} - name: autofix_pr::commit_changes::checkout_pr run: gh pr checkout "$PR_NUMBER" env: PR_NUMBER: ${{ inputs.pr_number }} - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - name: autofix_pr::download_patch_artifact uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 with: @@ -122,7 +122,7 @@ jobs: GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com GIT_AUTHOR_NAME: Zed Zippy GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} concurrency: group: ${{ github.workflow }}-${{ inputs.pr_number }} cancel-in-progress: true diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml index 528600138243cb8aca2e0fe0645eda198fc4f2b2..f8c654a293c26e50ccd5194742d7a6977009fb48 100644 --- a/.github/workflows/background_agent_mvp.yml +++ b/.github/workflows/background_agent_mvp.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/bump_collab_staging.yml b/.github/workflows/bump_collab_staging.yml index d400905b4da3304a8b916d3a38ae9d8a2855dbf5..4f9724439f37b276de625e5810c777c12f20e4b9 100644 --- a/.github/workflows/bump_collab_staging.yml +++ b/.github/workflows/bump_collab_staging.yml @@ -11,7 +11,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 62540321ed755f2fd3879a7ddfc3a37237d8e7de..6b2fa66147b656efd9c8e28cd43cd2e010930dd1 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -13,18 +13,18 @@ jobs: if: github.repository_owner == 'zed-industries' runs-on: namespace-profile-16x32-ubuntu-2204 steps: - - id: get-app-token + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false ref: ${{ inputs.branch }} - token: ${{ steps.get-app-token.outputs.token }} + token: ${{ steps.generate-token.outputs.token }} - name: bump_patch_version::run_bump_patch_version::bump_patch_version run: | channel="$(cat crates/zed/RELEASE_CHANNEL)" @@ -51,7 +51,7 @@ jobs: GIT_COMMITTER_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com GIT_AUTHOR_NAME: Zed Zippy GIT_AUTHOR_EMAIL: 234243425+zed-zippy[bot]@users.noreply.github.com - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} concurrency: group: ${{ github.workflow }}-${{ inputs.branch }} cancel-in-progress: true diff --git a/.github/workflows/catch_blank_issues.yml b/.github/workflows/catch_blank_issues.yml index c6f595ef2e0890ce107829f3e91490332567368a..dbceac5a196f2dc9c0963e491bd346dc8c0eff51 100644 --- a/.github/workflows/catch_blank_issues.yml +++ b/.github/workflows/catch_blank_issues.yml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 5 steps: - id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/cherry_pick.yml b/.github/workflows/cherry_pick.yml index ee0c1d35d0f9825d7c39b81fba0fe35901de2611..4a3bd0e643e027e7feaeac4760797e2a1fb16e11 100644 --- a/.github/workflows/cherry_pick.yml +++ b/.github/workflows/cherry_pick.yml @@ -26,12 +26,12 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - - id: get-app-token + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} @@ -43,7 +43,7 @@ jobs: CHANNEL: ${{ inputs.channel }} GIT_COMMITTER_NAME: Zed Zippy GIT_COMMITTER_EMAIL: hi@zed.dev - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} defaults: run: shell: bash -euxo pipefail {0} diff --git a/.github/workflows/comment_on_potential_duplicate_issues.yml b/.github/workflows/comment_on_potential_duplicate_issues.yml index de51cb1105c98901237ec88d47c34c69ea5c8080..0d7ce3aad3ce9deacfedfe1d237c41127a639da0 100644 --- a/.github/workflows/comment_on_potential_duplicate_issues.yml +++ b/.github/workflows/comment_on_potential_duplicate_issues.yml @@ -27,14 +27,14 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: sparse-checkout: script/github-check-new-issue-for-duplicates.py sparse-checkout-cone-mode: false - name: Get github app token id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index ef3b4fc39ddb5f0db9b09c5e861547ae8cd7eb08..b8003a69b243c3cafbf40857c653fb03f515eeec 100644 --- a/.github/workflows/community_update_all_top_ranking_issues.yml +++ b/.github/workflows/community_update_all_top_ranking_issues.yml @@ -10,7 +10,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: diff --git a/.github/workflows/community_update_weekly_top_ranking_issues.yml b/.github/workflows/community_update_weekly_top_ranking_issues.yml index 53b548f2bb4286e5de86d3823e67d75c0413a1cb..90d1934ffcb6d5d711896d3902b70599e4b06872 100644 --- a/.github/workflows/community_update_weekly_top_ranking_issues.yml +++ b/.github/workflows/community_update_weekly_top_ranking_issues.yml @@ -10,7 +10,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 if: github.repository == 'zed-industries/zed' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up uv uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml index 03113f2aa0be4dc794f8f5edec18df22fb0daa31..f6c4253573364269b5b28ee9773a3885381ddfe2 100644 --- a/.github/workflows/compare_perf.yml +++ b/.github/workflows/compare_perf.yml @@ -21,7 +21,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 62f799baae1fb64a31807030c5700019a3d2c1b7..62739b21675fec2b4289b646fec794846c5fe783 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -16,7 +16,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_pnpm diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index 37f23b20d2825e9f3d26c456903962a10c2d0081..4e029c63ccd8a022ac9d6107748f964585058735 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: clean: false diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 7fe06460f752599513c79b71bb01636d69d20e6c..9ba1ee7d8be38b1fd3b3147c679afde03b98dcd7 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -17,7 +17,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -48,7 +48,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -93,7 +93,7 @@ jobs: - name: deploy_collab::publish::sign_into_registry run: doctl registry login - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: deploy_collab::publish::build_docker_image @@ -113,7 +113,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: deploy_collab::deploy::install_doctl diff --git a/.github/workflows/docs_suggestions.yml b/.github/workflows/docs_suggestions.yml index c2dc8b4d5197bcbf38dbfb92dac8c23386726d53..c3d04d5780b290c81470dea16d11f473ee7361b1 100644 --- a/.github/workflows/docs_suggestions.yml +++ b/.github/workflows/docs_suggestions.yml @@ -64,7 +64,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -296,7 +296,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.ref || '' }} diff --git a/.github/workflows/extension_auto_bump.yml b/.github/workflows/extension_auto_bump.yml index 9388a0a442bf249505aaf51e9b6826d3bb228fb7..d4480194edbcacd24d0dff9bfd807abeb513d8ae 100644 --- a/.github/workflows/extension_auto_bump.yml +++ b/.github/workflows/extension_auto_bump.yml @@ -17,7 +17,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 2 diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index f19f64bc61b7d3cf67f8ca44002be3d50d26f0af..72bc340a814e340ef8e716e3db4cb156aee40e8f 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -34,7 +34,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -74,13 +74,13 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -138,7 +138,7 @@ jobs: BUMP_TYPE: ${{ inputs.bump-type }} WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_pull_request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: title: ${{ steps.bump-version.outputs.title }} body: ${{ steps.bump-version.outputs.body }} @@ -162,13 +162,13 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - id: determine-tag @@ -212,15 +212,15 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.app-id }} private-key: ${{ secrets.app-secret }} owner: zed-industries repositories: extensions - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - id: get-extension-id diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 5323967dd534c8ce81a1125c2e1af4e34564435c..066e1ab1739a0fabb0d6ce8f0f7f4832cfbdc228 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -21,7 +21,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} @@ -73,7 +73,7 @@ jobs: runs-on: namespace-profile-8x32-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -115,7 +115,7 @@ jobs: runs-on: namespace-profile-8x32-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index f695b43ecac47a221bbc795d03e6ddd6259d7014..4dfaf708f738ef5b5fe8d8687d80690af040eba9 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -20,7 +20,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: checkout_zed_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -114,8 +114,8 @@ jobs: max-parallel: 10 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} @@ -125,7 +125,7 @@ jobs: permission-contents: write permission-workflows: write - name: checkout_extension_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false path: extension @@ -173,7 +173,7 @@ jobs: echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT" - id: create-pr name: extension_workflow_rollout::rollout_workflows_to_extension::create_pull_request - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: path: extension title: Update CI workflows to `${{ steps.short-sha.outputs.sha_short }}` @@ -207,14 +207,14 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} permission-contents: write - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 diff --git a/.github/workflows/good_first_issue_notifier.yml b/.github/workflows/good_first_issue_notifier.yml index f366c671726348f605325576d65e13c6faa5616e..fc1b49424dce248d107d35cd6f228dd297478cad 100644 --- a/.github/workflows/good_first_issue_notifier.yml +++ b/.github/workflows/good_first_issue_notifier.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Prepare Discord message id: prepare-message diff --git a/.github/workflows/pr_labeler.yml b/.github/workflows/pr_labeler.yml index 4a1f9c474c6d00bec137bbfb58ba78acb15440d1..2f09ad681698d008845565c989b26f51c489d500 100644 --- a/.github/workflows/pr_labeler.yml +++ b/.github/workflows/pr_labeler.yml @@ -17,7 +17,7 @@ jobs: timeout-minutes: 5 steps: - id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v2.1.4 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index 254800329f3dfbe04664853dca9617778a5930d1..e7ba9075db8e552b5050e5e65fef9aeac872a776 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -14,7 +14,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -38,13 +38,13 @@ jobs: runs-on: namespace-profile-8x16-ubuntu-2204 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -63,7 +63,7 @@ jobs: - name: publish_extension_cli::update_sha_in_zed::regenerate_workflows run: cargo xtask workflows - name: publish_extension_cli::create_pull_request_zed - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: title: 'extension_ci: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}`' body: | @@ -87,8 +87,8 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - id: generate-token - name: extension_bump::generate_token - uses: actions/create-github-app-token@v2 + name: steps::generate_token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} @@ -108,7 +108,7 @@ jobs: sed -i "s/ZED_EXTENSION_CLI_SHA: [a-f0-9]*/ZED_EXTENSION_CLI_SHA: $GITHUB_SHA/" \ .github/workflows/ci.yml - name: publish_extension_cli::create_pull_request_extensions - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 with: title: Bump extension CLI version to `${{ steps.short-sha.outputs.sha_short }}` body: | diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml index de96c3df78bdb67edd584696f02316478e4446dd..9655a81235d79e1e24ae5185ebce8c8051437392 100644 --- a/.github/workflows/randomized_tests.yml +++ b/.github/workflows/randomized_tests.yml @@ -28,7 +28,7 @@ jobs: node-version: "18" - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: clean: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07a0a6d672a0a66c9c1609e82a22af9034dc936e..ec01217c6acb7ab9a4afd5b65aa1f98a9740aab1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -58,7 +58,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -111,7 +111,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -151,7 +151,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -183,7 +183,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -216,7 +216,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -244,7 +244,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_tests::check_scripts::run_shellcheck @@ -275,7 +275,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 25 @@ -305,7 +305,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -345,7 +345,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -388,7 +388,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -433,7 +433,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -482,7 +482,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -527,7 +527,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -617,16 +617,16 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') runs-on: namespace-profile-2x4-ubuntu-2404 steps: - - id: get-app-token + - id: generate-token name: steps::authenticate_as_zippy - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 with: app-id: ${{ secrets.ZED_ZIPPY_APP_ID }} private-key: ${{ secrets.ZED_ZIPPY_APP_PRIVATE_KEY }} - name: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false run: gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false env: - GITHUB_TOKEN: ${{ steps.get-app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} push_release_update_notification: needs: - create_draft_release diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 093a17e8760e52fc4278d56dd6144b6a0432f3c5..a60ae34c27a0f955d7b068187e88c0a463329a86 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -16,7 +16,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 @@ -30,7 +30,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -70,7 +70,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -107,7 +107,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -153,7 +153,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -202,7 +202,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -253,7 +253,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -308,7 +308,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -361,7 +361,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_bundling::set_release_channel_to_nightly @@ -406,7 +406,7 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_dependencies_namespace @@ -440,7 +440,7 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_store_macos @@ -488,7 +488,7 @@ jobs: runs-on: namespace-profile-4x8-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml index 56cbd17a197200a6764ed1e28c87e90740cd7deb..218d84e7afa39c2333fcd65bc05c5dc07bf2db8c 100644 --- a/.github/workflows/run_agent_evals.yml +++ b/.github/workflows/run_agent_evals.yml @@ -24,7 +24,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index 5a93cf074e2a2d7f2f3cf8418ed508c5ad359d9e..bc16c2ee9c4f72969a42d04745ba3953d8462469 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -23,7 +23,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -62,7 +62,7 @@ jobs: CXX: clang++-18 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -104,7 +104,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -148,7 +148,7 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_node @@ -196,7 +196,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -240,7 +240,7 @@ jobs: TIMESTAMP_SERVER: http://timestamp.acs.microsoft.com steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_sentry @@ -274,7 +274,7 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_dependencies_namespace @@ -306,7 +306,7 @@ jobs: GIT_LFS_SKIP_SMUDGE: '1' steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_nix_store_macos diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 6af46e678d3d629cc2f7973b8b31ee99477dfefc..46ed2e380afe7618aa835d5e122955504283ee97 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -21,7 +21,7 @@ jobs: fail-fast: false steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 1906acf9fab7bbaab81b0549328c2e85d732756d..746941b08c8d6e67148af0651f41cc651a13b2eb 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -19,7 +19,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: ${{ github.ref == 'refs/heads/main' && 2 || 350 }} @@ -124,7 +124,7 @@ jobs: runs-on: namespace-profile-4x8-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -171,7 +171,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -204,7 +204,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -239,7 +239,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -270,7 +270,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -303,7 +303,7 @@ jobs: runs-on: self-32vcpu-windows-2022 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -348,7 +348,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -403,7 +403,7 @@ jobs: runs-on: namespace-profile-mac-large steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -449,7 +449,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -493,7 +493,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -534,7 +534,7 @@ jobs: runs-on: namespace-profile-8x16-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -576,7 +576,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -611,7 +611,7 @@ jobs: CXX: clang++ steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config @@ -657,7 +657,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::cache_rust_dependencies_namespace @@ -676,7 +676,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: run_tests::check_scripts::run_shellcheck @@ -714,7 +714,7 @@ jobs: GIT_COMMITTER_EMAIL: ci@zed.dev steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false fetch-depth: 0 diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 44f12a1886bdac2fa1da8c870d223dd358285658..670a6e6b0fc19940b598221e439a68b656c7ca0f 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -24,7 +24,7 @@ jobs: runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: clean: false - name: steps::setup_cargo_config diff --git a/.github/workflows/track_duplicate_bot_effectiveness.yml b/.github/workflows/track_duplicate_bot_effectiveness.yml index fa1c80616cb6133a7a4cad8841bbaad03115ff58..0d41a6070610ce9e9cc3faa06af78145bc9caec1 100644 --- a/.github/workflows/track_duplicate_bot_effectiveness.yml +++ b/.github/workflows/track_duplicate_bot_effectiveness.yml @@ -22,14 +22,14 @@ jobs: timeout-minutes: 5 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: sparse-checkout: script/github-track-duplicate-bot-effectiveness.py sparse-checkout-cone-mode: false - name: Get github app token id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} @@ -61,14 +61,14 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: sparse-checkout: script/github-track-duplicate-bot-effectiveness.py sparse-checkout-cone-mode: false - name: Get github app token id: get-app-token - uses: actions/create-github-app-token@bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1 # v1.11.7 + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 with: app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/update_duplicate_magnets.yml b/.github/workflows/update_duplicate_magnets.yml index c3832b7bdbec13f74a8136cb1120a682f6e53920..d14f4aa92451aab9c36df49d3be128fd4797a4da 100644 --- a/.github/workflows/update_duplicate_magnets.yml +++ b/.github/workflows/update_duplicate_magnets.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'zed-industries/zed' steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 38cd926ef4b3c4bf7e0ba4ae8ccab823be9b3187..a69856ed3333810dcada4b8a8ac5b6cadee12e23 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -5,8 +5,9 @@ use crate::tasks::workflows::{ extension_tests::{self}, runners, steps::{ - self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, - NamedJob, cache_rust_dependencies_namespace, checkout_repo, dependant_job, named, + self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, + RepositoryTarget, cache_rust_dependencies_namespace, checkout_repo, dependant_job, + generate_token, named, }, vars::{ JobOutput, StepOutput, WorkflowInput, WorkflowSecret, @@ -123,7 +124,7 @@ fn create_version_label( app_secret: &WorkflowSecret, ) -> (NamedJob, StepOutput) { let (generate_token, generated_token) = - generate_token(&app_id.to_string(), &app_secret.to_string(), None); + generate_token(&app_id.to_string(), &app_secret.to_string()).into(); let (determine_tag_step, tag) = determine_tag(current_version); let job = steps::dependant_job(dependencies) .defaults(extension_job_defaults()) @@ -221,7 +222,7 @@ fn bump_extension_version( app_secret: &WorkflowSecret, ) -> NamedJob { let (generate_token, generated_token) = - generate_token(&app_id.to_string(), &app_secret.to_string(), None); + generate_token(&app_id.to_string(), &app_secret.to_string()).into(); let (bump_version, _new_version, title, body, branch_name) = bump_version(current_version, bump_type); @@ -249,49 +250,6 @@ fn bump_extension_version( named::job(job) } -pub(crate) fn generate_token( - app_id_source: &str, - app_secret_source: &str, - repository_target: Option, -) -> (Step, StepOutput) { - let step = named::uses("actions", "create-github-app-token", "v2") - .id("generate-token") - .add_with( - Input::default() - .add("app-id", app_id_source) - .add("private-key", app_secret_source) - .when_some( - repository_target, - |input, - RepositoryTarget { - owner, - repositories, - permissions, - }| { - input - .when_some(owner, |input, owner| input.add("owner", owner)) - .when_some(repositories, |input, repositories| { - input.add("repositories", repositories) - }) - .when_some(permissions, |input, permissions| { - permissions - .into_iter() - .fold(input, |input, (permission, level)| { - input.add( - permission, - serde_json::to_value(&level).unwrap_or_default(), - ) - }) - }) - }, - ), - ); - - let generated_token = StepOutput::new(&step, "token"); - - (step, generated_token) -} - fn install_bump_2_version() -> Step { named::run( runners::Platform::Linux, @@ -364,7 +322,12 @@ fn create_pull_request( generated_token: StepOutput, branch_name: StepOutput, ) -> Step { - named::uses("peter-evans", "create-pull-request", "v7").with( + named::uses( + "peter-evans", + "create-pull-request", + "98357b18bf14b5342f975ff684046ec3b2a07725", + ) + .with( Input::default() .add("title", title.to_string()) .add("body", body.to_string()) @@ -389,11 +352,9 @@ fn trigger_release( app_secret: &WorkflowSecret, ) -> NamedJob { let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]); - let (generate_token, generated_token) = generate_token( - &app_id.to_string(), - &app_secret.to_string(), - Some(extension_registry), - ); + let (generate_token, generated_token) = + generate_token(&app_id.to_string(), &app_secret.to_string()) + .for_repository(extension_registry); let (get_extension_id, extension_id) = get_extension_id(); let (release_action, pull_request_number) = release_action(extension_id, tag, &generated_token); @@ -526,34 +487,3 @@ fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) { (app_id, app_secret) } - -pub(crate) struct RepositoryTarget { - owner: Option, - repositories: Option, - permissions: Option>, -} - -impl RepositoryTarget { - pub fn new(owner: T, repositories: &[&str]) -> Self { - Self { - owner: Some(owner.to_string()), - repositories: Some(repositories.join("\n")), - permissions: None, - } - } - - pub fn current() -> Self { - Self { - owner: None, - repositories: None, - permissions: None, - } - } - - pub fn permissions(self, permissions: impl Into>) -> Self { - Self { - permissions: Some(permissions.into()), - ..self - } - } -} diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index a62bb107da5228cd3ba620e47ab77dc673974696..418b7f9e4617ad0ca42b666b7eb4d7d9614895a7 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -9,9 +9,10 @@ use crate::tasks::workflows::steps::CheckoutStep; use crate::tasks::workflows::steps::cache_rust_dependencies_namespace; use crate::tasks::workflows::vars::JobOutput; use crate::tasks::workflows::{ - extension_bump::{RepositoryTarget, generate_token}, runners, - steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named}, + steps::{ + self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, RepositoryTarget, generate_token, named, + }, vars::{self, StepOutput, WorkflowInput}, }; @@ -268,25 +269,29 @@ fn rollout_workflows_to_extension( "#, }; - named::uses("peter-evans", "create-pull-request", "v7") - .add_with(("path", "extension")) - .add_with(("title", title.clone())) - .add_with(("body", body)) - .add_with(("commit-message", title)) - .add_with(("branch", "update-workflows")) - .add_with(( - "committer", - "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", - )) - .add_with(( - "author", - "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", - )) - .add_with(("base", "main")) - .add_with(("delete-branch", true)) - .add_with(("token", token.to_string())) - .add_with(("sign-commits", true)) - .id("create-pr") + named::uses( + "peter-evans", + "create-pull-request", + "98357b18bf14b5342f975ff684046ec3b2a07725", + ) + .add_with(("path", "extension")) + .add_with(("title", title.clone())) + .add_with(("body", body)) + .add_with(("commit-message", title)) + .add_with(("branch", "update-workflows")) + .add_with(( + "committer", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + )) + .add_with(( + "author", + "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>", + )) + .add_with(("base", "main")) + .add_with(("delete-branch", true)) + .add_with(("token", token.to_string())) + .add_with(("sign-commits", true)) + .id("create-pr") } fn enable_auto_merge(token: &StepOutput) -> Step { @@ -303,17 +308,15 @@ fn rollout_workflows_to_extension( )) } - let (authenticate, token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some( + let (authenticate, token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository( RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([ ("permission-pull-requests".to_owned(), Level::Write), ("permission-contents".to_owned(), Level::Write), ("permission-workflows".to_owned(), Level::Write), ]), - ), - ); + ); + let (calculate_short_sha, short_sha) = get_short_sha(); let job = Job::default() @@ -368,14 +371,11 @@ fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput "#}) } - let (authenticate, token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some( + let (authenticate, token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).for_repository( RepositoryTarget::current() .permissions([("permission-contents".to_owned(), Level::Write)]), - ), - ); + ); let job = Job::default() .needs([rollout_job.name.clone()]) diff --git a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs index 0f9bf521247522d0ee68ca32d116e73048bee1a7..dad4bce45399bd8d0b4a6ff842f87830bd77484f 100644 --- a/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs +++ b/tooling/xtask/src/tasks/workflows/publish_extension_cli.rs @@ -2,9 +2,8 @@ use gh_workflow::{ctx::Context, *}; use indoc::indoc; use crate::tasks::workflows::{ - extension_bump::{RepositoryTarget, generate_token}, runners, - steps::{self, CommonJobConditions, NamedJob, named}, + steps::{self, CommonJobConditions, NamedJob, RepositoryTarget, generate_token, named}, vars::{self, StepOutput}, }; @@ -52,11 +51,8 @@ fn publish_job() -> NamedJob { } fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob { - let (generate_token, generated_token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some(RepositoryTarget::current()), - ); + let (generate_token, generated_token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into(); fn replace_sha() -> Step { named::bash(indoc! {r#" @@ -92,7 +88,7 @@ fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput) short_sha ); - named::uses("peter-evans", "create-pull-request", "v7").with( + named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with( Input::default() .add("title", title.clone()) .add( @@ -121,11 +117,9 @@ fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput) fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob { let extensions_repo = RepositoryTarget::new("zed-industries", &["extensions"]); - let (generate_token, generated_token) = generate_token( - vars::ZED_ZIPPY_APP_ID, - vars::ZED_ZIPPY_APP_PRIVATE_KEY, - Some(extensions_repo), - ); + let (generate_token, generated_token) = + generate_token(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY) + .for_repository(extensions_repo); fn checkout_extensions_repo(token: &StepOutput) -> Step { named::uses( @@ -165,7 +159,7 @@ fn create_pull_request_extensions( ) -> Step { let title = format!("Bump extension CLI version to `{}`", short_sha); - named::uses("peter-evans", "create-pull-request", "v7").with( + named::uses("peter-evans", "create-pull-request", "98357b18bf14b5342f975ff684046ec3b2a07725").with( Input::default() .add("title", title.clone()) .add( diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 2593d5dd0e8a2edc33f558de07af05a30f46ddbe..1be6a779f33bfb411ccdd5ac4d979b07dc283e50 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -1,7 +1,11 @@ use gh_workflow::*; use serde_json::Value; -use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput}; +use crate::tasks::workflows::{ + runners::Platform, + steps::named::function_name, + vars::{self, StepOutput}, +}; pub(crate) fn use_clang(job: Job) -> Job { job.add_env(Env::new("CC", "clang")) @@ -114,7 +118,7 @@ impl From for Step { .uses( "actions", "checkout", - "11bd71901bbe5b1630ceea73d27597364c9af683", // v4 + "93cb6efe18208431cddfb8368fd83d5badbf9bfd", // v5.0.1 ) // prevent checkout action from running `git clean -ffdx` which // would delete the target directory @@ -491,15 +495,119 @@ pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step { .add_env(("REF_NAME", ref_name.to_string())) } +pub(crate) struct GenerateAppToken<'a> { + job_name: String, + app_id: &'a str, + app_secret: &'a str, + repository_target: Option, +} + +impl<'a> GenerateAppToken<'a> { + pub fn for_repository(self, repository_target: RepositoryTarget) -> (Step, StepOutput) { + Self { + repository_target: Some(repository_target), + ..self + } + .into() + } +} + +impl<'a> From> for (Step, StepOutput) { + fn from(token: GenerateAppToken<'a>) -> Self { + let step = Step::new(token.job_name) + .uses( + "actions", + "create-github-app-token", + "f8d387b68d61c58ab83c6c016672934102569859", + ) + .id("generate-token") + .add_with( + Input::default() + .add("app-id", token.app_id) + .add("private-key", token.app_secret) + .when_some( + token.repository_target, + |input, + RepositoryTarget { + owner, + repositories, + permissions, + }| { + input + .when_some(owner, |input, owner| input.add("owner", owner)) + .when_some(repositories, |input, repositories| { + input.add("repositories", repositories) + }) + .when_some(permissions, |input, permissions| { + permissions.into_iter().fold( + input, + |input, (permission, level)| { + input.add( + permission, + serde_json::to_value(&level).unwrap_or_default(), + ) + }, + ) + }) + }, + ), + ); + + let generated_token = StepOutput::new(&step, "token"); + (step, generated_token) + } +} + +pub(crate) struct RepositoryTarget { + owner: Option, + repositories: Option, + permissions: Option>, +} + +impl RepositoryTarget { + pub fn new(owner: T, repositories: &[&str]) -> Self { + Self { + owner: Some(owner.to_string()), + repositories: Some(repositories.join("\n")), + permissions: None, + } + } + + pub fn current() -> Self { + Self { + owner: None, + repositories: None, + permissions: None, + } + } + + pub fn permissions(self, permissions: impl Into>) -> Self { + Self { + permissions: Some(permissions.into()), + ..self + } + } +} + +pub(crate) fn generate_token<'a>( + app_id_source: &'a str, + app_secret_source: &'a str, +) -> GenerateAppToken<'a> { + generate_token_with_job_name(app_id_source, app_secret_source) +} + pub fn authenticate_as_zippy() -> (Step, StepOutput) { - let step = named::uses( - "actions", - "create-github-app-token", - "bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1", - ) - .add_with(("app-id", vars::ZED_ZIPPY_APP_ID)) - .add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY)) - .id("get-app-token"); - let output = StepOutput::new(&step, "token"); - (step, output) + generate_token_with_job_name(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY).into() +} + +fn generate_token_with_job_name<'a>( + app_id_source: &'a str, + app_secret_source: &'a str, +) -> GenerateAppToken<'a> { + GenerateAppToken { + job_name: function_name(1), + app_id: app_id_source, + app_secret: app_secret_source, + repository_target: None, + } } From 4e83c75681196ac661cb7e19f438a5561599ff23 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 26 Mar 2026 10:53:49 +0100 Subject: [PATCH 26/45] Fix issue in `StreamingEditFileTool` with incomplete last line (#51747) image Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- .../src/edit_agent/streaming_fuzzy_matcher.rs | 60 +++++++++++++++++++ .../src/tools/streaming_edit_file_tool.rs | 53 +++++++++++++++- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs index 1ce2ca6f361a7e8186711d35d4dc640b8f13ce5a..e6a56099a293215050fa082a0432f216754473af 100644 --- a/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs +++ b/crates/agent/src/edit_agent/streaming_fuzzy_matcher.rs @@ -72,6 +72,18 @@ impl StreamingFuzzyMatcher { pub fn finish(&mut self) -> Vec> { // Process any remaining incomplete line if !self.incomplete_line.is_empty() { + if self.matches.len() == 1 { + let range = &mut self.matches[0]; + if range.end < self.snapshot.len() + && self + .snapshot + .contains_str_at(range.end + 1, &self.incomplete_line) + { + range.end += 1 + self.incomplete_line.len(); + return self.matches.clone(); + } + } + self.query_lines.push(self.incomplete_line.clone()); self.incomplete_line.clear(); self.matches = self.resolve_location_fuzzy(); @@ -722,6 +734,54 @@ mod tests { ); } + #[gpui::test] + fn test_prefix_of_last_line_resolves_to_correct_range() { + let text = indoc! {r#" + fn on_query_change(&mut self, cx: &mut Context) { + self.filter(cx); + } + + + + fn render_search(&self, cx: &mut Context) -> Div { + div() + } + "#}; + + let buffer = TextBuffer::new( + ReplicaId::LOCAL, + BufferId::new(1).unwrap(), + text.to_string(), + ); + let snapshot = buffer.snapshot(); + + // Query with a partial last line. + let query = "}\n\n\n\nfn render_search"; + + let mut matcher = StreamingFuzzyMatcher::new(snapshot.clone()); + matcher.push(query, None); + let matches = matcher.finish(); + + // The match should include the line containing "fn render_search". + let matched_text = matches + .first() + .map(|range| snapshot.text_for_range(range.clone()).collect::()); + + assert!( + matches.len() == 1, + "Expected exactly one match, got {}: {:?}", + matches.len(), + matched_text, + ); + + let matched_text = matched_text.unwrap(); + pretty_assertions::assert_eq!( + matched_text, + "}\n\n\n\nfn render_search", + "Match should include the render_search line", + ); + } + #[track_caller] fn assert_location_resolution(text_with_expected_range: &str, query: &str, rng: &mut StdRng) { let (text, expected_ranges) = marked_text_ranges(text_with_expected_range, false); diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index e62e47d404364f8aaddef3b4329cf93e1295370b..ea89d6fef77bf02e50a7e1599254cac897ed074f 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -116,7 +116,6 @@ pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching /// to handle minor differences in whitespace or formatting. /// - /// Always include complete lines. Do not start or end mid-line. /// Be minimal with replacements: /// - For unique lines, include only those lines /// - For non-unique lines, include enough context to identify them @@ -3916,6 +3915,58 @@ mod tests { assert_eq!(new_text, "new_content"); } + #[gpui::test] + async fn test_streaming_edit_partial_last_line(cx: &mut TestAppContext) { + let file_content = indoc::indoc! {r#" + fn on_query_change(&mut self, cx: &mut Context) { + self.filter(cx); + } + + + + fn render_search(&self, cx: &mut Context) -> Div { + div() + } + "#} + .to_string(); + + let (tool, _project, _action_log, _fs, _thread) = + setup_test(cx, json!({"file.rs": file_content})).await; + + // The model sends old_text with a PARTIAL last line. + let old_text = "}\n\n\n\nfn render_search"; + let new_text = "}\n\nfn render_search"; + + let (sender, input) = ToolInput::::test(); + let (event_stream, _receiver) = ToolCallEventStream::test(); + let task = cx.update(|cx| tool.clone().run(input, event_stream, cx)); + + sender.send_final(json!({ + "display_description": "Remove extra blank lines", + "path": "root/file.rs", + "mode": "edit", + "edits": [{"old_text": old_text, "new_text": new_text}] + })); + + let result = task.await; + let StreamingEditFileToolOutput::Success { + new_text: final_text, + .. + } = result.unwrap() + else { + panic!("expected success"); + }; + + // The edit should reduce 3 blank lines to 1 blank line before + // fn render_search, without duplicating the function signature. + let expected = file_content.replace("}\n\n\n\nfn render_search", "}\n\nfn render_search"); + pretty_assertions::assert_eq!( + final_text, + expected, + "Edit should only remove blank lines before render_search" + ); + } + #[gpui::test] async fn test_streaming_reject_created_file_deletes_it(cx: &mut TestAppContext) { let (tool, _project, action_log, fs, _thread) = setup_test(cx, json!({"dir": {}})).await; From d3a362c046b510e0fa2d169b307196be2d378301 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Thu, 26 Mar 2026 11:16:46 +0100 Subject: [PATCH 27/45] Do not congratulate bots for their merged PRs (#52477) I appreciate its efforts and it helps me a lot, but I do not think thanking zed-zippy in Discord is the right move to acknowledge its work. Release Notes: - N/A --- .github/workflows/congrats.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index 6a4111a1c5b5143ee9be067911207d5b4ca1448c..a57be7a75ad13829b096477da015ac6a43a325d7 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -29,6 +29,13 @@ jobs: } const mergedPR = prs.find(pr => pr.merged_at !== null) || prs[0]; + + if (mergedPR.user.type === "Bot") { + // They are a good bot, but not good enough to be congratulated + core.setOutput('should_congratulate', 'false'); + return; + } + const prAuthor = mergedPR.user.login; try { From ef46b31373276aa938f98d1552d33c07798a3f7b Mon Sep 17 00:00:00 2001 From: Shuhei Kadowaki <40514306+aviatesk@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:33:36 +0900 Subject: [PATCH 28/45] editor: Fix `fade_out` styling for completion labels without highlights (#45936) LSP's `CompletionItemKind` is defined by the protocol and may not have a corresponding highlight name in a language's `highlights.scm`. For example, Julia's grammar defines `@function.call` but not `@function`, so completions with `Method` kind cannot resolve a highlight. Since the mapping from `CompletionItemKind` to highlight names is an internal implementation detail that is not guaranteed to be stable, the fallback behavior should provide consistent styling regardless of grammar definitions. Bundled language extensions (e.g., Rust, TypeScript) apply `fade_out` or muted styling to the description portion of completion labels, so the fallback path and extension-provided labels should match this behavior. Previously, the description portion could fail to receive the expected `fade_out` styling in several independent cases: 1. When `runs` was empty (grammar lacks the highlight name), the `flat_map` loop never executed 2. When theme lacked a style for the `highlight_id`, early return skipped the `fade_out` logic 3. When extensions used `Literal` spans with `highlight_name: None`, `HighlightId::default()` was still added to `runs`, preventing `fade_out` from being applied The fix ensures description portions consistently receive `fade_out` styling regardless of whether the label portion can be highlighted, both for fallback completions and extension-provided labels. Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: MrSubidubi --- crates/editor/src/editor.rs | 85 ++++++++++--------- .../src/extension_lsp_adapter.rs | 9 +- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1984c2180d1c5434b02a1623510fc2caa30177c4..1786013a4a4d746c0580813c3e9b9962b1baa72d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -28848,49 +28848,58 @@ pub fn styled_runs_for_code_label<'a>( ..Default::default() }; + if label.runs.is_empty() { + let desc_start = label.filter_range.end; + let fade_run = + (desc_start < label.text.len()).then(|| (desc_start..label.text.len(), fade_out)); + return Either::Left(fade_run.into_iter()); + } + let mut prev_end = label.filter_range.end; - label - .runs - .iter() - .enumerate() - .flat_map(move |(ix, (range, highlight_id))| { - let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { - HighlightStyle { - color: Some(local_player.cursor), - ..Default::default() - } - } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { - HighlightStyle { - background_color: Some(local_player.selection), - ..Default::default() - } - } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { - style - } else { - return Default::default(); - }; - let muted_style = style.highlight(fade_out); + Either::Right( + label + .runs + .iter() + .enumerate() + .flat_map(move |(ix, (range, highlight_id))| { + let style = if *highlight_id == language::HighlightId::TABSTOP_INSERT_ID { + HighlightStyle { + color: Some(local_player.cursor), + ..Default::default() + } + } else if *highlight_id == language::HighlightId::TABSTOP_REPLACE_ID { + HighlightStyle { + background_color: Some(local_player.selection), + ..Default::default() + } + } else if let Some(style) = syntax_theme.get(*highlight_id).cloned() { + style + } else { + return Default::default(); + }; - let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); - if range.start >= label.filter_range.end { - if range.start > prev_end { - runs.push((prev_end..range.start, fade_out)); + let mut runs = SmallVec::<[(Range, HighlightStyle); 3]>::new(); + let muted_style = style.highlight(fade_out); + if range.start >= label.filter_range.end { + if range.start > prev_end { + runs.push((prev_end..range.start, fade_out)); + } + runs.push((range.clone(), muted_style)); + } else if range.end <= label.filter_range.end { + runs.push((range.clone(), style)); + } else { + runs.push((range.start..label.filter_range.end, style)); + runs.push((label.filter_range.end..range.end, muted_style)); } - runs.push((range.clone(), muted_style)); - } else if range.end <= label.filter_range.end { - runs.push((range.clone(), style)); - } else { - runs.push((range.start..label.filter_range.end, style)); - runs.push((label.filter_range.end..range.end, muted_style)); - } - prev_end = cmp::max(prev_end, range.end); + prev_end = cmp::max(prev_end, range.end); - if ix + 1 == label.runs.len() && label.text.len() > prev_end { - runs.push((prev_end..label.text.len(), fade_out)); - } + if ix + 1 == label.runs.len() && label.text.len() > prev_end { + runs.push((prev_end..label.text.len(), fade_out)); + } - runs - }) + runs + }), + ) } pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + '_ { diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 88401906fc28bb297fc2798346e110c9651b1387..13899f11c30556db189da48ed1fcb4b5d12b2f20 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -547,15 +547,16 @@ fn build_code_label( text.push_str(code_span); } extension::CodeLabelSpan::Literal(span) => { - let highlight_id = language + if let Some(highlight_id) = language .grammar() .zip(span.highlight_name.as_ref()) .and_then(|(grammar, highlight_name)| { grammar.highlight_id_for_name(highlight_name) }) - .unwrap_or_default(); - let ix = text.len(); - runs.push((ix..ix + span.text.len(), highlight_id)); + { + let ix = text.len(); + runs.push((ix..ix + span.text.len(), highlight_id)); + } text.push_str(&span.text); } } From b7c64e56b1f95cf787c0e52bd5e39f247cfb9125 Mon Sep 17 00:00:00 2001 From: Kunall Banerjee Date: Thu, 26 Mar 2026 07:44:05 -0400 Subject: [PATCH 29/45] settings_content: Fix hover descriptions for newtype wrapper settings (#51705) Happened to notice it when debugging some stuff. I initially only caught `git_hosting_providers`, but AI found another instance. | `disable_ai` | `git_hosting_providers` | |--------|--------| | image | image | Release Notes: - Fixed an issue where some settings used the wrong documentation in LSP hover documentation --- .../settings_content/src/settings_content.rs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index ea505cd2dcbd20bf5520169b808bb6848119a95a..861b6fee454edc4d18b8248b42315287a33c572c 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1133,15 +1133,15 @@ pub struct WhichKeySettingsContent { pub delay_ms: Option, } +// An ExtendingVec in the settings can only accumulate new values. +// +// This is useful for things like private files where you only want +// to allow new values to be added. +// +// Consider using a HashMap instead of this type +// (like auto_install_extensions) so that user settings files can both add +// and remove values from the set. #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -/// An ExtendingVec in the settings can only accumulate new values. -/// -/// This is useful for things like private files where you only want -/// to allow new values to be added. -/// -/// Consider using a HashMap instead of this type -/// (like auto_install_extensions) so that user settings files can both add -/// and remove values from the set. pub struct ExtendingVec(pub Vec); impl Into> for ExtendingVec { @@ -1161,10 +1161,10 @@ impl merge_from::MergeFrom for ExtendingVec { } } -/// A SaturatingBool in the settings can only ever be set to true, -/// later attempts to set it to false will be ignored. -/// -/// Used by `disable_ai`. +// A SaturatingBool in the settings can only ever be set to true, +// later attempts to set it to false will be ignored. +// +// Used by `disable_ai`. #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SaturatingBool(pub bool); From a3d72e542756d7f50e8067234dac1168a2da8acd Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:52:26 -0300 Subject: [PATCH 30/45] agent_ui: Make thread generation top-down (#52440) Ever since we introduced the agent panel the way it is right now back in May 2025, we wanted to make the thread generation be top-down. Now, with the changes we're planning to launch very soon revolving around parallel agents, doing this become even more important for a better experience. Particularly because in the agent panel's new empty state, the message editor is full screen. We want to minimize as much as possible layout shift between writing your first prompt and actually submitting it. So this means that content will stream down from your first prompt and auto-scroll you if it goes beyond the viewport. To pull this off, we added a `follow_tail` feature directly to the GPUI list so that we could only call it in the thread view layer as opposed to doing it all there. https://github.com/user-attachments/assets/99961819-6a79-40e0-b482-dca68c829161 Release Notes: - Agent: Made the thread generation be top-down instead of bottom-up. Agent content now streams from the top and auto-scroll as they go beyond the viewport. --------- Co-authored-by: Richard Feldman --- crates/agent_ui/src/conversation_view.rs | 10 +- .../src/conversation_view/thread_view.rs | 565 +++++++++++------- crates/gpui/src/elements/list.rs | 261 +++++++- 3 files changed, 601 insertions(+), 235 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 740beabce22ab6eb476b8c60b281c3ebc9d9df12..f5c91cf342c69badf2915e21c17f819963416ec5 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -795,7 +795,7 @@ impl ConversationView { }); let count = thread.read(cx).entries().len(); - let list_state = ListState::new(0, gpui::ListAlignment::Bottom, px(2048.0)); + let list_state = ListState::new(0, gpui::ListAlignment::Top, px(2048.0)); entry_view_state.update(cx, |view_state, cx| { for ix in 0..count { view_state.sync_entry(ix, &thread, window, cx); @@ -1255,9 +1255,11 @@ impl ConversationView { } AcpThreadEvent::Stopped(stop_reason) => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); active.clear_auto_expand_tracking(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if is_subagent { @@ -1325,8 +1327,10 @@ impl ConversationView { } AcpThreadEvent::Error => { if let Some(active) = self.thread_view(&thread_id) { - active.update(cx, |active, _cx| { + active.update(cx, |active, cx| { active.thread_retry_status.take(); + active.list_state.set_follow_tail(false); + active.sync_generating_indicator(cx); }); } if !is_subagent { diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 1ad07efd52ddcffe29bd3d50e382d85813c3c994..2778a5b4a2583a0b232f86184f33c4446bc18ea5 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -285,6 +285,7 @@ pub struct ThreadView { pub hovered_recent_history_item: Option, pub show_external_source_prompt_warning: bool, pub show_codex_windows_warning: bool, + pub generating_indicator_in_list: bool, pub history: Option>, pub _history_subscription: Option, } @@ -525,19 +526,39 @@ impl ThreadView { history, _history_subscription: history_subscription, show_codex_windows_warning, + generating_indicator_in_list: false, }; + + this.sync_generating_indicator(cx); let list_state_for_scroll = this.list_state.clone(); let thread_view = cx.entity().downgrade(); + this.list_state - .set_scroll_handler(move |_event, _window, cx| { + .set_scroll_handler(move |event, _window, cx| { let list_state = list_state_for_scroll.clone(); let thread_view = thread_view.clone(); + let is_following_tail = event.is_following_tail; // N.B. We must defer because the scroll handler is called while the // ListState's RefCell is mutably borrowed. Reading logical_scroll_top() // directly would panic from a double borrow. cx.defer(move |cx| { let scroll_top = list_state.logical_scroll_top(); let _ = thread_view.update(cx, |this, cx| { + if !is_following_tail { + let is_at_bottom = { + let current_offset = + list_state.scroll_px_offset_for_scrollbar().y.abs(); + let max_offset = list_state.max_offset_for_scrollbar().y; + current_offset >= max_offset - px(1.0) + }; + + let is_generating = + matches!(this.thread.read(cx).status(), ThreadStatus::Generating); + + if is_at_bottom && is_generating { + list_state.set_follow_tail(true); + } + } if let Some(thread) = this.as_native_thread(cx) { thread.update(cx, |thread, _cx| { thread.set_ui_scroll_position(Some(scroll_top)); @@ -1043,7 +1064,11 @@ impl ThreadView { this.update_in(cx, |this, _window, cx| { this.set_editor_is_expanded(false, cx); })?; - let _ = this.update(cx, |this, cx| this.scroll_to_bottom(cx)); + + let _ = this.update(cx, |this, cx| { + this.list_state.set_follow_tail(true); + cx.notify(); + }); let _stop_turn = defer({ let this = this.clone(); @@ -1097,6 +1122,12 @@ impl ThreadView { thread.send(contents, cx) })?; + + let _ = this.update(cx, |this, cx| { + this.sync_generating_indicator(cx); + cx.notify(); + }); + let res = send.await; let turn_time_ms = turn_start_time.elapsed().as_millis(); drop(_stop_turn); @@ -1236,13 +1267,13 @@ impl ThreadView { ); } - // generation - pub fn cancel_generation(&mut self, cx: &mut Context) { self.thread_retry_status.take(); self.thread_error.take(); self.user_interrupted_generation = true; self._cancel_task = Some(self.thread.update(cx, |thread, cx| thread.cancel(cx))); + self.sync_generating_indicator(cx); + cx.notify(); } pub fn retry_generation(&mut self, cx: &mut Context) { @@ -1254,6 +1285,8 @@ impl ThreadView { } let task = thread.update(cx, |thread, cx| thread.retry(cx)); + self.sync_generating_indicator(cx); + cx.notify(); cx.spawn(async move |this, cx| { let result = task.await; @@ -1582,11 +1615,10 @@ impl ThreadView { } }) }; + self.message_editor.focus_handle(cx).focus(window, cx); cx.notify(); } - // tool permissions - pub fn authorize_tool_call( &mut self, session_id: acp::SessionId, @@ -1640,6 +1672,17 @@ impl ThreadView { Some(()) } + fn is_waiting_for_confirmation(entry: &AgentThreadEntry) -> bool { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + matches!( + tool_call.status, + ToolCallStatus::WaitingForConfirmation { .. } + ) + } else { + false + } + } + fn handle_authorize_tool_call( &mut self, action: &AuthorizeToolCall, @@ -3207,22 +3250,98 @@ impl ThreadView { }) }; - if show_split { - let max_output_tokens = self - .as_native_thread(cx) - .and_then(|thread| thread.read(cx).model()) - .and_then(|model| model.max_output_tokens()) - .unwrap_or(0); + let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); + let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); + let input_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.input_tokens); + let output_tokens_label = + crate::text_thread_editor::humanize_token_count(usage.output_tokens); + + let progress_ratio = if usage.max_tokens > 0 { + usage.used_tokens as f32 / usage.max_tokens as f32 + } else { + 0.0 + }; + let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); + + let tooltip_separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); + + let (user_rules_count, first_user_rules_id, project_rules_count, project_entry_ids) = self + .as_native_thread(cx) + .map(|thread| { + let project_context = thread.read(cx).project_context().read(cx); + let user_rules_count = project_context.user_rules.len(); + let first_user_rules_id = project_context.user_rules.first().map(|r| r.uuid.0); + let project_entry_ids = project_context + .worktrees + .iter() + .filter_map(|wt| wt.rules_file.as_ref()) + .map(|rf| ProjectEntryId::from_usize(rf.project_entry_id)) + .collect::>(); + let project_rules_count = project_entry_ids.len(); + ( + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + ) + }) + .unwrap_or_default(); + + let workspace = self.workspace.clone(); + let max_output_tokens = self + .as_native_thread(cx) + .and_then(|thread| thread.read(cx).model()) + .and_then(|model| model.max_output_tokens()) + .unwrap_or(0); + let input_max_label = crate::text_thread_editor::humanize_token_count( + usage.max_tokens.saturating_sub(max_output_tokens), + ); + let output_max_label = crate::text_thread_editor::humanize_token_count(max_output_tokens); + + let build_tooltip = { + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + move |_window: &mut Window, cx: &mut App| { + let percentage = percentage.clone(); + let used = used.clone(); + let max = max.clone(); + let input_tokens_label = input_tokens_label.clone(); + let output_tokens_label = output_tokens_label.clone(); + let input_max_label = input_max_label.clone(); + let output_max_label = output_max_label.clone(); + let project_entry_ids = project_entry_ids.clone(); + let workspace = workspace.clone(); + cx.new(move |_cx| TokenUsageTooltip { + percentage, + used, + max, + input_tokens: input_tokens_label, + output_tokens: output_tokens_label, + input_max: input_max_label, + output_max: output_max_label, + show_split, + separator_color: tooltip_separator_color, + user_rules_count, + first_user_rules_id, + project_rules_count, + project_entry_ids, + workspace, + }) + .into() + } + }; + + if show_split { let input = crate::text_thread_editor::humanize_token_count(usage.input_tokens); - let input_max = crate::text_thread_editor::humanize_token_count( - usage.max_tokens.saturating_sub(max_output_tokens), - ); + let input_max = input_max_label; let output = crate::text_thread_editor::humanize_token_count(usage.output_tokens); - let output_max = crate::text_thread_editor::humanize_token_count(max_output_tokens); + let output_max = output_max_label; Some( h_flex() + .id("split_token_usage") .flex_shrink_0() .gap_1() .mr_1p5() @@ -3266,39 +3385,15 @@ impl ThreadView { .color(Color::Muted), ), ) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } else { - let used = crate::text_thread_editor::humanize_token_count(usage.used_tokens); - let max = crate::text_thread_editor::humanize_token_count(usage.max_tokens); - let progress_ratio = if usage.max_tokens > 0 { - usage.used_tokens as f32 / usage.max_tokens as f32 - } else { - 0.0 - }; - let progress_color = if progress_ratio >= 0.85 { cx.theme().status().warning } else { cx.theme().colors().text_muted }; - let separator_color = Color::Custom(cx.theme().colors().text_disabled.opacity(0.6)); - - let percentage = format!("{}%", (progress_ratio * 100.0).round() as u32); - - let (user_rules_count, project_rules_count) = self - .as_native_thread(cx) - .map(|thread| { - let project_context = thread.read(cx).project_context().read(cx); - let user_rules = project_context.user_rules.len(); - let project_rules = project_context - .worktrees - .iter() - .filter(|wt| wt.rules_file.is_some()) - .count(); - (user_rules, project_rules) - }) - .unwrap_or((0, 0)); Some( h_flex() @@ -3315,53 +3410,7 @@ impl ThreadView { .stroke_width(px(2.)) .progress_color(progress_color), ) - .tooltip(Tooltip::element({ - move |_, cx| { - v_flex() - .min_w_40() - .child( - Label::new("Context") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .child( - h_flex() - .gap_0p5() - .child(Label::new(percentage.clone())) - .child(Label::new("•").color(separator_color).mx_1()) - .child(Label::new(used.clone())) - .child(Label::new("/").color(separator_color)) - .child(Label::new(max.clone()).color(Color::Muted)), - ) - .when(user_rules_count > 0 || project_rules_count > 0, |this| { - this.child( - v_flex() - .mt_1p5() - .pt_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new("Rules") - .color(Color::Muted) - .size(LabelSize::Small), - ) - .when(user_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} user rules", - user_rules_count - ))) - }) - .when(project_rules_count > 0, |this| { - this.child(Label::new(format!( - "{} project rules", - project_rules_count - ))) - }), - ) - }) - .into_any_element() - } - })) + .hoverable_tooltip(build_tooltip) .into_any_element(), ) } @@ -3910,16 +3959,184 @@ impl ThreadView { } } +struct TokenUsageTooltip { + percentage: String, + used: String, + max: String, + input_tokens: String, + output_tokens: String, + input_max: String, + output_max: String, + show_split: bool, + separator_color: Color, + user_rules_count: usize, + first_user_rules_id: Option, + project_rules_count: usize, + project_entry_ids: Vec, + workspace: WeakEntity, +} + +impl Render for TokenUsageTooltip { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let separator_color = self.separator_color; + let percentage = self.percentage.clone(); + let used = self.used.clone(); + let max = self.max.clone(); + let input_tokens = self.input_tokens.clone(); + let output_tokens = self.output_tokens.clone(); + let input_max = self.input_max.clone(); + let output_max = self.output_max.clone(); + let show_split = self.show_split; + let user_rules_count = self.user_rules_count; + let first_user_rules_id = self.first_user_rules_id; + let project_rules_count = self.project_rules_count; + let project_entry_ids = self.project_entry_ids.clone(); + let workspace = self.workspace.clone(); + + ui::tooltip_container(cx, move |container, cx| { + container + .min_w_40() + .when(!show_split, |this| { + this.child( + Label::new("Context") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new(percentage.clone())) + .child(Label::new("\u{2022}").color(separator_color).mx_1()) + .child(Label::new(used.clone())) + .child(Label::new("/").color(separator_color)) + .child(Label::new(max.clone()).color(Color::Muted)), + ) + }) + .when(show_split, |this| { + this.child( + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_0p5() + .child(Label::new("Input:").color(Color::Muted).mr_0p5()) + .child(Label::new(input_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(input_max).color(Color::Muted)), + ) + .child( + h_flex() + .gap_0p5() + .child(Label::new("Output:").color(Color::Muted).mr_0p5()) + .child(Label::new(output_tokens)) + .child(Label::new("/").color(separator_color)) + .child(Label::new(output_max).color(Color::Muted)), + ), + ) + }) + .when( + user_rules_count > 0 || project_rules_count > 0, + move |this| { + this.child( + v_flex() + .mt_1p5() + .pt_1p5() + .pb_0p5() + .gap_0p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Rules") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child( + v_flex() + .mx_neg_1() + .when(user_rules_count > 0, move |this| { + this.child( + Button::new( + "open-user-rules", + format!("{} user rules", user_rules_count), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + window.dispatch_action( + Box::new(OpenRulesLibrary { + prompt_to_select: first_user_rules_id, + }), + cx, + ); + }), + ) + }) + .when(project_rules_count > 0, move |this| { + let workspace = workspace.clone(); + let project_entry_ids = project_entry_ids.clone(); + this.child( + Button::new( + "open-project-rules", + format!( + "{} project rules", + project_rules_count + ), + ) + .end_icon( + Icon::new(IconName::ArrowUpRight) + .color(Color::Muted) + .size(IconSize::XSmall), + ) + .on_click(move |_, window, cx| { + let _ = + workspace.update(cx, |workspace, cx| { + let project = + workspace.project().read(cx); + let paths = project_entry_ids + .iter() + .flat_map(|id| { + project.path_for_entry(*id, cx) + }) + .collect::>(); + for path in paths { + workspace + .open_path( + path, None, true, window, + cx, + ) + .detach_and_log_err(cx); + } + }); + }), + ) + }), + ), + ) + }, + ) + }) + } +} + impl ThreadView { pub(crate) fn render_entries(&mut self, cx: &mut Context) -> List { list( self.list_state.clone(), cx.processor(|this, index: usize, window, cx| { let entries = this.thread.read(cx).entries(); - let Some(entry) = entries.get(index) else { - return Empty.into_any(); - }; - this.render_entry(index, entries.len(), entry, window, cx) + if let Some(entry) = entries.get(index) { + this.render_entry(index, entries.len(), entry, window, cx) + } else if this.generating_indicator_in_list { + let confirmation = entries + .last() + .is_some_and(|entry| Self::is_waiting_for_confirmation(entry)); + this.render_generating(confirmation, cx).into_any_element() + } else { + Empty.into_any() + } }), ) .with_sizing_behavior(gpui::ListSizingBehavior::Auto) @@ -3959,12 +4176,6 @@ impl ThreadView { let editor_focus = editor.focus_handle(cx).is_focused(window); let focus_border = cx.theme().colors().border_focused; - let rules_item = if entry_ix == 0 { - self.render_rules_item(cx) - } else { - None - }; - let has_checkpoint_button = message .checkpoint .as_ref() @@ -3983,10 +4194,6 @@ impl ThreadView { .map(|this| { if is_first_indented { this.pt_0p5() - } else if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { - this.pt(rems_from_px(18.)) - } else if rules_item.is_some() { - this.pt_3() } else { this.pt_2() } @@ -3995,7 +4202,6 @@ impl ThreadView { .px_2() .gap_1p5() .w_full() - .children(rules_item) .when(is_editable && has_checkpoint_button, |this| { this.children(message.id.clone().map(|message_id| { h_flex() @@ -4250,6 +4456,8 @@ impl ThreadView { primary }; + let thread = self.thread.clone(); + let primary = if is_indented { let line_top = if is_first_indented { rems_from_px(-12.0) @@ -4277,28 +4485,16 @@ impl ThreadView { primary }; - let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry { - matches!( - tool_call.status, - ToolCallStatus::WaitingForConfirmation { .. } - ) - } else { - false - }; + let needs_confirmation = Self::is_waiting_for_confirmation(entry); - let thread = self.thread.clone(); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { v_flex() .w_full() .child(primary) - .map(|this| { - if needs_confirmation { - this.child(self.render_generating(true, cx)) - } else { - this.child(self.render_thread_controls(&thread, cx)) - } + .when(!needs_confirmation, |this| { + this.child(self.render_thread_controls(&thread, cx)) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -4382,7 +4578,7 @@ impl ThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return self.render_generating(false, cx).into_any_element(); + return Empty.into_any_element(); } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) @@ -4582,13 +4778,12 @@ impl ThreadView { }); cx.notify(); } else { - self.scroll_to_bottom(cx); + self.scroll_to_end(cx); } } - pub fn scroll_to_bottom(&mut self, cx: &mut Context) { - let entry_count = self.thread.read(cx).entries().len(); - self.list_state.reset(entry_count); + pub fn scroll_to_end(&mut self, cx: &mut Context) { + self.list_state.scroll_to_end(); cx.notify(); } @@ -4669,6 +4864,21 @@ impl ThreadView { }) } + /// Ensures the list item count includes (or excludes) an extra item for the generating indicator + pub(crate) fn sync_generating_indicator(&mut self, cx: &App) { + let is_generating = matches!(self.thread.read(cx).status(), ThreadStatus::Generating); + + if is_generating && !self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count, 1); + self.generating_indicator_in_list = true; + } else if !is_generating && self.generating_indicator_in_list { + let entries_count = self.thread.read(cx).entries().len(); + self.list_state.splice(entries_count..entries_count + 1, 0); + self.generating_indicator_in_list = false; + } + } + fn render_generating(&self, confirmation: bool, cx: &App) -> impl IntoElement { let show_stats = AgentSettings::get_global(cx).show_turn_stats; let elapsed_label = show_stats @@ -4952,7 +5162,7 @@ impl ThreadView { let entity = entity.clone(); move |_, cx| { entity.update(cx, |this, cx| { - this.scroll_to_bottom(cx); + this.scroll_to_end(cx); }); } }) @@ -7423,113 +7633,6 @@ impl ThreadView { } } - fn render_rules_item(&self, cx: &Context) -> Option { - let project_context = self - .as_native_thread(cx)? - .read(cx) - .project_context() - .read(cx); - - let user_rules_text = if project_context.user_rules.is_empty() { - None - } else if project_context.user_rules.len() == 1 { - let user_rules = &project_context.user_rules[0]; - - match user_rules.title.as_ref() { - Some(title) => Some(format!("Using \"{title}\" user rule")), - None => Some("Using user rule".into()), - } - } else { - Some(format!( - "Using {} user rules", - project_context.user_rules.len() - )) - }; - - let first_user_rules_id = project_context - .user_rules - .first() - .map(|user_rules| user_rules.uuid.0); - - let rules_files = project_context - .worktrees - .iter() - .filter_map(|worktree| worktree.rules_file.as_ref()) - .collect::>(); - - let rules_file_text = match rules_files.as_slice() { - &[] => None, - &[rules_file] => Some(format!( - "Using project {:?} file", - rules_file.path_in_worktree - )), - rules_files => Some(format!("Using {} project rules files", rules_files.len())), - }; - - if user_rules_text.is_none() && rules_file_text.is_none() { - return None; - } - - let has_both = user_rules_text.is_some() && rules_file_text.is_some(); - - Some( - h_flex() - .px_2p5() - .child( - Icon::new(IconName::Attach) - .size(IconSize::XSmall) - .color(Color::Disabled), - ) - .when_some(user_rules_text, |parent, user_rules_text| { - parent.child( - h_flex() - .id("user-rules") - .ml_1() - .mr_1p5() - .child( - Label::new(user_rules_text) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View User Rules")) - .on_click(move |_event, window, cx| { - window.dispatch_action( - Box::new(OpenRulesLibrary { - prompt_to_select: first_user_rules_id, - }), - cx, - ) - }), - ) - }) - .when(has_both, |this| { - this.child( - Label::new("•") - .size(LabelSize::XSmall) - .color(Color::Disabled), - ) - }) - .when_some(rules_file_text, |parent, rules_file_text| { - parent.child( - h_flex() - .id("project-rules") - .ml_1p5() - .child( - Label::new(rules_file_text) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .hover(|s| s.bg(cx.theme().colors().element_hover)) - .tooltip(Tooltip::text("View Project Rules")) - .on_click(cx.listener(Self::handle_open_rules)), - ) - }) - .into_any(), - ) - } - fn tool_card_header_bg(&self, cx: &Context) -> Hsla { cx.theme() .colors() diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 578900085334baf27ab90ae77748fb7fd362e8ad..ed441e3b40534690d02b31109e719c60dd5802e0 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -72,6 +72,7 @@ struct StateInner { scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, pending_scroll: Option, + follow_tail: bool, } /// Keeps track of a fractional scroll position within an item for restoration @@ -102,6 +103,9 @@ pub struct ListScrollEvent { /// Whether the list has been scrolled. pub is_scrolled: bool, + + /// Whether the list is currently in follow-tail mode (auto-scrolling to end). + pub is_following_tail: bool, } /// The sizing behavior to apply during layout. @@ -236,6 +240,7 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, + follow_tail: false, }))); this.splice(0..0, item_count); this @@ -394,6 +399,34 @@ impl ListState { }); } + /// Scroll the list to the very end (past the last item). + /// + /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the + /// anchor, so the list's layout pass will walk backwards from the end and + /// always show the bottom of the last item — even when that item is still + /// growing (e.g. during streaming). + pub fn scroll_to_end(&self) { + let state = &mut *self.0.borrow_mut(); + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + + /// Set whether the list should automatically follow the tail (auto-scroll to the end). + pub fn set_follow_tail(&self, follow: bool) { + self.0.borrow_mut().follow_tail = follow; + if follow { + self.scroll_to_end(); + } + } + + /// Returns whether the list is currently in follow-tail mode (auto-scrolling to the end). + pub fn is_following_tail(&self) -> bool { + self.0.borrow().follow_tail + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); @@ -559,7 +592,6 @@ impl StateInner { if self.reset { return; } - let padding = self.last_padding.unwrap_or_default(); let scroll_max = (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.)); @@ -581,6 +613,10 @@ impl StateInner { }); } + if self.follow_tail && delta.y > px(0.) { + self.follow_tail = false; + } + if let Some(handler) = self.scroll_handler.as_mut() { let visible_range = Self::visible_range(&self.items, height, scroll_top); handler( @@ -588,6 +624,7 @@ impl StateInner { visible_range, count: self.items.summary().count, is_scrolled: self.logical_scroll_top.is_some(), + is_following_tail: self.follow_tail, }, window, cx, @@ -677,6 +714,15 @@ impl StateInner { let mut rendered_height = padding.top; let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); + + if self.follow_tail { + scroll_top = ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }; + self.logical_scroll_top = Some(scroll_top); + } + let mut rendered_focused_item = false; let available_item_space = size( @@ -958,6 +1004,8 @@ impl StateInner { content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + self.follow_tail = false; + if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; } else { @@ -1457,6 +1505,217 @@ mod test { assert_eq!(offset.offset_in_item, px(20.)); } + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_tail(true); + + // First paint — items are 50px, total 500px, viewport 200px. + // Follow-tail should anchor to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // The scroll should be at the bottom: the last visible items fill the + // 200px viewport from the end of 500px of content (offset 300px). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Simulate items growing (e.g. streaming content makes each item taller). + // 10 items × 80px = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After growth, follow-tail should have re-anchored to the new end. + // 800px total − 200px viewport = 600px offset → item 7 at offset 40px, + // but follow-tail anchors to item_count (10), and layout walks back to + // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200, + // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking → + // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the user scrolling up. + // This should disengage follow-tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_tail(true); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the scrollbar moving the viewport to the middle. + // `set_offset_from_scrollbar` accepts a positive distance from the start. + state.set_offset_from_scrollbar(point(px(0.), px(150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + // A subsequent draw should preserve the user's manual position instead + // of snapping back to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + // Scroll to the middle of the list (item 3). + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + // Enable follow-tail — this should immediately snap the scroll anchor + // to the end, like the user just sent a prompt. + state.set_follow_tail(true); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After paint, scroll should be at the bottom. + // 500px total − 200px viewport = 300px offset → item 6, offset 0. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + #[gpui::test] fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); From 5d0934b443e9eb20d6b604b52a29dd0b1edda536 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Thu, 26 Mar 2026 11:53:16 +0000 Subject: [PATCH 31/45] workspace: Show file path in bottom bar (#52381) Context: if the toolbar and tab bar are both disabled, the current filename is not visible. This adds it to the bottom bar, similar to vim. Behind a setting, disabled by default Release Notes: - N/A or Added/Fixed/Improved ... --- assets/settings/default.json | 2 + crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/workspace.rs | 4 ++ crates/settings_ui/src/page_data.rs | 24 +++++++- crates/workspace/src/active_file_name.rs | 69 ++++++++++++++++++++++ crates/workspace/src/workspace.rs | 1 + crates/workspace/src/workspace_settings.rs | 2 + crates/zed/src/zed.rs | 2 + 8 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/workspace/src/active_file_name.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 9ea3285f90885d1ab2c33717b802ac6e8ebbfe3d..7bfb1f2cdb68856d66073e8629d9921602d806d8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1617,6 +1617,8 @@ "status_bar": { // Whether to show the status bar. "experimental.show": true, + // Whether to show the name of the active file in the status bar. + "show_active_file": false, // Whether to show the active language button in the status bar. "active_language_button": true, // Whether to show the cursor position button in the status bar. diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 414fef665a8e6841bc43242bd2f0a05147eaea1d..2d52fee639f50b26ec115a69660a90492e7e85ef 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -769,6 +769,7 @@ impl VsCodeSettings { fn status_bar_settings_content(&self) -> Option { skip_default(StatusBarSettingsContent { show: self.read_bool("workbench.statusBar.visible"), + show_active_file: None, active_language_button: None, cursor_position_button: None, line_endings_button: None, diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 7134f7db6e058bbbdd53e72196ae6727c628d339..ef00a44790fd10b8c56278362a2f552a40f52cbb 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -434,6 +434,10 @@ pub struct StatusBarSettingsContent { /// Default: true #[serde(rename = "experimental.show")] pub show: Option, + /// Whether to show the name of the active file in the status bar. + /// + /// Default: false + pub show_active_file: Option, /// Whether to display the active language button in the status bar. /// /// Default: true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 5fa1679532aa9ad82801e78a929a8bfd59509818..593564c7013fa8a0fb3e6a9f49ff0d14fbe9584f 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3327,7 +3327,7 @@ fn search_and_files_page() -> SettingsPage { } fn window_and_layout_page() -> SettingsPage { - fn status_bar_section() -> [SettingsPageItem; 9] { + fn status_bar_section() -> [SettingsPageItem; 10] { [ SettingsPageItem::SectionHeader("Status Bar"), SettingsPageItem::SettingItem(SettingItem { @@ -3472,6 +3472,28 @@ fn window_and_layout_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Active File Name", + description: "Show the name of the active file in the status bar.", + field: Box::new(SettingField { + json_path: Some("status_bar.show_active_file"), + pick: |settings_content| { + settings_content + .status_bar + .as_ref()? + .show_active_file + .as_ref() + }, + write: |settings_content, value| { + settings_content + .status_bar + .get_or_insert_default() + .show_active_file = value; + }, + }), + metadata: None, + files: USER, + }), ] } diff --git a/crates/workspace/src/active_file_name.rs b/crates/workspace/src/active_file_name.rs new file mode 100644 index 0000000000000000000000000000000000000000..f35312d529423c4dc81bb71dc585c99169afdd39 --- /dev/null +++ b/crates/workspace/src/active_file_name.rs @@ -0,0 +1,69 @@ +use gpui::{ + Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window, +}; +use settings::Settings; +use ui::{Button, Tooltip, prelude::*}; +use util::paths::PathStyle; + +use crate::{StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings}; + +pub struct ActiveFileName { + project_path: Option, + full_path: Option, +} + +impl ActiveFileName { + pub fn new() -> Self { + Self { + project_path: None, + full_path: None, + } + } +} + +impl Render for ActiveFileName { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !StatusBarSettings::get_global(cx).show_active_file { + return Empty.into_any_element(); + } + + let Some(project_path) = self.project_path.clone() else { + return Empty.into_any_element(); + }; + + let tooltip_text = self + .full_path + .clone() + .unwrap_or_else(|| project_path.clone()); + + div() + .child( + Button::new("active-file-name-button", project_path) + .label_size(LabelSize::Small) + .tooltip(Tooltip::text(tooltip_text)), + ) + .into_any_element() + } +} + +impl EventEmitter for ActiveFileName {} + +impl StatusItemView for ActiveFileName { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(item) = active_pane_item { + self.project_path = item + .project_path(cx) + .map(|path| path.path.display(PathStyle::local()).into_owned().into()); + self.full_path = item.tab_tooltip_text(cx); + } else { + self.project_path = None; + self.full_path = None; + } + cx.notify(); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 37cac09863b2251a7c8dc259d3fb1fc68c00c07e..fcb46039921f94e9a4a8b717f62ec9f709955f40 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1,3 +1,4 @@ +pub mod active_file_name; pub mod dock; pub mod history_manager; pub mod invalid_item_view; diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 5575af3d7cf07fd7afd22ddbb78a620bab775714..d78b233229800b571ccc37f87719d09125f1c4c3 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -132,6 +132,7 @@ impl Settings for TabBarSettings { #[derive(Deserialize, RegisterSetting)] pub struct StatusBarSettings { pub show: bool, + pub show_active_file: bool, pub active_language_button: bool, pub cursor_position_button: bool, pub line_endings_button: bool, @@ -143,6 +144,7 @@ impl Settings for StatusBarSettings { let status_bar = content.status_bar.clone().unwrap(); StatusBarSettings { show: status_bar.show.unwrap(), + show_active_file: status_bar.show_active_file.unwrap(), active_language_button: status_bar.active_language_button.unwrap(), cursor_position_button: status_bar.cursor_position_button.unwrap(), line_endings_button: status_bar.line_endings_button.unwrap(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1a51d08540a95381e4494ae724806967dd8ed1ec..d8438eb1b85aaa5191a178adc6b61865ebd94590 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -478,6 +478,7 @@ pub fn initialize_workspace( let search_button = cx.new(|_| search::search_status_button::SearchButton::new()); let diagnostic_summary = cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); + let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new()); let activity_indicator = activity_indicator::ActivityIndicator::new( workspace, workspace.project().read(cx).languages().clone(), @@ -510,6 +511,7 @@ pub fn initialize_workspace( status_bar.add_left_item(search_button, window, cx); status_bar.add_left_item(lsp_button, window, cx); status_bar.add_left_item(diagnostic_summary, window, cx); + status_bar.add_left_item(active_file_name, window, cx); status_bar.add_left_item(activity_indicator, window, cx); status_bar.add_right_item(edit_prediction_ui, window, cx); status_bar.add_right_item(active_buffer_encoding, window, cx); From ec6c4ed00bc50edefa18c95b256bc8f8c8486711 Mon Sep 17 00:00:00 2001 From: Ted Robertson <10043369+tredondo@users.noreply.github.com> Date: Thu, 26 Mar 2026 05:37:18 -0700 Subject: [PATCH 32/45] docs: Update keybindings in `webstorm.md` (#49583) - add Linux/Windows keybindings - use consistent spacing around the `+` in key combos Release Notes: - I don't have access to a Mac, so I haven't verified any of the macOS keybindings. I suspect some may be out of date. Please verify. - All other migration guides should be updated with Linux/Windows shortcuts, and should use consistent spacing around the `+`: - https://zed.dev/docs/migrate/vs-code#differences-in-keybindings - https://zed.dev/docs/migrate/intellij#differences-in-keybindings - https://zed.dev/docs/migrate/pycharm#differences-in-keybindings - https://zed.dev/docs/migrate/rustrover#differences-in-keybindings --------- Co-authored-by: MrSubidubi --- crates/docs_preprocessor/src/main.rs | 106 ++++++++++++++++++--- docs/README.md | 8 ++ docs/src/migrate/webstorm.md | 133 +++++++++++++-------------- 3 files changed, 163 insertions(+), 84 deletions(-) diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index 43efbeea0b0310cf70cd9bdb560b1b0d2b0c14ef..fc1bc404244a4896e7d13fbb0e9c81674438568f 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -22,8 +22,45 @@ static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") }); +static KEYMAP_JETBRAINS_MACOS: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/macos/jetbrains.json").expect("Failed to load JetBrains macOS keymap") +}); + +static KEYMAP_JETBRAINS_LINUX: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/linux/jetbrains.json").expect("Failed to load JetBrains Linux keymap") +}); + static ALL_ACTIONS: LazyLock = LazyLock::new(load_all_actions); +#[derive(Clone, Copy)] +#[allow(dead_code)] +enum Os { + MacOs, + Linux, + Windows, +} + +#[derive(Clone, Copy)] +enum KeymapOverlay { + JetBrains, +} + +impl KeymapOverlay { + fn parse(name: &str) -> Option { + match name { + "jetbrains" => Some(Self::JetBrains), + _ => None, + } + } + + fn keymap(self, os: Os) -> &'static KeymapFile { + match (self, os) { + (Self::JetBrains, Os::MacOs) => &KEYMAP_JETBRAINS_MACOS, + (Self::JetBrains, Os::Linux | Os::Windows) => &KEYMAP_JETBRAINS_LINUX, + } + } +} + const FRONT_MATTER_COMMENT: &str = ""; fn main() -> Result<()> { @@ -64,6 +101,9 @@ enum PreprocessorError { snippet: String, error: String, }, + UnknownKeymapOverlay { + overlay_name: String, + }, } impl PreprocessorError { @@ -125,6 +165,13 @@ impl std::fmt::Display for PreprocessorError { snippet ) } + PreprocessorError::UnknownKeymapOverlay { overlay_name } => { + write!( + f, + "Unknown keymap overlay: '{}'. Supported overlays: jetbrains", + overlay_name + ) + } } } } @@ -205,20 +252,39 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb (.*?)\}").unwrap(); + let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex .replace_all(&chapter.content, |caps: ®ex::Captures| { - let action = caps[1].trim(); + let overlay_name = caps.get(1).map(|m| m.as_str()); + let action = caps[2].trim(); + if is_missing_action(action) { errors.insert(PreprocessorError::new_for_not_found_action( action.to_string(), )); return String::new(); } - let macos_binding = find_binding("macos", action).unwrap_or_default(); - let linux_binding = find_binding("linux", action).unwrap_or_default(); + + let overlay = if let Some(name) = overlay_name { + let Some(overlay) = KeymapOverlay::parse(name) else { + errors.insert(PreprocessorError::UnknownKeymapOverlay { + overlay_name: name.to_string(), + }); + return String::new(); + }; + Some(overlay) + } else { + None + }; + + let macos_binding = + find_binding_with_overlay(Os::MacOs, action, overlay) + .unwrap_or_default(); + let linux_binding = + find_binding_with_overlay(Os::Linux, action, overlay) + .unwrap_or_default(); if macos_binding.is_empty() && linux_binding.is_empty() { return "
No default binding
".to_string(); @@ -227,7 +293,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet{formatted_macos_binding}|{formatted_linux_binding}") + format!("{formatted_macos_binding}|{formatted_linux_binding}") }) .into_owned() }); @@ -270,15 +336,8 @@ fn is_missing_action(name: &str) -> bool { actions_available() && find_action_by_name(name).is_none() } -fn find_binding(os: &str, action: &str) -> Option { - let keymap = match os { - "macos" => &KEYMAP_MACOS, - "linux" | "freebsd" => &KEYMAP_LINUX, - "windows" => &KEYMAP_WINDOWS, - _ => unreachable!("Not a valid OS: {}", os), - }; - - // Find the binding in reverse order, as the last binding takes precedence. +// Find the binding in reverse order, as the last binding takes precedence. +fn find_binding_in_keymap(keymap: &KeymapFile, action: &str) -> Option { keymap.sections().rev().find_map(|section| { section.bindings().rev().find_map(|(keystroke, a)| { if name_for_action(a.to_string()) == action { @@ -290,6 +349,25 @@ fn find_binding(os: &str, action: &str) -> Option { }) } +fn find_binding(os: Os, action: &str) -> Option { + let keymap = match os { + Os::MacOs => &KEYMAP_MACOS, + Os::Linux => &KEYMAP_LINUX, + Os::Windows => &KEYMAP_WINDOWS, + }; + find_binding_in_keymap(keymap, action) +} + +fn find_binding_with_overlay( + os: Os, + action: &str, + overlay: Option, +) -> Option { + overlay + .and_then(|overlay| find_binding_in_keymap(overlay.keymap(os), action)) + .or_else(|| find_binding(os, action)) +} + fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { let settings_schema = SettingsStore::json_schema(&Default::default()); let settings_validator = jsonschema::validator_for(&settings_schema) diff --git a/docs/README.md b/docs/README.md index a0f9bbd5c628f41d291880239ca555ea7ec0e3ea..f03f008223ba1102585c34f3b98bf93a985c1284 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,14 @@ This will output a code element like: `Cmd + , | Ctrl + ,`. We then By using the action name, we can ensure that the keybinding is always up-to-date rather than hardcoding the keybinding. +#### Keymap Overlays + +`{#kb:keymap_name scope::Action}` - e.g., `{#kb:jetbrains editor::GoToDefinition}`. + +This resolves the keybinding from a keymap overlay (e.g., JetBrains) first, falling back to the default keymap if the overlay doesn't define a binding for that action. This is useful for sections where the documentation expects a special base keymap to be configured. + +Supported overlays: `jetbrains`. + ### Actions `{#action scope::Action}` - e.g., `{#action zed::OpenSettings}`. diff --git a/docs/src/migrate/webstorm.md b/docs/src/migrate/webstorm.md index 72916b04c5579785d2f099f1fd2b09d7ffb11acf..eb41f5c245cdc33a9a78320997b546bee8e14f15 100644 --- a/docs/src/migrate/webstorm.md +++ b/docs/src/migrate/webstorm.md @@ -37,11 +37,11 @@ This opens the current directory in Zed. If you're coming from WebStorm, the fastest way to feel at home is to use the JetBrains keymap. During onboarding, you can select it as your base keymap. If you missed that step, you can change it anytime: -1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +1. Open Settings with {#kb zed::OpenSettings} 2. Search for `Base Keymap` 3. Select `JetBrains` -This maps familiar shortcuts like `Shift Shift` for Search Everywhere, `Cmd+O` for Go to Class, and `Cmd+Shift+A` for Find Action. +This maps familiar shortcuts like {#kb:jetbrains project_symbols::Toggle} for Go to Class and {#kb:jetbrains command_palette::Toggle} for Find Action. ## Set Up Editor Preferences @@ -63,7 +63,7 @@ Zed also supports per-project settings. Create a `.zed/settings.json` file in yo ## Open or Create a Project -After setup, press `Cmd+Shift+O` (with JetBrains keymap) to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. +After setup, use {#kb:jetbrains file_finder::Toggle} to open a folder. This becomes your workspace in Zed. Unlike WebStorm, there's no project configuration wizard, no framework selection dialog, and no project structure setup required. To start a new project, create a directory using your terminal or file manager, then open it in Zed. The editor will treat that folder as the root of your project. For new projects, you'd typically run `npm init`, `pnpm create`, or your framework's CLI tool first, then open the resulting folder in Zed. @@ -72,60 +72,53 @@ You can also launch Zed from the terminal inside any folder with: Once inside a project: -- Use `Cmd+Shift+O` or `Cmd+E` to jump between files quickly (like WebStorm's "Recent Files") -- Use `Cmd+Shift+A` or `Shift Shift` to open the Command Palette (like WebStorm's "Search Everywhere") -- Use `Cmd+O` to search for symbols (like WebStorm's "Go to Symbol") +- Use {#kb:jetbrains file_finder::Toggle} to jump between files quickly (like WebStorm's "Recent Files") +- Use {#kb:jetbrains command_palette::Toggle} to open the Command Palette (like WebStorm's "Search Everywhere") +- Use {#kb:jetbrains project_symbols::Toggle} to search for symbols (like WebStorm's "Go to Symbol") -Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with `Cmd+1` (just like WebStorm's Project tool window). +Open buffers appear as tabs across the top. The Project Panel shows your file tree and Git status. Toggle it with {#kb:jetbrains project_panel::ToggleFocus} (just like WebStorm's Project tool window). ## Differences in Keybindings -If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference for how Zed compares to WebStorm. - -### Common Shared Keybindings - -| Action | Shortcut | -| ----------------------------- | ----------------------- | -| Search Everywhere | `Shift Shift` | -| Find Action / Command Palette | `Cmd + Shift + A` | -| Go to File | `Cmd + Shift + O` | -| Go to Symbol | `Cmd + O` | -| Recent Files | `Cmd + E` | -| Go to Definition | `Cmd + B` | -| Find Usages | `Alt + F7` | -| Rename Symbol | `Shift + F6` | -| Reformat Code | `Cmd + Alt + L` | -| Toggle Project Panel | `Cmd + 1` | -| Toggle Terminal | `Alt + F12` | -| Duplicate Line | `Cmd + D` | -| Delete Line | `Cmd + Backspace` | -| Move Line Up/Down | `Shift + Alt + Up/Down` | -| Expand/Shrink Selection | `Alt + Up/Down` | -| Comment Line | `Cmd + /` | -| Go Back / Forward | `Cmd + [` / `Cmd + ]` | -| Toggle Breakpoint | `Ctrl + F8` | - -### Different Keybindings (WebStorm → Zed) - -| Action | WebStorm | Zed (JetBrains keymap) | -| ---------------------- | ----------- | ------------------------ | -| File Structure | `Cmd + F12` | `Cmd + F12` (outline) | -| Navigate to Next Error | `F2` | `F2` | -| Run | `Ctrl + R` | `Ctrl + Alt + R` (tasks) | -| Debug | `Ctrl + D` | `Alt + Shift + F9` | -| Stop | `Cmd + F2` | `Ctrl + F2` | +If you chose the JetBrains keymap during onboarding, most of your shortcuts should already feel familiar. Here's a quick reference of common actions and their keybindings with the JetBrains keymap active. + +### Common Keybindings + +| Action | Zed Keybinding | +| ---------------------- | ----------------------------------------------- | +| Command Palette | {#kb:jetbrains command_palette::Toggle} | +| Go to File | {#kb:jetbrains file_finder::Toggle} | +| Go to Symbol | {#kb:jetbrains project_symbols::Toggle} | +| File Outline | {#kb:jetbrains outline::Toggle} | +| Go to Definition | {#kb:jetbrains editor::GoToDefinition} | +| Find Usages | {#kb:jetbrains editor::FindAllReferences} | +| Rename Symbol | {#kb:jetbrains editor::Rename} | +| Reformat Code | {#kb:jetbrains editor::Format} | +| Toggle Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Toggle Terminal | {#kb:jetbrains terminal_panel::Toggle} | +| Duplicate Line | {#kb:jetbrains editor::DuplicateSelection} | +| Delete Line | {#kb:jetbrains editor::DeleteLine} | +| Move Line Up | {#kb:jetbrains editor::MoveLineUp} | +| Move Line Down | {#kb:jetbrains editor::MoveLineDown} | +| Expand Selection | {#kb:jetbrains editor::SelectLargerSyntaxNode} | +| Shrink Selection | {#kb:jetbrains editor::SelectSmallerSyntaxNode} | +| Comment Line | {#kb:jetbrains editor::ToggleComments} | +| Go Back | {#kb:jetbrains pane::GoBack} | +| Go Forward | {#kb:jetbrains pane::GoForward} | +| Toggle Breakpoint | {#kb:jetbrains editor::ToggleBreakpoint} | +| Navigate to Next Error | {#kb:jetbrains editor::GoToDiagnostic} | ### Unique to Zed -| Action | Shortcut | Notes | -| ----------------- | -------------------------- | ------------------------------ | -| Toggle Right Dock | `Cmd + R` | Assistant panel, notifications | -| Split Panes | `Cmd + K`, then arrow keys | Create splits in any direction | +| Action | Keybinding | Notes | +| ----------------- | -------------------------------- | ------------------------------------------------------------- | +| Toggle Right Dock | {#kb workspace::ToggleRightDock} | Assistant panel, notifications | +| Split Pane Right | {#kb pane::SplitRight} | Use other arrow keys to create splits in different directions | ### How to Customize Keybindings -- Open the Command Palette (`Cmd+Shift+A` or `Shift Shift`) -- Run `Zed: Open Keymap Editor` +- Open the Command Palette ({#kb:jetbrains command_palette::Toggle}) +- Run `zed: open keymap` This opens a list of all available bindings. You can override individual shortcuts or remove conflicts. @@ -143,9 +136,9 @@ WebStorm's index enables features like finding all usages across your entire cod **How to adapt:** -- Search symbols across the project with `Cmd+O` (powered by the TypeScript language server) -- Find files by name with `Cmd+Shift+O` -- Use `Cmd+Shift+F` for text search—it stays fast even in large monorepos +- Search symbols across the project with {#kb:jetbrains project_symbols::Toggle} (powered by the TypeScript language server) +- Find files by name with {#kb:jetbrains file_finder::Toggle} +- Use {#kb pane::DeploySearch} for text search—it stays fast even in large monorepos - Run `tsc --noEmit` or `eslint .` from the terminal when you need deeper project-wide analysis ### LSP vs. Native Language Intelligence @@ -169,10 +162,10 @@ Where you might notice differences: **How to adapt:** -- Use `Alt+Enter` for available code actions—the list will vary by language server +- Use {#kb:jetbrains editor::ToggleCodeActions} for available code actions—the list will vary by language server - Ensure your `tsconfig.json` is properly configured so the language server understands your project structure - Use Prettier for consistent formatting (it's enabled by default for JS/TS) -- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel (`Cmd+6`)—ESLint and TypeScript together catch many of the same issues +- For code inspection similar to WebStorm's "Inspect Code," check the Diagnostics panel ({#kb:jetbrains diagnostics::Deploy})—ESLint and TypeScript together catch many of the same issues ### No Project Model @@ -212,8 +205,8 @@ What this means in practice: ] ``` -- Use `Ctrl+Alt+R` to run tasks quickly -- Lean on your terminal (`Alt+F12`) for anything tasks don't cover +- Use {#kb:jetbrains task::Spawn} to run tasks quickly +- Lean on your terminal ({#kb:jetbrains terminal_panel::Toggle}) for anything tasks don't cover ### No Framework Integration @@ -223,8 +216,8 @@ Zed has none of this built-in. The TypeScript language server sees your code as **How to adapt:** -- Use grep and file search liberally. `Cmd+Shift+F` with a regex can find component definitions, route configurations, or API endpoints. -- Rely on your language server's "find references" (`Alt+F7`) for navigation—it works, just without framework context +- Use grep and file search liberally. {#kb pane::DeploySearch} with a regex can find component definitions, route configurations, or API endpoints. +- Rely on your language server's "find references" ({#kb:jetbrains editor::FindAllReferences}) for navigation—it works, just without framework context - Consider using framework-specific CLI tools (`ng`, `next`, `vite`) from Zed's terminal - For React, JSX/TSX syntax and TypeScript types still provide good intelligence @@ -232,16 +225,16 @@ Zed has none of this built-in. The TypeScript language server sees your code as ### Tool Windows vs. Docks -WebStorm organizes auxiliary views into numbered tool windows (Project = 1, npm = Alt+F11, Terminal = Alt+F12, etc.). Zed uses a similar concept called "docks": +WebStorm organizes auxiliary views into numbered tool windows. Zed uses a similar concept called "docks": -| WebStorm Tool Window | Zed Equivalent | Shortcut (JetBrains keymap) | -| -------------------- | -------------- | --------------------------- | -| Project (1) | Project Panel | `Cmd + 1` | -| Git (9 or Cmd+0) | Git Panel | `Cmd + 0` | -| Terminal (Alt+F12) | Terminal Panel | `Alt + F12` | -| Structure (7) | Outline Panel | `Cmd + 7` | -| Problems (6) | Diagnostics | `Cmd + 6` | -| Debug (5) | Debug Panel | `Cmd + 5` | +| WebStorm Tool Window | Zed Equivalent | Zed Keybinding | +| -------------------- | -------------- | ------------------------------------------ | +| Project | Project Panel | {#kb:jetbrains project_panel::ToggleFocus} | +| Git | Git Panel | {#kb:jetbrains git_panel::ToggleFocus} | +| Terminal | Terminal Panel | {#kb:jetbrains terminal_panel::Toggle} | +| Structure | Outline Panel | {#kb:jetbrains outline_panel::ToggleFocus} | +| Problems | Diagnostics | {#kb:jetbrains diagnostics::Deploy} | +| Debug | Debug Panel | {#kb:jetbrains debug_panel::ToggleFocus} | Zed has three dock positions: left, bottom, and right. Panels can be moved between docks by dragging or through settings. @@ -252,10 +245,10 @@ Note that there's no dedicated npm tool window in Zed. Use the terminal or defin Both WebStorm and Zed offer integrated debugging for JavaScript and TypeScript: - Zed uses `vscode-js-debug` (the same debug adapter that VS Code uses) -- Set breakpoints with `Ctrl+F8` -- Start debugging with `Alt+Shift+F9` or press `F4` and select a debug target -- Step through code with `F7` (step into), `F8` (step over), `Shift+F8` (step out) -- Continue execution with `F9` +- Set breakpoints with {#kb:jetbrains editor::ToggleBreakpoint} +- Start debugging with {#kb:jetbrains debugger::Start} +- Step through code with {#kb:jetbrains debugger::StepInto} (step into), {#kb:jetbrains debugger::StepOver} (step over), {#kb:jetbrains debugger::StepOut} (step out) +- Continue execution with {#kb:jetbrains debugger::Continue} Zed can debug: @@ -359,7 +352,7 @@ If you're used to AI assistants in WebStorm (like GitHub Copilot, JetBrains AI A ### Configuring GitHub Copilot -1. Open Settings with `Cmd+,` (macOS) or `Ctrl+,` (Linux/Windows) +1. Open Settings with {#kb zed::OpenSettings} 2. Navigate to **AI → Edit Predictions** 3. Click **Configure** next to "Configure Providers" 4. Under **GitHub Copilot**, click **Sign in to GitHub** From 15d8660748b508b3525d3403e5d172f1a557bfa5 Mon Sep 17 00:00:00 2001 From: Jose Garcia <47431411+ruxwez@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:37:35 +0000 Subject: [PATCH 33/45] collab_ui: Fix "lost session" visual bug in Collab Panel (#52486) ## Context This PR fixes a UX issue ("visual bug") in the collaboration panel documented in issue [#51800](https://github.com/zed-industries/zed/issues/51800), where users who had already signed in were still seeing the "Sign In" screen after restarting the editor. As I mentioned in my response there ([Link to comment #4132366441](https://github.com/zed-industries/zed/issues/51800#issuecomment-4132366441)), I have investigated the problem thoroughly and found that the session is not actually lost. What I discovered is that in Zed, only "staff" users automatically connect to the collaboration servers when opening the editor (by design, this logic is in `crates/client/src/client.rs` starting at line `962`). Therefore, regular users keep their saved session and `Authenticated` status, but since they don't automatically connect upon startup, the UI didn't detect this correctly. It erroneously showed the GitHub account request and the "Sign in to enable collaboration" text, giving the false impression that the user had been logged out. ### Screenshots Before (Bug) image After (Fix) image **Note:** This PR specifically addresses the visual issue in the **Collab Panel**. Similar behaviors might exist in other parts of the editor, but this change focuses on correcting the collaboration interface. This current PR: 1. Improves the `render_signed_out` function in `crates/collab_ui/src/collab_panel.rs`. 2. Simplifies the connection check using `self.client.user_id().is_some()`, which is more robust against volatile network states and perfectly covers connection transitions. 3. During rendering, it detects existing credentials and shows the correct message "Connect" / "Connecting...", replacing the GitHub icon with the appropriate network icon (`SignalHigh`). ## How to Review - Review the cleaner and simplified code in `crates/collab_ui/src/collab_panel.rs:render_signed_out`. - Verify that instead of verbose validations on the `Status` enum, simply checking the user ID correctly captures any subsequent subtype, properly differentiating between account authorization and a simple network reconnection. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed an issue (#51800) in the Collab Panel where the UI appeared to log users out. Implemented improvements to properly differentiate between "Sign In" and "Connect," avoiding false authentication prompts when users are already logged in but not automatically connected to the servers. --------- Co-authored-by: Danilo Leal --- crates/collab_ui/src/collab_panel.rs | 77 ++++++++++++++++------------ 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 3c32e76fea6dcd64b2a8b74c565544954af28c44..392e2340f14c5b633bcd9a0a8128d9423aed6a22 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2340,46 +2340,57 @@ impl CollabPanel { fn render_signed_out(&mut self, cx: &mut Context) -> Div { let collab_blurb = "Work with your team in realtime with collaborative editing, voice, shared notes and more."; - let is_signing_in = self.client.status().borrow().is_signing_in(); - let button_label = if is_signing_in { - "Signing in…" + + // Two distinct "not connected" states: + // - Authenticated (has credentials): user just needs to connect. + // - Unauthenticated (no credentials): user needs to sign in via GitHub. + let is_authenticated = self.client.user_id().is_some(); + let status = *self.client.status().borrow(); + let is_busy = status.is_signing_in(); + + let (button_id, button_label, button_icon) = if is_authenticated { + ( + "connect", + if is_busy { "Connecting…" } else { "Connect" }, + IconName::Public, + ) } else { - "Sign in" + ( + "sign_in", + if is_busy { + "Signing in…" + } else { + "Sign In with GitHub" + }, + IconName::Github, + ) }; v_flex() - .gap_6() .p_4() + .gap_4() + .size_full() + .text_center() + .justify_center() .child(Label::new(collab_blurb)) .child( - v_flex() - .gap_2() - .child( - Button::new("sign_in", button_label) - .start_icon(Icon::new(IconName::Github).color(Color::Muted)) - .style(ButtonStyle::Filled) - .full_width() - .disabled(is_signing_in) - .on_click(cx.listener(|this, _, window, cx| { - let client = this.client.clone(); - let workspace = this.workspace.clone(); - cx.spawn_in(window, async move |_, mut cx| { - client - .connect(true, &mut cx) - .await - .into_response() - .notify_workspace_async_err(workspace, &mut cx); - }) - .detach() - })), - ) - .child( - v_flex().w_full().items_center().child( - Label::new("Sign in to enable collaboration.") - .color(Color::Muted) - .size(LabelSize::Small), - ), - ), + Button::new(button_id, button_label) + .full_width() + .start_icon(Icon::new(button_icon).color(Color::Muted)) + .style(ButtonStyle::Outlined) + .disabled(is_busy) + .on_click(cx.listener(|this, _, window, cx| { + let client = this.client.clone(); + let workspace = this.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { + client + .connect(true, &mut cx) + .await + .into_response() + .notify_workspace_async_err(workspace, &mut cx); + }) + .detach() + })), ) } From 12bdc208e5298a6aaa1803b07bf6f63599cb6add Mon Sep 17 00:00:00 2001 From: Erik Funder Carstensen Date: Thu, 26 Mar 2026 14:48:18 +0100 Subject: [PATCH 34/45] zed_agent: Pick rules file in order described in docs (#52495) ## Context This makes zed-agent prioritize rules files in the same order as is described in the docs. My order of experience was - saw in my zed agent thread `Using project "CLAUDE.md" file. - went to settings to see if I can make it use `AGENTS.md` instead. - went to [the docs](https://zed.dev/docs/ai/rules) where it specifies that AGENTS.md is be picked over CLAUDE.md. - went to source to see what went wrong ## How to Review I'm changing the order of filenames in an array - the only two places where the order matters is when picking which rules file to use. The last place it's used with an `.iter().any()`. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [ ] Tests cover the new/changed behavior - If you want the behavior tested I can, but I think it's equally hard keeping docs and tests and docs and this codepath in sync. - [x] Performance impact has been considered and is acceptable Release Notes: -Fixed agent rules files are prioritized as described in docs --- crates/prompt_store/src/prompts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index 6a845bb8dd394f8a1ff26a8a0e130156a2a158bd..b0052947c44445be37f99e99cf723d5aa53c5008 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -26,9 +26,9 @@ pub const RULES_FILE_NAMES: &[&str] = &[ ".windsurfrules", ".clinerules", ".github/copilot-instructions.md", - "CLAUDE.md", "AGENT.md", "AGENTS.md", + "CLAUDE.md", "GEMINI.md", ]; From dd0d87f4eec9470a8bdd219a13d83c1a097e7139 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Thu, 26 Mar 2026 14:53:49 +0100 Subject: [PATCH 35/45] eval: Improve `StreamingEditFileTool` performance (#52428) ## Context | Eval | Score | |------|-------| | eval_delete_function | 1.00 | | eval_extract_handle_command_output | 0.96 | | eval_translate_doc_comments | 0.96 | Porting the rest of the evals is still a todo. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Ben Brandt --- .../possible-09.diff | 20 +++++++++++++++++++ .../src/tools/evals/streaming_edit_file.rs | 2 ++ .../src/tools/streaming_edit_file_tool.rs | 2 ++ crates/language_model/src/tool_schema.rs | 7 ++++++- 4 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff diff --git a/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff new file mode 100644 index 0000000000000000000000000000000000000000..6bc45657b3d6bf23b4542deb4f6016472a0e89b9 --- /dev/null +++ b/crates/agent/src/tools/evals/fixtures/extract_handle_command_output/possible-09.diff @@ -0,0 +1,20 @@ +@@ -5,7 +5,7 @@ + use futures::AsyncWriteExt; + use gpui::SharedString; + use serde::{Deserialize, Serialize}; +-use std::process::Stdio; ++use std::process::{Output, Stdio}; + use std::{ops::Range, path::Path}; + use text::Rope; + use time::OffsetDateTime; +@@ -94,6 +94,10 @@ + + let output = child.output().await.context("reading git blame output")?; + ++ handle_command_output(output) ++} ++ ++fn handle_command_output(output: Output) -> Result { + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let trimmed = stderr.trim(); diff --git a/crates/agent/src/tools/evals/streaming_edit_file.rs b/crates/agent/src/tools/evals/streaming_edit_file.rs index 5ab931915e4789e2dd9f6fb7c1da19be6da59de2..6a55517037e54ae4166cd22427201d9325ef0f76 100644 --- a/crates/agent/src/tools/evals/streaming_edit_file.rs +++ b/crates/agent/src/tools/evals/streaming_edit_file.rs @@ -808,6 +808,8 @@ fn eval_extract_handle_command_output() { include_str!("fixtures/extract_handle_command_output/possible-05.diff"), include_str!("fixtures/extract_handle_command_output/possible-06.diff"), include_str!("fixtures/extract_handle_command_output/possible-07.diff"), + include_str!("fixtures/extract_handle_command_output/possible-08.diff"), + include_str!("fixtures/extract_handle_command_output/possible-09.diff"), ]; eval_utils::eval(100, 0.95, eval_utils::NoProcessor, move || { diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index ea89d6fef77bf02e50a7e1599254cac897ed074f..df99b4d65a62e3bb12239ef58d9ad49416554209 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -111,6 +111,8 @@ pub enum StreamingEditFileMode { } /// A single edit operation that replaces old text with new text +/// Properly escape all text fields as valid JSON strings. +/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching diff --git a/crates/language_model/src/tool_schema.rs b/crates/language_model/src/tool_schema.rs index f9402c28dc316f9ccdacc58afaa0eebd6699f92d..6fbb3761b43ea04924aaa23373920c41a14c74e3 100644 --- a/crates/language_model/src/tool_schema.rs +++ b/crates/language_model/src/tool_schema.rs @@ -17,7 +17,12 @@ pub enum LanguageModelToolSchemaFormat { pub fn root_schema_for(format: LanguageModelToolSchemaFormat) -> Schema { let mut generator = match format { - LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(), + LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07() + .with(|settings| { + settings.meta_schema = None; + settings.inline_subschemas = true; + }) + .into_generator(), LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3() .with(|settings| { settings.meta_schema = None; From 260280d3e74a916ff3939bcb31122e885a1cd566 Mon Sep 17 00:00:00 2001 From: Gaauwe Rombouts Date: Thu, 26 Mar 2026 17:21:41 +0100 Subject: [PATCH 36/45] docs: Improve image display aspect ratio (#52511) ## Context Updates the image heights to auto on the docs pages, so that they don't get squishy and keep their correct aspect ratio. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --- docs/src/development/glossary.md | 6 +++--- docs/src/performance.md | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/src/development/glossary.md b/docs/src/development/glossary.md index ed3b9fdde00a605ec04e3efc25271b57691a45af..1f6b07840b8c70a86c587c45e7b617b0266144e1 100644 --- a/docs/src/development/glossary.md +++ b/docs/src/development/glossary.md @@ -84,16 +84,16 @@ h_flex() - `Panel`: An `Entity` implementing the `Panel` trait. Panels can be placed in a `Dock`. In the image below: `ProjectPanel` is in the left dock, `DebugPanel` is in the bottom dock, and `AgentPanel` is in the right dock. `Editor` does not implement `Panel`. - `Dock`: A UI element similar to a `Pane` that can be opened and hidden. Up to three docks can be open at once: left, right, and bottom. A dock contains one or more `Panel`s, not `Pane`s. -Screenshot for the Pane and Dock features +Screenshot for the Pane and Dock features - `Project`: One or more `Worktree`s - `Worktree`: Represents either local or remote files. -Screenshot for the Worktree feature +Screenshot for the Worktree feature - [Multibuffer](https://zed.dev/docs/multibuffers): A list of Editors, a multi-buffer allows editing multiple files simultaneously. A multi-buffer opens when an operation in Zed returns multiple locations, examples: _search_ or _go to definition_. See project search in the image below. -Screenshot for the MultiBuffer feature +Screenshot for the MultiBuffer feature ## Editor diff --git a/docs/src/performance.md b/docs/src/performance.md index e974d63f8816b68d30a1c06d7cbbc083f8564327..e52ea9c684de0e2b9d39efe2741dfe0728bc7641 100644 --- a/docs/src/performance.md +++ b/docs/src/performance.md @@ -15,7 +15,7 @@ See [samply](https://github.com/mstange/samply)'s README on how to install and r The profile.json does not contain any symbols. Firefox profiler can add the local symbols to the profile for for. To do that hit the upload local profile button in the top right corner. -image +image # In depth CPU profiling (Tracing) @@ -52,10 +52,12 @@ Download the profiler: ## Usage Open the profiler (tracy-profiler), you should see zed in the list of `Discovered clients` click it. -image + +image To find functions that take a long time follow this image: -image + +image # Task/Async profiling From be6cd3e5f70438f36b5a483f30cd84977e78073e Mon Sep 17 00:00:00 2001 From: Josh Robson Chase Date: Thu, 26 Mar 2026 12:31:18 -0400 Subject: [PATCH 37/45] helix: Fix insert line above/below with selection (#46492) Fix Helix `o`/`O` behavior when a selection is active. This updates `InsertLineAbove` and `InsertLineBelow` to use the selection bounds correctly for Helix selections, including line selections whose end is represented at column 0 of the following line. It also adds Helix select-mode keybindings for `o` and `O`, and adds tests covering both line selections and selections created via `v`. Closes #43210 Release Notes: - helix: Fixed insert line above/below behavior when a full line is selected --------- Co-authored-by: dino --- assets/keymaps/vim.json | 2 + crates/vim/src/helix.rs | 85 ++++++++++++++++++++++++++++++++++++++++ crates/vim/src/normal.rs | 34 ++++++++++------ crates/vim/src/vim.rs | 4 +- 4 files changed, 110 insertions(+), 15 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5da15a1c1f304743c55e87ecc208fd6adbdc7cc2..ae0a0dd0f1ef3ba99814b39db6ec3932d0ef3730 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -337,6 +337,8 @@ "shift-j": "vim::JoinLines", "i": "vim::InsertBefore", "a": "vim::InsertAfter", + "o": "vim::InsertLineBelow", + "shift-o": "vim::InsertLineAbove", "p": "vim::Paste", "u": "vim::Undo", "r": "vim::PushReplace", diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 56241275b5d8fa6de3645c6d00361b29dc49d259..c1e766c03a897facb3c7acf76b3ef7811e6910a8 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -1898,6 +1898,91 @@ mod test { ); } + #[gpui::test] + async fn test_helix_insert_before_after_select_lines(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline ˇtwo\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("2 x"); + cx.assert_state( + "line one\n«line two\nline three\nˇ»line four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("line one\nˇ\nline two\nline three\nline four", Mode::Insert); + } + + #[gpui::test] + async fn test_helix_insert_before_after_helix_select(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Test new line in selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + // Test new line in opposite selection direction + cx.set_state( + "ˇline one\nline two\nline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v j j"); + cx.assert_state( + "«line one\nline two\nlˇ»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("shift-o"); + cx.assert_state("ˇ\nline one\nline two\nline three\nline four", Mode::Insert); + + cx.set_state( + "line one\nline two\nˇline three\nline four", + Mode::HelixNormal, + ); + cx.simulate_keystrokes("v k k"); + cx.assert_state( + "«ˇline one\nline two\nl»ine three\nline four", + Mode::HelixSelect, + ); + cx.simulate_keystrokes("o"); + cx.assert_state("line one\nline two\nline three\nˇ\nline four", Mode::Insert); + } + #[gpui::test] async fn test_helix_select_mode_motion(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 6763c5cddb8bf2cda6aa4fa0988ff6be67119d3c..118805586118e36269a1f0c1d1d619058133da30 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -731,10 +731,10 @@ impl Vim { .collect::>(); editor.edit_with_autoindent(edits, cx); editor.change_selections(Default::default(), window, cx, |s| { - s.move_cursors_with(&mut |map, cursor, _| { - let previous_line = map.start_of_relative_buffer_row(cursor, -1); + s.move_with(&mut |map, selection| { + let previous_line = map.start_of_relative_buffer_row(selection.start, -1); let insert_point = motion::end_of_line(map, false, previous_line, 1); - (insert_point, SelectionGoal::None) + selection.collapse_to(insert_point, SelectionGoal::None) }); }); }); @@ -750,14 +750,19 @@ impl Vim { self.start_recording(cx); self.switch_mode(Mode::Insert, false, window, cx); self.update_editor(cx, |_, editor, cx| { - let text_layout_details = editor.text_layout_details(window, cx); editor.transact(window, cx, |editor, window, cx| { let selections = editor.selections.all::(&editor.display_snapshot(cx)); let snapshot = editor.buffer().read(cx).snapshot(cx); let selection_end_rows: BTreeSet = selections .into_iter() - .map(|selection| selection.end.row) + .map(|selection| { + if !selection.is_empty() && selection.end.column == 0 { + selection.end.row.saturating_sub(1) + } else { + selection.end.row + } + }) .collect(); let edits = selection_end_rows .into_iter() @@ -772,14 +777,17 @@ impl Vim { }) .collect::>(); editor.change_selections(Default::default(), window, cx, |s| { - s.maybe_move_cursors_with(&mut |map, cursor, goal| { - Motion::CurrentLine.move_point( - map, - cursor, - goal, - None, - &text_layout_details, - ) + s.move_with(&mut |map, selection| { + let current_line = if !selection.is_empty() && selection.end.column() == 0 { + // If this is an insert after a selection to the end of the line, the + // cursor needs to be bumped back, because it'll be at the start of the + // *next* line. + map.start_of_relative_buffer_row(selection.end, -1) + } else { + selection.end + }; + let insert_point = motion::end_of_line(map, false, current_line, 1); + selection.collapse_to(insert_point, SelectionGoal::None) }); }); editor.edit_with_autoindent(edits, cx); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 1c2416dcdb4b9a4a06970c66aded0816faf21cd0..11cf59f590823068088308a74354badf3bacfbd1 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1210,7 +1210,7 @@ impl Vim { return; } - if !mode.is_visual() && last_mode.is_visual() { + if !mode.is_visual() && last_mode.is_visual() && !last_mode.is_helix() { self.create_visual_marks(last_mode, window, cx); } @@ -1277,7 +1277,7 @@ impl Vim { } s.move_with(&mut |map, selection| { - if last_mode.is_visual() && !mode.is_visual() { + if last_mode.is_visual() && !last_mode.is_helix() && !mode.is_visual() { let mut point = selection.head(); if !selection.reversed && !selection.is_empty() { point = movement::left(map, selection.head()); From 2d62837877e14fa369a9ae598d49ac011a8fe6b4 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:37:28 -0300 Subject: [PATCH 38/45] sidebar: Ensure the projects menu is dismissed (#52494) --- crates/recent_projects/src/sidebar_recent_projects.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/recent_projects/src/sidebar_recent_projects.rs b/crates/recent_projects/src/sidebar_recent_projects.rs index bef88557b12aa076658799ff0c08518c68b6e729..72006cf6b769d23e4d2e4d535d33b61c605bad8c 100644 --- a/crates/recent_projects/src/sidebar_recent_projects.rs +++ b/crates/recent_projects/src/sidebar_recent_projects.rs @@ -403,8 +403,8 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { Some( v_flex() - .flex_1() .p_1p5() + .flex_1() .gap_1() .border_t_1() .border_color(cx.theme().colors().border_variant) @@ -414,9 +414,10 @@ impl PickerDelegate for SidebarRecentProjectsDelegate { }; Button::new("open_local_folder", "Add Local Project") .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx)) - .on_click(move |_, window, cx| { + .on_click(cx.listener(move |_, _, window, cx| { + cx.emit(DismissEvent); window.dispatch_action(open_action.boxed_clone(), cx) - }) + })) }) .into_any(), ) From 2a3fcb2ce4a47b87b103d6a5760777f7d03a11ce Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:24:20 -0300 Subject: [PATCH 39/45] collab_panel: Add ability to favorite a channel (#52378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the ability to favorite a channel in the collab panel. Note that favorited channels: - appear at the very top of the panel - also appear in their normal place in the tree - are not stored in settings but rather in the local key-value store Screenshot 2026-03-25 at 1  11@2x Release Notes: - Collab: Added the ability to favorite channels in the collab panel. --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/collab_ui/src/collab_panel.rs | 322 ++++++++++++++++++++------- 4 files changed, 243 insertions(+), 82 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 412bec85625412089b2435e46573c1cf40c50b4f..617d7a6d0662264858ac3066d40481135dab9ae6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1077,6 +1077,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 5741c5a9af5517533c214f0f77050aa2faf1a669..d3dda49c9a52a8c9b52dfddc04ae573f2fa4cf28 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1138,6 +1138,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index d94cbfdac16b5a86c380c158fae9f467abd5d202..e665d26aaf0c90d6c2fa4ee66284687c843fcd62 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1082,6 +1082,7 @@ "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", "alt-enter": "collab_panel::OpenSelectedChannelNotes", + "shift-enter": "collab_panel::ToggleSelectedChannelFavorite", }, }, { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 392e2340f14c5b633bcd9a0a8128d9423aed6a22..74e7a7c82b2123bfca8d4fc4a9e8f02463e3f7d3 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -61,6 +61,8 @@ actions!( /// /// Use `collab::OpenChannelNotes` to open the channel notes for the current call. OpenSelectedChannelNotes, + /// Toggles whether the selected channel is in the Favorites section. + ToggleSelectedChannelFavorite, /// Starts moving a channel to a new location. StartMoveChannel, /// Moves the selected item to the current location. @@ -256,6 +258,7 @@ pub struct CollabPanel { subscriptions: Vec, collapsed_sections: Vec
, collapsed_channels: Vec, + favorite_channels: Vec, filter_active_channels: bool, workspace: WeakEntity, } @@ -263,11 +266,14 @@ pub struct CollabPanel { #[derive(Serialize, Deserialize)] struct SerializedCollabPanel { collapsed_channels: Option>, + #[serde(default)] + favorite_channels: Option>, } #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] enum Section { ActiveCall, + FavoriteChannels, Channels, ChannelInvites, ContactRequests, @@ -387,6 +393,7 @@ impl CollabPanel { match_candidates: Vec::default(), collapsed_sections: vec![Section::Offline], collapsed_channels: Vec::default(), + favorite_channels: Vec::default(), filter_active_channels: false, workspace: workspace.weak_handle(), client: workspace.app_state().client.clone(), @@ -460,7 +467,13 @@ impl CollabPanel { panel.update(cx, |panel, cx| { panel.collapsed_channels = serialized_panel .collapsed_channels - .unwrap_or_else(Vec::new) + .unwrap_or_default() + .iter() + .map(|cid| ChannelId(*cid)) + .collect(); + panel.favorite_channels = serialized_panel + .favorite_channels + .unwrap_or_default() .iter() .map(|cid| ChannelId(*cid)) .collect(); @@ -493,12 +506,22 @@ impl CollabPanel { } else { Some(self.collapsed_channels.iter().map(|id| id.0).collect()) }; + + let favorite_channels = if self.favorite_channels.is_empty() { + None + } else { + Some(self.favorite_channels.iter().map(|id| id.0).collect()) + }; + let kvp = KeyValueStore::global(cx); self.pending_serialization = cx.background_spawn( async move { kvp.write_kvp( serialization_key, - serde_json::to_string(&SerializedCollabPanel { collapsed_channels })?, + serde_json::to_string(&SerializedCollabPanel { + collapsed_channels, + favorite_channels, + })?, ) .await?; anyhow::Ok(()) @@ -512,10 +535,8 @@ impl CollabPanel { } fn update_entries(&mut self, select_same_item: bool, cx: &mut Context) { - let channel_store = self.channel_store.read(cx); - let user_store = self.user_store.read(cx); let query = self.filter_editor.read(cx).text(cx); - let fg_executor = cx.foreground_executor(); + let fg_executor = cx.foreground_executor().clone(); let executor = cx.background_executor().clone(); let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); @@ -541,7 +562,7 @@ impl CollabPanel { } // Populate the active user. - if let Some(user) = user_store.current_user() { + if let Some(user) = self.user_store.read(cx).current_user() { self.match_candidates.clear(); self.match_candidates .push(StringMatchCandidate::new(0, &user.github_login)); @@ -662,6 +683,62 @@ impl CollabPanel { let mut request_entries = Vec::new(); + let previous_len = self.favorite_channels.len(); + self.favorite_channels + .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some()); + if self.favorite_channels.len() != previous_len { + self.serialize(cx); + } + + let channel_store = self.channel_store.read(cx); + let user_store = self.user_store.read(cx); + + if !self.favorite_channels.is_empty() { + let favorite_channels: Vec<_> = self + .favorite_channels + .iter() + .filter_map(|id| channel_store.channel_for_id(*id)) + .collect(); + + self.match_candidates.clear(); + self.match_candidates.extend( + favorite_channels + .iter() + .enumerate() + .map(|(ix, channel)| StringMatchCandidate::new(ix, &channel.name)), + ); + + let matches = fg_executor.block_on(match_strings( + &self.match_candidates, + &query, + true, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + + if !matches.is_empty() || query.is_empty() { + self.entries + .push(ListEntry::Header(Section::FavoriteChannels)); + + let matches_by_candidate: HashMap = + matches.iter().map(|mat| (mat.candidate_id, mat)).collect(); + + for (ix, channel) in favorite_channels.iter().enumerate() { + if !query.is_empty() && !matches_by_candidate.contains_key(&ix) { + continue; + } + self.entries.push(ListEntry::Channel { + channel: (*channel).clone(), + depth: 0, + has_children: false, + string_match: matches_by_candidate.get(&ix).cloned().cloned(), + }); + } + } + } + self.entries.push(ListEntry::Header(Section::Channels)); if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() { @@ -1359,6 +1436,18 @@ impl CollabPanel { window.handler_for(&this, move |this, _, cx| { this.copy_channel_notes_link(channel_id, cx) }), + ) + .separator() + .entry( + if self.is_channel_favorited(channel_id) { + "Remove from Favorites" + } else { + "Add to Favorites" + }, + None, + window.handler_for(&this, move |this, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + }), ); let mut has_destructive_actions = false; @@ -1608,7 +1697,8 @@ impl CollabPanel { Section::ActiveCall => Self::leave_call(window, cx), Section::Channels => self.new_root_channel(window, cx), Section::Contacts => self.toggle_contact_finder(window, cx), - Section::ContactRequests + Section::FavoriteChannels + | Section::ContactRequests | Section::Online | Section::Offline | Section::ChannelInvites => { @@ -1838,6 +1928,24 @@ impl CollabPanel { self.collapsed_channels.binary_search(&channel_id).is_ok() } + fn toggle_favorite_channel(&mut self, channel_id: ChannelId, cx: &mut Context) { + match self.favorite_channels.binary_search(&channel_id) { + Ok(ix) => { + self.favorite_channels.remove(ix); + } + Err(ix) => { + self.favorite_channels.insert(ix, channel_id); + } + }; + self.serialize(cx); + self.update_entries(true, cx); + cx.notify(); + } + + fn is_channel_favorited(&self, channel_id: ChannelId) -> bool { + self.favorite_channels.binary_search(&channel_id).is_ok() + } + fn leave_call(window: &mut Window, cx: &mut App) { ActiveCall::global(cx) .update(cx, |call, cx| call.hang_up(cx)) @@ -1954,6 +2062,17 @@ impl CollabPanel { } } + fn toggle_selected_channel_favorite( + &mut self, + _: &ToggleSelectedChannelFavorite, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(channel) = self.selected_channel() { + self.toggle_favorite_channel(channel.id, cx); + } + } + fn set_channel_visibility( &mut self, channel_id: ChannelId, @@ -2589,6 +2708,7 @@ impl CollabPanel { SharedString::from("Current Call") } } + Section::FavoriteChannels => SharedString::from("Favorites"), Section::ContactRequests => SharedString::from("Requests"), Section::Contacts => SharedString::from("Contacts"), Section::Channels => SharedString::from("Channels"), @@ -2606,6 +2726,7 @@ impl CollabPanel { }), Section::Contacts => Some( IconButton::new("add-contact", IconName::Plus) + .icon_size(IconSize::Small) .on_click( cx.listener(|this, _, window, cx| this.toggle_contact_finder(window, cx)), ) @@ -2619,9 +2740,6 @@ impl CollabPanel { IconButton::new("filter-active-channels", IconName::ListFilter) .icon_size(IconSize::Small) .toggle_state(self.filter_active_channels) - .when(!self.filter_active_channels, |button| { - button.visible_on_hover("section-header") - }) .on_click(cx.listener(|this, _, _window, cx| { this.filter_active_channels = !this.filter_active_channels; this.update_entries(true, cx); @@ -2634,10 +2752,11 @@ impl CollabPanel { ) .child( IconButton::new("add-channel", IconName::Plus) + .icon_size(IconSize::Small) .on_click(cx.listener(|this, _, window, cx| { this.new_root_channel(window, cx) })) - .tooltip(Tooltip::text("Create a channel")), + .tooltip(Tooltip::text("Create Channel")), ) .into_any_element(), ) @@ -2646,7 +2765,11 @@ impl CollabPanel { }; let can_collapse = match section { - Section::ActiveCall | Section::Channels | Section::Contacts => false, + Section::ActiveCall + | Section::Channels + | Section::Contacts + | Section::FavoriteChannels => false, + Section::ChannelInvites | Section::ContactRequests | Section::Online @@ -2932,11 +3055,17 @@ impl CollabPanel { .unwrap_or(px(240.)); let root_id = channel.root_id(); - div() - .h_6() + let is_favorited = self.is_channel_favorited(channel_id); + let (favorite_icon, favorite_color, favorite_tooltip) = if is_favorited { + (IconName::StarFilled, Color::Accent, "Remove from Favorites") + } else { + (IconName::Star, Color::Muted, "Add to Favorites") + }; + + h_flex() .id(channel_id.0 as usize) .group("") - .flex() + .h_6() .w_full() .when(!channel.is_root_channel(), |el| { el.on_drag(channel.clone(), move |channel, _, _, cx| { @@ -2966,6 +3095,7 @@ impl CollabPanel { .child( ListItem::new(channel_id.0 as usize) // Add one level of depth for the disclosure arrow. + .height(px(26.)) .indent_level(depth + 1) .indent_step_size(px(20.)) .toggle_state(is_selected || is_active) @@ -2991,78 +3121,105 @@ impl CollabPanel { ) }, )) - .start_slot( - div() - .relative() - .child( - Icon::new(if is_public { - IconName::Public - } else { - IconName::Hash - }) - .size(IconSize::Small) - .color(Color::Muted), - ) - .children(has_notes_notification.then(|| { - div() - .w_1p5() - .absolute() - .right(px(-1.)) - .top(px(-1.)) - .child(Indicator::dot().color(Color::Info)) - })), - ) .child( h_flex() - .id(channel_id.0 as usize) - .child(match string_match { - None => Label::new(channel.name.clone()).into_any_element(), - Some(string_match) => HighlightedLabel::new( - channel.name.clone(), - string_match.positions.clone(), - ) - .into_any_element(), - }) - .children(face_pile.map(|face_pile| face_pile.p_1())), + .id(format!("inside-{}", channel_id.0)) + .w_full() + .gap_1() + .child( + div() + .relative() + .child( + Icon::new(if is_public { + IconName::Public + } else { + IconName::Hash + }) + .size(IconSize::Small) + .color(Color::Muted), + ) + .children(has_notes_notification.then(|| { + div() + .w_1p5() + .absolute() + .right(px(-1.)) + .top(px(-1.)) + .child(Indicator::dot().color(Color::Info)) + })), + ) + .child( + h_flex() + .id(channel_id.0 as usize) + .child(match string_match { + None => Label::new(channel.name.clone()).into_any_element(), + Some(string_match) => HighlightedLabel::new( + channel.name.clone(), + string_match.positions.clone(), + ) + .into_any_element(), + }) + .children(face_pile.map(|face_pile| face_pile.p_1())), + ) + .tooltip({ + let channel_store = self.channel_store.clone(); + move |_window, cx| { + cx.new(|_| JoinChannelTooltip { + channel_store: channel_store.clone(), + channel_id, + has_notes_notification, + }) + .into() + } + }), ), ) .child( - h_flex().absolute().right(rems(0.)).h_full().child( - h_flex() - .h_full() - .bg(cx.theme().colors().background) - .rounded_l_sm() - .gap_1() - .px_1() - .child( - IconButton::new("channel_notes", IconName::Reader) - .style(ButtonStyle::Filled) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(if has_notes_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.open_channel_notes(channel_id, window, cx) - })) - .tooltip(Tooltip::text("Open channel notes")), - ) - .visible_on_hover(""), - ), - ) - .tooltip({ - let channel_store = self.channel_store.clone(); - move |_window, cx| { - cx.new(|_| JoinChannelTooltip { - channel_store: channel_store.clone(), - channel_id, - has_notes_notification, + h_flex() + .absolute() + .right_0() + .visible_on_hover("") + .h_full() + .pl_1() + .pr_1p5() + .gap_0p5() + .bg(cx.theme().colors().background.opacity(0.5)) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_favorite", favorite_icon) + .icon_size(IconSize::Small) + .icon_color(favorite_color) + .on_click(cx.listener(move |this, _, _window, cx| { + this.toggle_favorite_channel(channel_id, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + favorite_tooltip, + &ToggleSelectedChannelFavorite, + &focus_handle, + cx, + ) + }) }) - .into() - } - }) + .child({ + let focus_handle = self.focus_handle.clone(); + IconButton::new("channel_notes", IconName::Reader) + .icon_size(IconSize::Small) + .when(!has_notes_notification, |this| { + this.icon_color(Color::Muted) + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_channel_notes(channel_id, window, cx) + })) + .tooltip(move |_window, cx| { + Tooltip::for_action_in( + "Open Channel Notes", + &OpenSelectedChannelNotes, + &focus_handle, + cx, + ) + }) + }), + ) } fn render_channel_editor( @@ -3161,6 +3318,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::show_inline_context_menu)) .on_action(cx.listener(CollabPanel::rename_selected_channel)) .on_action(cx.listener(CollabPanel::open_selected_channel_notes)) + .on_action(cx.listener(CollabPanel::toggle_selected_channel_favorite)) .on_action(cx.listener(CollabPanel::collapse_selected_channel)) .on_action(cx.listener(CollabPanel::expand_selected_channel)) .on_action(cx.listener(CollabPanel::start_move_selected_channel)) @@ -3382,7 +3540,7 @@ impl Render for JoinChannelTooltip { .channel_participants(self.channel_id); container - .child(Label::new("Join channel")) + .child(Label::new("Join Channel")) .children(participants.iter().map(|participant| { h_flex() .gap_2() From cd05f190546de4f3206cbce59f2f368986c81b3c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:36:04 +0000 Subject: [PATCH 40/45] Pin dependencies (#52522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/github-script](https://redirect.github.com/actions/github-script) | action | pinDigest | → `f28e40c` | | [actions/setup-python](https://redirect.github.com/actions/setup-python) | action | pinDigest | → `a26af69` | | [namespacelabs/nscloud-cache-action](https://redirect.github.com/namespacelabs/nscloud-cache-action) | action | pinDigest | → `a90bb5d` | | [taiki-e/install-action](https://redirect.github.com/taiki-e/install-action) | action | pinDigest | → `921e2c9` | | [taiki-e/install-action](https://redirect.github.com/taiki-e/install-action) | action | pinDigest | → `b4f2d5c` | | [withastro/automation](https://redirect.github.com/withastro/automation) | action | pinDigest | → `a5bd0c5` | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/15138) for more information. --- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Marshall Bowers --- .github/actions/run_tests/action.yml | 2 +- .github/actions/run_tests_windows/action.yml | 2 +- .github/workflows/autofix_pr.yml | 2 +- .github/workflows/background_agent_mvp.yml | 2 +- .../community_champion_auto_labeler.yml | 2 +- .github/workflows/compare_perf.yml | 2 +- .github/workflows/congrats.yml | 4 +-- .github/workflows/deploy_collab.yml | 6 ++-- .github/workflows/extension_bump.yml | 6 ++-- .github/workflows/extension_tests.yml | 6 ++-- .../workflows/extension_workflow_rollout.yml | 4 +-- .github/workflows/publish_extension_cli.yml | 4 +-- .github/workflows/release.yml | 14 ++++----- .github/workflows/release_nightly.yml | 4 +-- .github/workflows/run_agent_evals.yml | 2 +- .github/workflows/run_bundling.yml | 4 +-- .github/workflows/run_cron_unit_evals.yml | 4 +-- .github/workflows/run_tests.yml | 30 +++++++++---------- .github/workflows/run_unit_evals.yml | 4 +-- .../xtask/src/tasks/workflows/compare_perf.rs | 6 +++- .../src/tasks/workflows/extension_bump.rs | 13 ++++++-- .../workflows/extension_workflow_rollout.rs | 2 +- tooling/xtask/src/tasks/workflows/steps.rs | 30 +++++++++++++++---- 23 files changed, 93 insertions(+), 62 deletions(-) diff --git a/.github/actions/run_tests/action.yml b/.github/actions/run_tests/action.yml index a071aba3a87dcf8e8f48f740115cfddf48b9f805..610c334a65c3a3817ab0ee2bb7356a923643092b 100644 --- a/.github/actions/run_tests/action.yml +++ b/.github/actions/run_tests/action.yml @@ -5,7 +5,7 @@ runs: using: "composite" steps: - name: Install nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/actions/run_tests_windows/action.yml b/.github/actions/run_tests_windows/action.yml index 307b73f363b7d5fd7a3c9e5082c4f17d622ec165..3752cbb50d538459ea58d2219e591d1abbda6247 100644 --- a/.github/actions/run_tests_windows/action.yml +++ b/.github/actions/run_tests_windows/action.yml @@ -12,7 +12,7 @@ runs: steps: - name: Install test runner working-directory: ${{ inputs.working-directory }} - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c # nextest - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/autofix_pr.yml b/.github/workflows/autofix_pr.yml index 36a459c94b9ea2e35b683bb957d33db362bee262..f055c078cf4f814e342697e311ad5660f68f4624 100644 --- a/.github/workflows/autofix_pr.yml +++ b/.github/workflows/autofix_pr.yml @@ -31,7 +31,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/background_agent_mvp.yml b/.github/workflows/background_agent_mvp.yml index f8c654a293c26e50ccd5194742d7a6977009fb48..2f048d572df6fb45368c6d7aece574e83c9e7949 100644 --- a/.github/workflows/background_agent_mvp.yml +++ b/.github/workflows/background_agent_mvp.yml @@ -50,7 +50,7 @@ jobs: "${HOME}/.local/bin/droid" --version - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/community_champion_auto_labeler.yml b/.github/workflows/community_champion_auto_labeler.yml index fa44afc16dcaee4c1e1176b9344aed476ac6d8e5..82a9e274d64725b0e55c6ced46ca64ac3890e35e 100644 --- a/.github/workflows/community_champion_auto_labeler.yml +++ b/.github/workflows/community_champion_auto_labeler.yml @@ -12,7 +12,7 @@ jobs: runs-on: namespace-profile-2x4-ubuntu-2404 steps: - name: Check if author is a community champion and apply label - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: COMMUNITY_CHAMPIONS: | 0x2CA diff --git a/.github/workflows/compare_perf.yml b/.github/workflows/compare_perf.yml index f6c4253573364269b5b28ee9773a3885381ddfe2..2b2154ce9bd14c85d0f0d10e95c4065a458006a1 100644 --- a/.github/workflows/compare_perf.yml +++ b/.github/workflows/compare_perf.yml @@ -33,7 +33,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: compare_perf::run_perf::install_hyperfine - uses: taiki-e/install-action@hyperfine + uses: taiki-e/install-action@b4f2d5cb8597b15997c8ede873eb6185efc5f0ad - name: steps::git_checkout run: git fetch origin "$REF_NAME" && git checkout "$REF_NAME" env: diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml index a57be7a75ad13829b096477da015ac6a43a325d7..4866b3c33bc6bab9f9d20ac1701b7d6535b356ee 100644 --- a/.github/workflows/congrats.yml +++ b/.github/workflows/congrats.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Get PR info and check if author is external id: check - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: github-token: ${{ secrets.CONGRATSBOT_GITHUB_TOKEN }} script: | @@ -57,7 +57,7 @@ jobs: congrats: needs: check-author if: needs.check-author.outputs.should_congratulate == 'true' - uses: withastro/automation/.github/workflows/congratsbot.yml@main + uses: withastro/automation/.github/workflows/congratsbot.yml@a5bd0c5748c4d56e687cdd558064f9ee8adfb1f2 # main with: EMOJIS: 🎉,🎊,🧑‍🚀,🥳,🙌,🚀,🦀,🔥,🚢 secrets: diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 9ba1ee7d8be38b1fd3b3147c679afde03b98dcd7..5a3eff186814128ebb3973642040d9228f0e87fd 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -26,7 +26,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -57,7 +57,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -66,7 +66,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: deploy_collab::tests::run_collab_tests diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 72bc340a814e340ef8e716e3db4cb156aee40e8f..b4cbac4ec8c0ab37ebad73eb96c2ee074ca969a6 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -84,7 +84,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -187,7 +187,7 @@ jobs: CURRENT_VERSION: ${{ needs.check_version_changed.outputs.current_version }} WORKING_DIR: ${{ inputs.working-directory }} - name: extension_bump::create_version_tag - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: |- github.rest.git.createRef({ @@ -239,7 +239,7 @@ jobs: env: COMMITTER_TOKEN: ${{ steps.generate-token.outputs.token }} - name: extension_bump::enable_automerge_if_staff - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/extension_tests.yml b/.github/workflows/extension_tests.yml index 066e1ab1739a0fabb0d6ce8f0f7f4832cfbdc228..622f4c8f1034b4ec0c7625a361ecdb6fb84d9429 100644 --- a/.github/workflows/extension_tests.yml +++ b/.github/workflows/extension_tests.yml @@ -77,7 +77,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -97,7 +97,7 @@ jobs: env: PACKAGE_NAME: ${{ steps.get-package-name.outputs.package_name }} - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: extension_tests::run_nextest run: 'cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n ''s|host: ||p'')"' env: @@ -131,7 +131,7 @@ jobs: wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension" chmod +x "$GITHUB_WORKSPACE/zed-extension" - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/extension_workflow_rollout.yml b/.github/workflows/extension_workflow_rollout.yml index 4dfaf708f738ef5b5fe8d8687d80690af040eba9..5bb315a730d8f25f6e1eccbbe5e1734e1cda6d99 100644 --- a/.github/workflows/extension_workflow_rollout.yml +++ b/.github/workflows/extension_workflow_rollout.yml @@ -57,7 +57,7 @@ jobs: PREV_COMMIT: ${{ steps.prev-tag.outputs.prev_commit }} - id: list-repos name: extension_workflow_rollout::fetch_extension_repos::get_repositories - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | const repos = await github.paginate(github.rest.repos.listForOrg, { @@ -81,7 +81,7 @@ jobs: return filteredRepos; result-encoding: json - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/publish_extension_cli.yml b/.github/workflows/publish_extension_cli.yml index e7ba9075db8e552b5050e5e65fef9aeac872a776..17248cea11307d4604b05d5160212a4f38e2874a 100644 --- a/.github/workflows/publish_extension_cli.yml +++ b/.github/workflows/publish_extension_cli.yml @@ -18,7 +18,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -48,7 +48,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec01217c6acb7ab9a4afd5b65aa1f98a9740aab1..b651e7046bc7d603a7a829ce1b59fcf0468bdd3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -31,7 +31,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -66,7 +66,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -79,7 +79,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -159,7 +159,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -191,7 +191,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -257,7 +257,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index a60ae34c27a0f955d7b068187e88c0a463329a86..30d0e1fbf9c7955d1216e2e3d7ac51a9a51f4416 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -410,7 +410,7 @@ jobs: with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -444,7 +444,7 @@ jobs: with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix diff --git a/.github/workflows/run_agent_evals.yml b/.github/workflows/run_agent_evals.yml index 218d84e7afa39c2333fcd65bc05c5dc07bf2db8c..83fd91b037fd982a25845b10aaff561b42af5fc5 100644 --- a/.github/workflows/run_agent_evals.yml +++ b/.github/workflows/run_agent_evals.yml @@ -28,7 +28,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/run_bundling.yml b/.github/workflows/run_bundling.yml index bc16c2ee9c4f72969a42d04745ba3953d8462469..71b2e4d5fa0b386334bb8acab8e732f1c7d0ad93 100644 --- a/.github/workflows/run_bundling.yml +++ b/.github/workflows/run_bundling.yml @@ -278,7 +278,7 @@ jobs: with: clean: false - name: steps::cache_nix_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: nix - name: nix_build::build_nix::install_nix @@ -310,7 +310,7 @@ jobs: with: clean: false - name: steps::cache_nix_store_macos - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: path: ~/nix-cache - name: nix_build::build_nix::install_nix diff --git a/.github/workflows/run_cron_unit_evals.yml b/.github/workflows/run_cron_unit_evals.yml index 46ed2e380afe7618aa835d5e122955504283ee97..7bb7f79473eb4dae170eb18edd454b7ae35d13e8 100644 --- a/.github/workflows/run_cron_unit_evals.yml +++ b/.github/workflows/run_cron_unit_evals.yml @@ -29,7 +29,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -38,7 +38,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 746941b08c8d6e67148af0651f41cc651a13b2eb..9f335a76beab036d97fe5555cd049ea46b4f87f0 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -128,7 +128,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -212,7 +212,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -247,7 +247,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -278,7 +278,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -356,7 +356,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -369,7 +369,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache @@ -411,7 +411,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -420,7 +420,7 @@ jobs: with: node-version: '20' - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 300 - name: steps::setup_sccache @@ -453,7 +453,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -501,7 +501,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -542,7 +542,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -580,7 +580,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -619,7 +619,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -661,7 +661,7 @@ jobs: with: clean: false - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -689,7 +689,7 @@ jobs: env: ACTIONLINT_BIN: ${{ steps.get_actionlint.outputs.executable }} - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup diff --git a/.github/workflows/run_unit_evals.yml b/.github/workflows/run_unit_evals.yml index 670a6e6b0fc19940b598221e439a68b656c7ca0f..1bf75188832668f40a24c4d3452940bf05fcd3fd 100644 --- a/.github/workflows/run_unit_evals.yml +++ b/.github/workflows/run_unit_evals.yml @@ -32,7 +32,7 @@ jobs: mkdir -p ./../.cargo cp ./.cargo/ci-config.toml ./../.cargo/config.toml - name: steps::cache_rust_dependencies_namespace - uses: namespacelabs/nscloud-cache-action@v1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 with: cache: rust path: ~/.rustup @@ -41,7 +41,7 @@ jobs: - name: steps::download_wasi_sdk run: ./script/download-wasi-sdk - name: steps::cargo_install_nextest - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@921e2c9f7148d7ba14cd819f417db338f63e733c - name: steps::clear_target_dir_if_large run: ./script/clear-target-dir-if-larger-than 250 - name: steps::setup_sccache diff --git a/tooling/xtask/src/tasks/workflows/compare_perf.rs b/tooling/xtask/src/tasks/workflows/compare_perf.rs index 74a1fbdc389e2b0dacdf579d9ee96a0366eb5c01..39f17b8d148bd6022913fdf5097368690cbd0fd0 100644 --- a/tooling/xtask/src/tasks/workflows/compare_perf.rs +++ b/tooling/xtask/src/tasks/workflows/compare_perf.rs @@ -42,7 +42,11 @@ pub fn run_perf( } fn install_hyperfine() -> Step { - named::uses("taiki-e", "install-action", "hyperfine") + named::uses( + "taiki-e", + "install-action", + "b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // hyperfine + ) } fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step { diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index a69856ed3333810dcada4b8a8ac5b6cadee12e23..a1c2abc169f4348fd04a529c5a5b10b412464c9b 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -145,7 +145,12 @@ fn create_version_label( } fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step { - named::uses("actions", "github-script", "v7").with( + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) + .with( Input::default() .add( "script", @@ -413,7 +418,11 @@ fn enable_automerge_if_staff( pull_request_number: StepOutput, generated_token: StepOutput, ) -> Step { - named::uses("actions", "github-script", "v7") + named::uses( + "actions", + "github-script", + "f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7 + ) .add_with(("github-token", generated_token.to_string())) .add_with(( "script", diff --git a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs index 418b7f9e4617ad0ca42b666b7eb4d7d9614895a7..3a5d14603f97b43aacb581aaf3b970bac31b701f 100644 --- a/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs +++ b/tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs @@ -50,7 +50,7 @@ pub(crate) fn extension_workflow_rollout() -> Workflow { fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) { fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step, StepOutput) { - let step = named::uses("actions", "github-script", "v7") + let step = named::uses("actions", "github-script", "f28e40c7f34bde8b3046d885e986cb6290c5673b") .id("list-repos") .add_with(( "script", diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 1be6a779f33bfb411ccdd5ac4d979b07dc283e50..ebdd9b30538eb389a267a1c2fdb1822eec1d3a54 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -177,7 +177,11 @@ pub fn cargo_fmt() -> Step { } pub fn cargo_install_nextest() -> Step { - named::uses("taiki-e", "install-action", "nextest") + named::uses( + "taiki-e", + "install-action", + "921e2c9f7148d7ba14cd819f417db338f63e733c", // nextest + ) } pub fn setup_cargo_config(platform: Platform) -> Step { @@ -230,9 +234,13 @@ pub fn install_rustup_target(target: &str) -> Step { } pub fn cache_rust_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1") - .add_with(("cache", "rust")) - .add_with(("path", "~/.rustup")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "rust")) + .add_with(("path", "~/.rustup")) } pub fn setup_sccache(platform: Platform) -> Step { @@ -259,14 +267,24 @@ pub fn show_sccache_stats(platform: Platform) -> Step { } pub fn cache_nix_dependencies_namespace() -> Step { - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("cache", "nix")) } pub fn cache_nix_store_macos() -> Step { // On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix` // cannot mount or symlink there. Instead we cache a user-writable directory and // use nix-store --import/--export in separate steps to transfer store paths. - named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache")) + named::uses( + "namespacelabs", + "nscloud-cache-action", + "a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // v1 + ) + .add_with(("path", "~/nix-cache")) } pub fn setup_linux() -> Step { From 086bece3f11a91679b046d9139d398c54059a0b2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 26 Mar 2026 13:18:39 -0700 Subject: [PATCH 41/45] Avoid flicker in flexible width agent panel's size when resizing workspace (#52519) This improves the rendering of flexible-width panels so that they do not lag behind by one frame when tracking workspace size changes. I've also simplified the code for panel size management in the workspace. Release Notes: - N/A --- crates/workspace/src/dock.rs | 77 ++---------- crates/workspace/src/workspace.rs | 197 ++++++++++++++++-------------- 2 files changed, 113 insertions(+), 161 deletions(-) diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 131c02e9c885b66ddf32ed6d2a0dfb01d2764a49..e0870503b7c64bb23218d897bc6b4828d315c8b8 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -776,17 +776,9 @@ impl Dock { } } - pub fn panel_size(&self, panel: &dyn PanelHandle, window: &Window, cx: &App) -> Option { - self.panel_entries - .iter() - .find(|entry| entry.panel.panel_id() == panel.panel_id()) - .map(|entry| self.resolved_panel_size(entry, window, cx)) - } - - pub fn active_panel_size(&self, window: &Window, cx: &App) -> Option { + pub fn active_panel_size(&self) -> Option { if self.is_open { - self.active_panel_entry() - .map(|entry| self.resolved_panel_size(entry, window, cx)) + self.active_panel_entry().map(|entry| entry.size_state) } else { None } @@ -947,28 +939,6 @@ impl Dock { } } - fn resolved_panel_size(&self, entry: &PanelEntry, window: &Window, cx: &App) -> Pixels { - if self.position.axis() == Axis::Horizontal - && entry.panel.supports_flexible_size(window, cx) - { - if let Some(workspace) = self.workspace.upgrade() { - let workspace = workspace.read(cx); - return resolve_panel_size( - entry.size_state, - entry.panel.as_ref(), - self.position, - workspace, - window, - cx, - ); - } - } - entry - .size_state - .size - .unwrap_or_else(|| entry.panel.default_size(window, cx)) - } - pub(crate) fn load_persisted_size_state( workspace: &Workspace, panel_key: &'static str, @@ -988,41 +958,10 @@ impl Dock { } } -pub(crate) fn resolve_panel_size( - size_state: PanelSizeState, - panel: &dyn PanelHandle, - position: DockPosition, - workspace: &Workspace, - window: &Window, - cx: &App, -) -> Pixels { - if position.axis() == Axis::Horizontal && panel.supports_flexible_size(window, cx) { - let ratio = size_state - .flexible_size_ratio - .or_else(|| workspace.default_flexible_dock_ratio(position)); - - if let Some(ratio) = ratio { - return workspace - .flexible_dock_size(position, ratio, window, cx) - .unwrap_or_else(|| { - size_state - .size - .unwrap_or_else(|| panel.default_size(window, cx)) - }); - } - } - - size_state - .size - .unwrap_or_else(|| panel.default_size(window, cx)) -} - impl Render for Dock { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let dispatch_context = Self::dispatch_context(); if let Some(entry) = self.visible_entry() { - let size = self.resolved_panel_size(entry, window, cx); - let position = self.position; let create_resize_handle = || { let handle = div() @@ -1091,8 +1030,10 @@ impl Render for Dock { .border_color(cx.theme().colors().border) .overflow_hidden() .map(|this| match self.position().axis() { - Axis::Horizontal => this.w(size).h_full().flex_row(), - Axis::Vertical => this.h(size).w_full().flex_col(), + // Width and height are always set on the workspace wrapper in + // render_dock, so fill whatever space the wrapper provides. + Axis::Horizontal => this.w_full().h_full().flex_row(), + Axis::Vertical => this.h_full().w_full().flex_col(), }) .map(|this| match self.position() { DockPosition::Left => this.border_r_1(), @@ -1102,8 +1043,8 @@ impl Render for Dock { .child( div() .map(|this| match self.position().axis() { - Axis::Horizontal => this.min_w(size).h_full(), - Axis::Vertical => this.min_h(size).w_full(), + Axis::Horizontal => this.w_full().h_full(), + Axis::Vertical => this.h_full().w_full(), }) .child( entry diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index fcb46039921f94e9a4a8b717f62ec9f709955f40..1d7c71c1ea4d66c65155a6491b7cf8a526256d82 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2208,30 +2208,29 @@ impl Workspace { did_set } - pub fn flexible_dock_size( - &self, - position: DockPosition, - ratio: f32, - window: &Window, - cx: &App, - ) -> Option { - if position.axis() != Axis::Horizontal { - return None; + fn dock_size(&self, dock: &Dock, window: &Window, cx: &App) -> Option { + let panel = dock.active_panel()?; + let size_state = dock + .stored_panel_size_state(panel.as_ref()) + .unwrap_or_default(); + let position = dock.position(); + + if position.axis() == Axis::Horizontal + && panel.supports_flexible_size(window, cx) + && let Some(ratio) = size_state + .flexible_size_ratio + .or_else(|| self.default_flexible_dock_ratio(position)) + && let Some(available_width) = + self.available_width_for_horizontal_dock(position, window, cx) + { + return Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE)); } - let available_width = self.available_width_for_horizontal_dock(position, window, cx)?; - Some((available_width * ratio.clamp(0.0, 1.0)).max(RESIZE_HANDLE_SIZE)) - } - - pub fn resolved_dock_panel_size( - &self, - dock: &Dock, - panel: &dyn PanelHandle, - window: &Window, - cx: &App, - ) -> Pixels { - let size_state = dock.stored_panel_size_state(panel).unwrap_or_default(); - dock::resolve_panel_size(size_state, panel, dock.position(), self, window, cx) + Some( + size_state + .size + .unwrap_or_else(|| panel.default_size(window, cx)), + ) } pub fn flexible_dock_ratio_for_size( @@ -4908,10 +4907,7 @@ impl Workspace { if let Some(dock_entity) = active_dock { let dock = dock_entity.read(cx); - let Some(panel_size) = dock - .active_panel() - .map(|panel| self.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) - else { + let Some(panel_size) = self.dock_size(&dock, window, cx) else { return; }; match dock.position() { @@ -7274,14 +7270,46 @@ impl Workspace { leader_border_for_pane(follower_states, &pane, window, cx) }); - Some( - div() - .flex() - .flex_none() - .overflow_hidden() - .child(dock.clone()) - .children(leader_border), - ) + let mut container = div() + .flex() + .overflow_hidden() + .flex_none() + .child(dock.clone()) + .children(leader_border); + + // Apply sizing only when the dock is open. When closed the dock is still + // included in the element tree so its focus handle remains mounted — without + // this, toggle_panel_focus cannot focus the panel when the dock is closed. + let dock = dock.read(cx); + if let Some(panel) = dock.visible_panel() { + let size_state = dock.stored_panel_size_state(panel.as_ref()); + if position.axis() == Axis::Horizontal { + if let Some(ratio) = size_state + .and_then(|state| state.flexible_size_ratio) + .or_else(|| self.default_flexible_dock_ratio(position)) + && panel.supports_flexible_size(window, cx) + { + let ratio = ratio.clamp(0.001, 0.999); + let grow = ratio / (1.0 - ratio); + let style = container.style(); + style.flex_grow = Some(grow); + style.flex_shrink = Some(1.0); + style.flex_basis = Some(relative(0.).into()); + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.w(size); + } + } else { + let size = size_state + .and_then(|state| state.size) + .unwrap_or_else(|| panel.default_size(window, cx)); + container = container.h(size); + } + } + + Some(container) } pub fn for_window(window: &Window, cx: &App) -> Option> { @@ -7351,18 +7379,17 @@ impl Workspace { } } - fn adjust_dock_size_by_px( + fn resize_dock( &mut self, - panel_size: Pixels, dock_pos: DockPosition, - px: Pixels, + new_size: Pixels, window: &mut Window, cx: &mut Context, ) { match dock_pos { - DockPosition::Left => self.resize_left_dock(panel_size + px, window, cx), - DockPosition::Right => self.resize_right_dock(panel_size + px, window, cx), - DockPosition::Bottom => self.resize_bottom_dock(panel_size + px, window, cx), + DockPosition::Left => self.resize_left_dock(new_size, window, cx), + DockPosition::Right => self.resize_right_dock(new_size, window, cx), + DockPosition::Bottom => self.resize_bottom_dock(new_size, window, cx), } } @@ -7806,14 +7833,10 @@ fn adjust_active_dock_size_by_px( return; }; let dock = active_dock.read(cx); - let Some(panel_size) = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) - else { + let Some(panel_size) = workspace.dock_size(&dock, window, cx) else { return; }; - let dock_pos = dock.position(); - workspace.adjust_dock_size_by_px(panel_size, dock_pos, px, window, cx); + workspace.resize_dock(dock.position(), panel_size + px, window, cx); } fn adjust_open_docks_size_by_px( @@ -7828,22 +7851,18 @@ fn adjust_open_docks_size_by_px( .filter_map(|dock_entity| { let dock = dock_entity.read(cx); if dock.is_open() { - let panel_size = dock.active_panel().map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - })?; let dock_pos = dock.position(); - Some((panel_size, dock_pos, px)) + let panel_size = workspace.dock_size(&dock, window, cx)?; + Some((dock_pos, panel_size + px)) } else { None } }) .collect::>(); - docks - .into_iter() - .for_each(|(panel_size, dock_pos, offset)| { - workspace.adjust_dock_size_by_px(panel_size, dock_pos, offset, window, cx); - }); + for (position, new_size) in docks { + workspace.resize_dock(position, new_size, window, cx); + } } impl Focusable for Workspace { @@ -12286,11 +12305,8 @@ mod tests { let dock = workspace.right_dock().read(cx); let workspace_width = workspace.bounds.size.width; - let initial_width = dock - .active_panel() - .map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - }) + let initial_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should have an initial width"); assert_eq!(initial_width, workspace_width / 2.); @@ -12298,11 +12314,8 @@ mod tests { workspace.resize_right_dock(px(300.), window, cx); let dock = workspace.right_dock().read(cx); - let resized_width = dock - .active_panel() - .map(|panel| { - workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx) - }) + let resized_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should keep its resized width"); assert_eq!(resized_width, px(300.)); @@ -12322,9 +12335,8 @@ mod tests { workspace.toggle_dock(DockPosition::Right, window, cx); let dock = workspace.right_dock().read(cx); - let reopened_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let reopened_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should restore when reopened"); assert_eq!(reopened_width, resized_width); @@ -12351,9 +12363,8 @@ mod tests { ); let dock = workspace.right_dock().read(cx); - let split_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let split_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should keep its user-resized proportion"); assert_eq!(split_width, px(300.)); @@ -12361,9 +12372,8 @@ mod tests { workspace.bounds.size.width = px(1600.); let dock = workspace.right_dock().read(cx); - let resized_window_width = dock - .active_panel() - .map(|panel| workspace.resolved_dock_panel_size(&dock, panel.as_ref(), window, cx)) + let resized_window_width = workspace + .dock_size(&dock, window, cx) .expect("flexible dock should preserve proportional size on window resize"); assert_eq!( @@ -12533,9 +12543,8 @@ mod tests { workspace.toggle_dock(DockPosition::Left, window, cx); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should have an active panel"); assert_eq!( @@ -12557,9 +12566,8 @@ mod tests { ); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should still have an active panel after vertical split"); assert_eq!( @@ -12578,15 +12586,13 @@ mod tests { workspace.toggle_dock(DockPosition::Right, window, cx); let right_dock = workspace.right_dock().read(cx); - let right_width = right_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&right_dock, p.as_ref(), window, cx)) + let right_width = workspace + .dock_size(&right_dock, window, cx) .expect("right dock should have an active panel"); let left_dock = workspace.left_dock().read(cx); - let left_width = left_dock - .active_panel() - .map(|p| workspace.resolved_dock_panel_size(&left_dock, p.as_ref(), window, cx)) + let left_width = workspace + .dock_size(&left_dock, window, cx) .expect("left dock should still have an active panel"); let available_width = workspace.bounds.size.width - right_width; @@ -12650,8 +12656,8 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(300.) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(300.)) ); workspace.resize_left_dock(px(1337.), window, cx); @@ -12684,7 +12690,12 @@ mod tests { panel_1.panel_id() ); assert_eq!( - right_dock.read(cx).active_panel_size(window, cx).unwrap(), + right_dock + .read(cx) + .active_panel_size() + .unwrap() + .size + .unwrap(), px(1337.) ); @@ -12722,8 +12733,8 @@ mod tests { panel_1.panel_id() ); assert_eq!( - left_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(1337.) + workspace.dock_size(&left_dock.read(cx), window, cx), + Some(px(1337.)) ); // And the right dock should be closed as it no longer has any panels. assert!(!workspace.right_dock().read(cx).is_open()); @@ -12739,8 +12750,8 @@ mod tests { // since the panel orientation changed from vertical to horizontal. let bottom_dock = workspace.bottom_dock(); assert_eq!( - bottom_dock.read(cx).active_panel_size(window, cx).unwrap(), - px(300.), + workspace.dock_size(&bottom_dock.read(cx), window, cx), + Some(px(300.)) ); // Close bottom dock and move panel_1 back to the left. bottom_dock.update(cx, |bottom_dock, cx| { From d3f5fc8466444c21332bfd70ba709d92c1903c88 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:23:03 -0300 Subject: [PATCH 42/45] agent_ui: Display an activity bar for subagents waiting for permission (#52460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/52346 Given the parallel nature of subagents calls, it's possible that there is a subagent way out of view that's waiting for the user to give permissions. Right now, it's kind of hard to know this and you may think something wrong is happening given the thread generation isn't making any progress. This PR adds an "activity bar" to the thread view that displays subagents on a "waiting for confirmation" status. We display the subagent's summary label as well as allow clicking on it to quickly scrolling to that subagent. Screenshot 2026-03-25 at 10  09@2x Release Notes: - Agent: Improved the experience of interacting with subagents waiting for confirmation. --- crates/agent_ui/src/conversation_view.rs | 14 ++ .../src/conversation_view/thread_view.rs | 130 +++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index f5c91cf342c69badf2915e21c17f819963416ec5..a3c87c8d66031f553bcd4cb8dc82c681a0b79c94 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -237,6 +237,20 @@ impl Conversation { )) } + pub fn subagents_awaiting_permission(&self, cx: &App) -> Vec<(acp::SessionId, usize)> { + self.permission_requests + .iter() + .filter_map(|(session_id, tool_call_ids)| { + let thread = self.threads.get(session_id)?; + if thread.read(cx).parent_session_id().is_some() && !tool_call_ids.is_empty() { + Some((session_id.clone(), tool_call_ids.len())) + } else { + None + } + }) + .collect() + } + pub fn authorize_pending_tool_call( &mut self, session_id: &acp::SessionId, diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 2778a5b4a2583a0b232f86184f33c4446bc18ea5..0c2ecf4bbefdbc2eb0431c0d7c094dc9f5b2155b 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -2155,7 +2155,14 @@ impl ThreadView { let plan = thread.plan(); let queue_is_empty = !self.has_queued_messages(); - if changed_buffers.is_empty() && plan.is_empty() && queue_is_empty { + let subagents_awaiting_permission = self.render_subagents_awaiting_permission(cx); + let has_subagents_awaiting = subagents_awaiting_permission.is_some(); + + if changed_buffers.is_empty() + && plan.is_empty() + && queue_is_empty + && !has_subagents_awaiting + { return None; } @@ -2183,6 +2190,14 @@ impl ThreadView { blur_radius: px(2.), spread_radius: px(0.), }]) + .when_some(subagents_awaiting_permission, |this, element| { + this.child(element) + }) + .when( + has_subagents_awaiting + && (!plan.is_empty() || !changed_buffers.is_empty() || !queue_is_empty), + |this| this.child(Divider::horizontal().color(DividerColor::Border)), + ) .when(!plan.is_empty(), |this| { this.child(self.render_plan_summary(plan, window, cx)) .when(plan_expanded, |parent| { @@ -2442,6 +2457,119 @@ impl ThreadView { ) } + fn render_subagents_awaiting_permission(&self, cx: &Context) -> Option { + let awaiting = self.conversation.read(cx).subagents_awaiting_permission(cx); + + if awaiting.is_empty() { + return None; + } + + let thread = self.thread.read(cx); + let entries = thread.entries(); + let mut subagent_items: Vec<(SharedString, usize)> = Vec::new(); + + for (session_id, _) in &awaiting { + for (entry_ix, entry) in entries.iter().enumerate() { + if let AgentThreadEntry::ToolCall(tool_call) = entry { + if let Some(info) = &tool_call.subagent_session_info { + if &info.session_id == session_id { + let subagent_summary: SharedString = { + let summary_text = tool_call.label.read(cx).source().to_string(); + if !summary_text.is_empty() { + summary_text.into() + } else { + "Subagent".into() + } + }; + subagent_items.push((subagent_summary, entry_ix)); + break; + } + } + } + } + } + + if subagent_items.is_empty() { + return None; + } + + let item_count = subagent_items.len(); + + Some( + v_flex() + .child( + h_flex() + .py_1() + .px_2() + .w_full() + .gap_1() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Label::new("Subagents Awaiting Permission:") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new(item_count.to_string()).size(LabelSize::Small)), + ) + .child( + v_flex().children(subagent_items.into_iter().enumerate().map( + |(ix, (label, entry_ix))| { + let is_last = ix == item_count - 1; + let group = format!("group-{}", entry_ix); + + h_flex() + .cursor_pointer() + .id(format!("subagent-permission-{}", entry_ix)) + .group(&group) + .p_1() + .pl_2() + .min_w_0() + .w_full() + .gap_1() + .justify_between() + .bg(cx.theme().colors().editor_background) + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .when(!is_last, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Circle) + .size(IconSize::XSmall) + .color(Color::Warning), + ) + .child( + Label::new(label) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .child( + div().visible_on_hover(&group).child( + Label::new("Scroll to Subagent") + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.list_state.scroll_to(ListOffset { + item_ix: entry_ix, + offset_in_item: px(0.0), + }); + cx.notify(); + })) + }, + )), + ) + .into_any(), + ) + } + fn render_message_queue_summary( &self, _window: &mut Window, From 73226701c022acdac3dc7b20a39717705da6614b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 18:23:19 -0300 Subject: [PATCH 43/45] agent_ui: Move fully complete plan to the thread view (#52462) When a plan generate by the plan tool fully completes, there's no need for that to be in the activity bar anymore. It's complete and in the next turn, the agent may come up with another plan and the cycle restarts. So, this PR moves a fully complete plan to the thread view, so that it stays as part of a given turn: image The way this PR does this is by adding a new entry to `AgentThreadEntry` and snapshotting the completed plan so we can display it properly in the thread. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 37 +++++++- crates/agent/src/agent.rs | 3 + .../src/conversation_view/thread_view.rs | 84 ++++++++++++++++++- crates/agent_ui/src/entry_view_state.rs | 18 ++-- 4 files changed, 130 insertions(+), 12 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index df59c67bb4576e34f76539df34147fb4606bb9f3..f33732f1e0f3623df5ce6833356f3547c5781adb 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -160,6 +160,7 @@ pub enum AgentThreadEntry { UserMessage(UserMessage), AssistantMessage(AssistantMessage), ToolCall(ToolCall), + CompletedPlan(Vec), } impl AgentThreadEntry { @@ -168,6 +169,7 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.indented, Self::AssistantMessage(message) => message.indented, Self::ToolCall(_) => false, + Self::CompletedPlan(_) => false, } } @@ -176,6 +178,14 @@ impl AgentThreadEntry { Self::UserMessage(message) => message.to_markdown(cx), Self::AssistantMessage(message) => message.to_markdown(cx), Self::ToolCall(tool_call) => tool_call.to_markdown(cx), + Self::CompletedPlan(entries) => { + let mut md = String::from("## Plan\n\n"); + for entry in entries { + let source = entry.content.read(cx).source().to_string(); + md.push_str(&format!("- [x] {}\n", source)); + } + md + } } } @@ -1298,7 +1308,9 @@ impl AcpThread { status: ToolCallStatus::WaitingForConfirmation { .. }, .. }) => return true, - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } false @@ -1320,7 +1332,9 @@ impl AcpThread { ) if call.diffs().next().is_some() => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1337,7 +1351,9 @@ impl AcpThread { }) => { return true; } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } @@ -1348,7 +1364,9 @@ impl AcpThread { for entry in self.entries.iter().rev() { match entry { AgentThreadEntry::UserMessage(..) => return false, - AgentThreadEntry::AssistantMessage(..) => continue, + AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) => { + continue; + } AgentThreadEntry::ToolCall(..) => return true, } } @@ -2065,6 +2083,13 @@ impl AcpThread { cx.notify(); } + pub fn snapshot_completed_plan(&mut self, cx: &mut Context) { + if !self.plan.is_empty() && self.plan.stats().pending == 0 { + let completed_entries = std::mem::take(&mut self.plan.entries); + self.push_entry(AgentThreadEntry::CompletedPlan(completed_entries), cx); + } + } + fn clear_completed_plan_entries(&mut self, cx: &mut Context) { self.plan .entries @@ -2223,6 +2248,10 @@ impl AcpThread { this.mark_pending_tools_as_canceled(); } + if !canceled { + this.snapshot_completed_plan(cx); + } + // Handle refusal - distinguish between user prompt and tool call refusals if let acp::StopReason::Refusal = r.stop_reason { this.had_error = true; diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index f36d8c0497430c27c7cafd99445c8baad18406f5..b7aa9d1e311016f572928993e049798c2b5e3bb2 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -942,6 +942,9 @@ impl NativeAgent { NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx) }) .await?; + acp_thread.update(cx, |thread, cx| { + thread.snapshot_completed_plan(cx); + }); Ok(acp_thread) }) } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 0c2ecf4bbefdbc2eb0431c0d7c094dc9f5b2155b..4ebe196e7ca7de9c6341925676423bdc4a8d8d38 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1,7 +1,10 @@ -use crate::{DEFAULT_THREAD_TITLE, SelectPermissionGranularity}; +use crate::{ + DEFAULT_THREAD_TITLE, SelectPermissionGranularity, + agent_configuration::configure_context_server_modal::default_markdown_style, +}; use std::cell::RefCell; -use acp_thread::ContentBlock; +use acp_thread::{ContentBlock, PlanEntry}; use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; use editor::actions::OpenExcerpts; @@ -2789,6 +2792,76 @@ impl ThreadView { .into_any_element() } + fn render_completed_plan( + &self, + entries: &[PlanEntry], + window: &Window, + cx: &Context, + ) -> AnyElement { + v_flex() + .px_5() + .py_1p5() + .w_full() + .child( + v_flex() + .w_full() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .child( + h_flex() + .px_2() + .py_1() + .gap_1() + .bg(self.tool_card_header_bg(cx)) + .border_b_1() + .border_color(self.tool_card_border_color(cx)) + .child( + Label::new("Completed Plan") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!( + "— {} {}", + entries.len(), + if entries.len() == 1 { "step" } else { "steps" } + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + v_flex().children(entries.iter().enumerate().map(|(index, entry)| { + h_flex() + .py_1() + .px_2() + .gap_1p5() + .when(index < entries.len() - 1, |this| { + this.border_b_1().border_color(cx.theme().colors().border) + }) + .child( + Icon::new(IconName::TodoComplete) + .size(IconSize::Small) + .color(Color::Success), + ) + .child( + div() + .max_w_full() + .overflow_x_hidden() + .text_xs() + .text_color(cx.theme().colors().text_muted) + .child(MarkdownElement::new( + entry.content.clone(), + default_markdown_style(window, cx), + )), + ) + })), + ), + ) + .into_any() + } + fn render_edits_summary( &self, changed_buffers: &BTreeMap, Entity>, @@ -4546,6 +4619,9 @@ impl ThreadView { cx, ) .into_any(), + AgentThreadEntry::CompletedPlan(entries) => { + self.render_completed_plan(entries, window, cx) + } }; let is_subagent_output = self.is_subagent() @@ -5411,7 +5487,9 @@ impl ThreadView { return false; } } - AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {} + AgentThreadEntry::ToolCall(_) + | AgentThreadEntry::AssistantMessage(_) + | AgentThreadEntry::CompletedPlan(_) => {} } } diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index ef5e8a9812e8266566f027365e4b270177aab71c..dfa76e3716f0b938e8ff53e0799c12dd1a657a88 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -235,6 +235,11 @@ impl EntryViewState { }; entry.sync(message); } + AgentThreadEntry::CompletedPlan(_) => { + if !matches!(self.entries.get(index), Some(Entry::CompletedPlan)) { + self.set_entry(index, Entry::CompletedPlan); + } + } }; } @@ -253,7 +258,9 @@ impl EntryViewState { pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} + Entry::UserMessage { .. } + | Entry::AssistantMessage { .. } + | Entry::CompletedPlan => {} Entry::ToolCall(ToolCallEntry { content }) => { for view in content.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -320,6 +327,7 @@ pub enum Entry { UserMessage(Entity), AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), + CompletedPlan, } impl Entry { @@ -327,14 +335,14 @@ impl Entry { match self { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(message) => Some(message.focus_handle.clone()), - Self::ToolCall(_) => None, + Self::ToolCall(_) | Self::CompletedPlan => None, } } pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Self::AssistantMessage(_) | Self::ToolCall(_) => None, + Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -361,7 +369,7 @@ impl Entry { ) -> Option { match self { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), - Self::UserMessage(_) | Self::ToolCall(_) => None, + Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None, } } @@ -376,7 +384,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(), - Self::UserMessage(_) | Self::AssistantMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false, } } } From 1625f98fb063faac06fdbdbb14a1b9d904e3a7db Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:25:58 -0300 Subject: [PATCH 44/45] collab_panel: Fix favorite channels not surviving startup (#52541) Follow up to https://github.com/zed-industries/zed/pull/52378 This PR fixes a little race condition that was happening where we were running the favorite channel pruning function faster than the channels could load, leading to favorite channels not surviving the app restarting. The fix is to make the pruning happen only when the number of channels is bigger than 0, which means the list from the server has already been loaded. Release Notes: - N/A _(No release notes yet because this feature hasn't reached the wider public)_ --- crates/collab_ui/src/collab_panel.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 74e7a7c82b2123bfca8d4fc4a9e8f02463e3f7d3..4e3e1ec1bfac253f7d9dae3b01fdc9a17b9acd34 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -683,11 +683,13 @@ impl CollabPanel { let mut request_entries = Vec::new(); - let previous_len = self.favorite_channels.len(); - self.favorite_channels - .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some()); - if self.favorite_channels.len() != previous_len { - self.serialize(cx); + if self.channel_store.read(cx).channel_count() > 0 { + let previous_len = self.favorite_channels.len(); + self.favorite_channels + .retain(|id| self.channel_store.read(cx).channel_for_id(*id).is_some()); + if self.favorite_channels.len() != previous_len { + self.serialize(cx); + } } let channel_store = self.channel_store.read(cx); From d77aba3ee721e4b93c9deb937739eed3b602df45 Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Thu, 26 Mar 2026 17:11:14 -0700 Subject: [PATCH 45/45] Group threads by canonical path lists (#52524) ## Context With the new sidebar, we are having some bugs around multi-root projects combined with git work trees that can cause threads to be visible in the agent panel but not have an entry in the sidebar. ## How to Review This PR takes a step towards resolving these issue by adding a `ProjectGroupBuilder` which is responsible for gathering the set of projects groups from the open workspaces and then helping to discover threads and map them into this set. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Mikayla Maki Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Mikayla Maki Co-authored-by: Max Brunsfeld --- crates/sidebar/src/project_group_builder.rs | 330 +++++++++++ crates/sidebar/src/sidebar.rs | 573 ++++++++++---------- 2 files changed, 621 insertions(+), 282 deletions(-) create mode 100644 crates/sidebar/src/project_group_builder.rs diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs new file mode 100644 index 0000000000000000000000000000000000000000..d03190e028082e086b30956933780090c1be07e5 --- /dev/null +++ b/crates/sidebar/src/project_group_builder.rs @@ -0,0 +1,330 @@ +//! The sidebar groups threads by a canonical path list. +//! +//! Threads have a path list associated with them, but this is the absolute path +//! of whatever worktrees they were associated with. In the sidebar, we want to +//! group all threads by their main worktree, and then we add a worktree chip to +//! the sidebar entry when that thread is in another worktree. +//! +//! This module is provides the functions and structures necessary to do this +//! lookup and mapping. + +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use gpui::{App, Entity}; +use ui::SharedString; +use workspace::{MultiWorkspace, PathList, Workspace}; + +/// Identifies a project group by a set of paths the workspaces in this group +/// have. +/// +/// Paths are mapped to their main worktree path first so we can group +/// workspaces by main repos. +#[derive(PartialEq, Eq, Hash, Clone)] +pub struct ProjectGroupName { + path_list: PathList, +} + +impl ProjectGroupName { + pub fn display_name(&self) -> SharedString { + let mut names = Vec::with_capacity(self.path_list.paths().len()); + for abs_path in self.path_list.paths() { + if let Some(name) = abs_path.file_name() { + names.push(name.to_string_lossy().to_string()); + } + } + if names.is_empty() { + // TODO: Can we do something better in this case? + "Empty Workspace".into() + } else { + names.join(", ").into() + } + } + + pub fn path_list(&self) -> &PathList { + &self.path_list + } +} + +#[derive(Default)] +pub struct ProjectGroup { + pub workspaces: Vec>, + /// Root paths of all open workspaces in this group. Used to skip + /// redundant thread-store queries for linked worktrees that already + /// have an open workspace. + covered_paths: HashSet>, +} + +impl ProjectGroup { + fn add_workspace(&mut self, workspace: &Entity, cx: &App) { + if !self.workspaces.contains(workspace) { + self.workspaces.push(workspace.clone()); + } + for path in workspace.read(cx).root_paths(cx) { + self.covered_paths.insert(path); + } + } + + pub fn first_workspace(&self) -> &Entity { + self.workspaces + .first() + .expect("groups always have at least one workspace") + } +} + +pub struct ProjectGroupBuilder { + /// Maps git repositories' work_directory_abs_path to their original_repo_abs_path + directory_mappings: HashMap, + project_group_names: Vec, + project_groups: Vec, +} + +impl ProjectGroupBuilder { + fn new() -> Self { + Self { + directory_mappings: HashMap::new(), + project_group_names: Vec::new(), + project_groups: Vec::new(), + } + } + + pub fn from_multiworkspace(mw: &MultiWorkspace, cx: &App) -> Self { + let mut builder = Self::new(); + + // First pass: collect all directory mappings from every workspace + // so we know how to canonicalize any path (including linked + // worktree paths discovered by the main repo's workspace). + for workspace in mw.workspaces() { + builder.add_workspace_mappings(workspace.read(cx), cx); + } + + // Second pass: group each workspace using canonical paths derived + // from the full set of mappings. + for workspace in mw.workspaces() { + let group_name = builder.canonical_workspace_paths(workspace, cx); + builder + .project_group_entry(&group_name) + .add_workspace(workspace, cx); + } + builder + } + + fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup { + match self.project_group_names.iter().position(|n| n == name) { + Some(idx) => &mut self.project_groups[idx], + None => { + let idx = self.project_group_names.len(); + self.project_group_names.push(name.clone()); + self.project_groups.push(ProjectGroup::default()); + &mut self.project_groups[idx] + } + } + } + + fn add_mapping(&mut self, work_directory: &Path, original_repo: &Path) { + let old = self + .directory_mappings + .insert(PathBuf::from(work_directory), PathBuf::from(original_repo)); + if let Some(old) = old { + debug_assert_eq!( + &old, original_repo, + "all worktrees should map to the same main worktree" + ); + } + } + + pub fn add_workspace_mappings(&mut self, workspace: &Workspace, cx: &App) { + for repo in workspace.project().read(cx).repositories(cx).values() { + let snapshot = repo.read(cx).snapshot(); + + self.add_mapping( + &snapshot.work_directory_abs_path, + &snapshot.original_repo_abs_path, + ); + + for worktree in snapshot.linked_worktrees.iter() { + self.add_mapping(&worktree.path, &snapshot.original_repo_abs_path); + } + } + } + + /// Derives the canonical group name for a workspace by canonicalizing + /// each of its root paths using the builder's directory mappings. + fn canonical_workspace_paths( + &self, + workspace: &Entity, + cx: &App, + ) -> ProjectGroupName { + let paths: Vec<_> = workspace + .read(cx) + .root_paths(cx) + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + ProjectGroupName { + path_list: PathList::new(&paths), + } + } + + pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path { + self.directory_mappings + .get(path) + .map(AsRef::as_ref) + .unwrap_or(path) + } + + /// Whether the given group should load threads for a linked worktree at + /// `worktree_path`. Returns `false` if the worktree already has an open + /// workspace in the group (its threads are loaded via the workspace loop) + /// or if the worktree's canonical path list doesn't match `group_path_list`. + pub fn group_owns_worktree( + &self, + group: &ProjectGroup, + group_path_list: &PathList, + worktree_path: &Path, + ) -> bool { + let worktree_arc: Arc = Arc::from(worktree_path); + if group.covered_paths.contains(&worktree_arc) { + return false; + } + let canonical = self.canonicalize_path_list(&PathList::new(&[worktree_path])); + canonical == *group_path_list + } + + fn canonicalize_path_list(&self, path_list: &PathList) -> PathList { + let paths: Vec<_> = path_list + .paths() + .iter() + .map(|p| self.canonicalize_path(p).to_path_buf()) + .collect(); + PathList::new(&paths) + } + + pub fn groups(&self) -> impl Iterator { + self.project_group_names + .iter() + .zip(self.project_groups.iter()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + async fn create_fs_with_main_and_worktree(cx: &mut TestAppContext) -> Arc { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt/feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt/feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "abc".into(), + }); + }) + .expect("git state should be set"); + fs + } + + #[gpui::test] + async fn test_main_repo_maps_to_itself(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The main repo path should canonicalize to itself. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/project")), + Path::new("/project"), + ); + + // An unknown path returns None. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/something/else")), + Path::new("/something/else"), + ); + }); + } + + #[gpui::test] + async fn test_worktree_checkout_canonicalizes_to_main_repo(cx: &mut TestAppContext) { + init_test(cx); + let fs = create_fs_with_main_and_worktree(cx).await; + cx.update(|cx| ::set_global(fs.clone(), cx)); + + // Open the worktree checkout as its own project. + let project = project::Project::test(fs.clone(), ["/wt/feature-a".as_ref()], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + + multi_workspace.read_with(cx, |mw, cx| { + let mut canonicalizer = ProjectGroupBuilder::new(); + for workspace in mw.workspaces() { + canonicalizer.add_workspace_mappings(workspace.read(cx), cx); + } + + // The worktree checkout path should canonicalize to the main repo. + assert_eq!( + canonicalizer.canonicalize_path(Path::new("/wt/feature-a")), + Path::new("/project"), + ); + }); + } +} diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 501b55a73260f0d453775fc245868669c35ab406..123ca7a6bec8af78f25a0c3bbac5767ced38b55f 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -19,16 +19,14 @@ use gpui::{ use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; -use project::{AgentId, Event as ProjectEvent, linked_worktree_short_name}; +use project::{Event as ProjectEvent, linked_worktree_short_name}; use recent_projects::sidebar_recent_projects::SidebarRecentProjects; use ui::utils::platform_title_bar_height; use settings::Settings as _; use std::collections::{HashMap, HashSet}; use std::mem; -use std::path::Path; use std::rc::Rc; -use std::sync::Arc; use theme::ActiveTheme; use ui::{ AgentThreadStatus, CommonAnimationExt, ContextMenu, Divider, HighlightedLabel, KeyBinding, @@ -47,6 +45,10 @@ use zed_actions::editor::{MoveDown, MoveUp}; use zed_actions::agents_sidebar::FocusSidebarFilter; +use crate::project_group_builder::ProjectGroupBuilder; + +mod project_group_builder; + gpui::actions!( agents_sidebar, [ @@ -118,6 +120,24 @@ struct ThreadEntry { diff_stats: DiffStats, } +impl ThreadEntry { + /// Updates this thread entry with active thread information. + /// + /// The existing [`ThreadEntry`] was likely deserialized from the database + /// but if we have a correspond thread already loaded we want to apply the + /// live information. + fn apply_active_info(&mut self, info: &ActiveThreadInfo) { + self.session_info.title = Some(info.title.clone()); + self.status = info.status; + self.icon = info.icon; + self.icon_from_external_svg = info.icon_from_external_svg.clone(); + self.is_live = true; + self.is_background = info.is_background; + self.is_title_generating = info.is_title_generating; + self.diff_stats = info.diff_stats; + } +} + #[derive(Clone)] enum ListEntry { ProjectHeader { @@ -209,21 +229,6 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } -fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { - let mut names = Vec::with_capacity(path_list.paths().len()); - for abs_path in path_list.paths() { - if let Some(name) = abs_path.file_name() { - names.push(name.to_string_lossy().to_string()); - } - } - if names.is_empty() { - // TODO: Can we do something better in this case? - "Empty Workspace".into() - } else { - names.join(", ").into() - } -} - /// The sidebar re-derives its entire entry list from scratch on every /// change via `update_entries` → `rebuild_contents`. Avoid adding /// incremental or inter-event coordination state — if something can @@ -542,8 +547,21 @@ impl Sidebar { result } - /// When modifying this thread, aim for a single forward pass over workspaces - /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data. + /// Rebuilds the sidebar contents from current workspace and thread state. + /// + /// Uses [`ProjectGroupBuilder`] to group workspaces by their main git + /// repository, then populates thread entries from the metadata store and + /// merges live thread info from active agent panels. + /// + /// Aim for a single forward pass over workspaces and threads plus an + /// O(T log T) sort. Avoid adding extra scans over the data. + /// + /// Properties: + /// + /// - Should always show every workspace in the multiworkspace + /// - If you have no threads, and two workspaces for the worktree and the main workspace, make sure at least one is shown + /// - Should always show every thread, associated with each workspace in the multiworkspace + /// - After every build_contents, our "active" state should exactly match the current workspace's, current agent panel's current thread. fn rebuild_contents(&mut self, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; @@ -552,7 +570,6 @@ impl Sidebar { let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - // Build a lookup for agent icons from the first workspace's AgentServerStore. let agent_server_store = workspaces .first() .map(|ws| ws.read(cx).project().read(cx).agent_server_store().clone()); @@ -607,118 +624,62 @@ impl Sidebar { let mut current_session_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); - // Identify absorbed workspaces in a single pass. A workspace is - // "absorbed" when it points at a git worktree checkout whose main - // repo is open as another workspace — its threads appear under the - // main repo's header instead of getting their own. - let mut main_repo_workspace: HashMap, usize> = HashMap::new(); - let mut absorbed: HashMap = HashMap::new(); - let mut pending: HashMap, Vec<(usize, SharedString, Arc)>> = HashMap::new(); - let mut absorbed_workspace_by_path: HashMap, usize> = HashMap::new(); - let workspace_indices_by_path: HashMap, Vec> = workspaces - .iter() - .enumerate() - .flat_map(|(index, workspace)| { - let paths = workspace_path_list(workspace, cx).paths().to_vec(); - paths - .into_iter() - .map(move |path| (Arc::from(path.as_path()), index)) - }) - .fold(HashMap::new(), |mut map, (path, index)| { - map.entry(path).or_default().push(index); - map - }); - - for (i, workspace) in workspaces.iter().enumerate() { - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_main_worktree() { - main_repo_workspace - .entry(snapshot.work_directory_abs_path.clone()) - .or_insert(i); - - for git_worktree in snapshot.linked_worktrees() { - let worktree_path: Arc = Arc::from(git_worktree.path.as_path()); - if let Some(worktree_indices) = - workspace_indices_by_path.get(worktree_path.as_ref()) - { - for &worktree_idx in worktree_indices { - if worktree_idx == i { - continue; - } - - let worktree_name = linked_worktree_short_name( - &snapshot.original_repo_abs_path, - &git_worktree.path, - ) - .unwrap_or_default(); - absorbed.insert(worktree_idx, (i, worktree_name.clone())); - absorbed_workspace_by_path - .insert(worktree_path.clone(), worktree_idx); - } - } - } - - if let Some(waiting) = pending.remove(&snapshot.work_directory_abs_path) { - for (ws_idx, name, ws_path) in waiting { - absorbed.insert(ws_idx, (i, name)); - absorbed_workspace_by_path.insert(ws_path, ws_idx); - } - } - } else { - let name: SharedString = snapshot - .work_directory_abs_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - .into(); - if let Some(&main_idx) = - main_repo_workspace.get(&snapshot.original_repo_abs_path) - { - absorbed.insert(i, (main_idx, name)); - absorbed_workspace_by_path - .insert(snapshot.work_directory_abs_path.clone(), i); - } else { - pending - .entry(snapshot.original_repo_abs_path.clone()) - .or_default() - .push((i, name, snapshot.work_directory_abs_path.clone())); - } - } - } - } + // Use ProjectGroupBuilder to canonically group workspaces by their + // main git repository. This replaces the manual absorbed-workspace + // detection that was here before. + let project_groups = ProjectGroupBuilder::from_multiworkspace(mw, cx); let has_open_projects = workspaces .iter() .any(|ws| !workspace_path_list(ws, cx).paths().is_empty()); - let active_ws_index = active_workspace - .as_ref() - .and_then(|active| workspaces.iter().position(|ws| ws == active)); - - for (ws_index, workspace) in workspaces.iter().enumerate() { - if absorbed.contains_key(&ws_index) { - continue; + let resolve_agent = |row: &ThreadMetadata| -> (Agent, IconName, Option) { + match &row.agent_id { + None => (Agent::NativeAgent, IconName::ZedAgent, None), + Some(id) => { + let custom_icon = agent_server_store + .as_ref() + .and_then(|store| store.read(cx).agent_icon(id)); + ( + Agent::Custom { id: id.clone() }, + IconName::Terminal, + custom_icon, + ) + } } + }; - let path_list = workspace_path_list(workspace, cx); + for (group_name, group) in project_groups.groups() { + let path_list = group_name.path_list().clone(); if path_list.paths().is_empty() { continue; } - let label = workspace_label_from_path_list(&path_list); + let label = group_name.display_name(); let is_collapsed = self.collapsed_groups.contains(&path_list); let should_load_threads = !is_collapsed || !query.is_empty(); - let is_active = active_ws_index.is_some_and(|active_idx| { - active_idx == ws_index - || absorbed - .get(&active_idx) - .is_some_and(|(main_idx, _)| *main_idx == ws_index) - }); - - let mut live_infos: Vec<_> = all_thread_infos_for_workspace(workspace, cx).collect(); + let is_active = active_workspace + .as_ref() + .is_some_and(|active| group.workspaces.contains(active)); + + // Pick a representative workspace for the group: prefer the active + // workspace if it belongs to this group, otherwise use the first. + // + // This is the workspace that will be activated by the project group + // header. + let representative_workspace = active_workspace + .as_ref() + .filter(|_| is_active) + .unwrap_or_else(|| group.first_workspace()); + + // Collect live thread infos from all workspaces in this group. + let live_infos: Vec<_> = group + .workspaces + .iter() + .flat_map(|ws| all_thread_infos_for_workspace(ws, cx)) + .collect(); let mut threads: Vec = Vec::new(); let mut has_running_threads = false; @@ -726,138 +687,124 @@ impl Sidebar { if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); - - // Read threads from the store cache for this workspace's path list. let thread_store = SidebarThreadMetadataStore::global(cx); - let workspace_rows: Vec<_> = - thread_store.read(cx).entries_for_path(&path_list).collect(); - for row in workspace_rows { - seen_session_ids.insert(row.session_id.clone()); - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(id) => { - let custom_icon = agent_server_store - .as_ref() - .and_then(|store| store.read(cx).agent_icon(&id)); - ( - Agent::Custom { id: id.clone() }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: ThreadEntryWorkspace::Open(workspace.clone()), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: None, - worktree_full_path: None, - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); - } - // Load threads from linked git worktrees of this workspace's repos. - { - let mut linked_worktree_queries: Vec<(PathList, SharedString, Arc)> = - Vec::new(); - for snapshot in root_repository_snapshots(workspace, cx) { - if snapshot.is_linked_worktree() { - continue; - } + // Load threads from each workspace in the group. + for workspace in &group.workspaces { + let ws_path_list = workspace_path_list(workspace, cx); + + // Determine if this workspace covers a git worktree (its + // path canonicalizes to the main repo, not itself). If so, + // threads from it get a worktree chip in the sidebar. + let worktree_info: Option<(SharedString, SharedString)> = + ws_path_list.paths().first().and_then(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + let name = + linked_worktree_short_name(canonical, path).unwrap_or_default(); + let full_path: SharedString = path.display().to_string().into(); + Some((name, full_path)) + } else { + None + } + }); - let main_worktree_path = snapshot.original_repo_abs_path.clone(); - - for git_worktree in snapshot.linked_worktrees() { - let worktree_name = - linked_worktree_short_name(&main_worktree_path, &git_worktree.path) - .unwrap_or_default(); - linked_worktree_queries.push(( - PathList::new(std::slice::from_ref(&git_worktree.path)), - worktree_name, - Arc::from(git_worktree.path.as_path()), - )); + let workspace_threads: Vec<_> = thread_store + .read(cx) + .entries_for_path(&ws_path_list) + .collect(); + for thread in workspace_threads { + if !seen_session_ids.insert(thread.session_id.clone()) { + continue; } + let (agent, icon, icon_from_external_svg) = resolve_agent(&thread); + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: thread.session_id.clone(), + work_dirs: None, + title: Some(thread.title.clone()), + updated_at: Some(thread.updated_at), + created_at: thread.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Open(workspace.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), + worktree_full_path: worktree_info + .as_ref() + .map(|(_, path)| path.clone()), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); } + } - for (worktree_path_list, worktree_name, worktree_path) in - &linked_worktree_queries - { - let target_workspace = match absorbed_workspace_by_path - .get(worktree_path.as_ref()) - { - Some(&idx) => { - live_infos - .extend(all_thread_infos_for_workspace(&workspaces[idx], cx)); - ThreadEntryWorkspace::Open(workspaces[idx].clone()) - } - None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), - }; + // Load threads from linked git worktrees that don't have an + // open workspace in this group. Only include worktrees that + // belong to this group (not shared with another group). + let linked_worktree_path_lists = group + .workspaces + .iter() + .flat_map(|ws| root_repository_snapshots(ws, cx)) + .filter(|snapshot| !snapshot.is_linked_worktree()) + .flat_map(|snapshot| { + snapshot + .linked_worktrees() + .iter() + .filter(|wt| { + project_groups.group_owns_worktree(group, &path_list, &wt.path) + }) + .map(|wt| PathList::new(std::slice::from_ref(&wt.path))) + .collect::>() + }); - let worktree_rows: Vec<_> = thread_store - .read(cx) - .entries_for_path(worktree_path_list) - .collect(); - for row in worktree_rows { - if !seen_session_ids.insert(row.session_id.clone()) { - continue; - } - let (agent, icon, icon_from_external_svg) = match &row.agent_id { - None => (Agent::NativeAgent, IconName::ZedAgent, None), - Some(name) => { - let custom_icon = - agent_server_store.as_ref().and_then(|store| { - store.read(cx).agent_icon(&AgentId(name.clone().into())) - }); - ( - Agent::Custom { - id: AgentId::new(name.clone()), - }, - IconName::Terminal, - custom_icon, - ) - } - }; - threads.push(ThreadEntry { - agent, - session_info: acp_thread::AgentSessionInfo { - session_id: row.session_id.clone(), - work_dirs: None, - title: Some(row.title.clone()), - updated_at: Some(row.updated_at), - created_at: row.created_at, - meta: None, - }, - icon, - icon_from_external_svg, - status: AgentThreadStatus::default(), - workspace: target_workspace.clone(), - is_live: false, - is_background: false, - is_title_generating: false, - highlight_positions: Vec::new(), - worktree_name: Some(worktree_name.clone()), - worktree_full_path: Some( - worktree_path.display().to_string().into(), - ), - worktree_highlight_positions: Vec::new(), - diff_stats: DiffStats::default(), - }); + for worktree_path_list in linked_worktree_path_lists { + for row in thread_store.read(cx).entries_for_path(&worktree_path_list) { + if !seen_session_ids.insert(row.session_id.clone()) { + continue; } + let worktree_info = row.folder_paths.paths().first().and_then(|path| { + let canonical = project_groups.canonicalize_path(path); + if canonical != path.as_path() { + let name = + linked_worktree_short_name(canonical, path).unwrap_or_default(); + let full_path: SharedString = path.display().to_string().into(); + Some((name, full_path)) + } else { + None + } + }); + let (agent, icon, icon_from_external_svg) = resolve_agent(&row); + threads.push(ThreadEntry { + agent, + session_info: acp_thread::AgentSessionInfo { + session_id: row.session_id.clone(), + work_dirs: None, + title: Some(row.title.clone()), + updated_at: Some(row.updated_at), + created_at: row.created_at, + meta: None, + }, + icon, + icon_from_external_svg, + status: AgentThreadStatus::default(), + workspace: ThreadEntryWorkspace::Closed(row.folder_paths.clone()), + is_live: false, + is_background: false, + is_title_generating: false, + highlight_positions: Vec::new(), + worktree_name: worktree_info.as_ref().map(|(name, _)| name.clone()), + worktree_full_path: worktree_info.map(|(_, path)| path), + worktree_highlight_positions: Vec::new(), + diff_stats: DiffStats::default(), + }); } } @@ -878,19 +825,12 @@ impl Sidebar { // Merge live info into threads and update notification state // in a single pass. for thread in &mut threads { - let session_id = &thread.session_info.session_id; - - if let Some(info) = live_info_by_session.get(session_id) { - thread.session_info.title = Some(info.title.clone()); - thread.status = info.status; - thread.icon = info.icon; - thread.icon_from_external_svg = info.icon_from_external_svg.clone(); - thread.is_live = true; - thread.is_background = info.is_background; - thread.is_title_generating = info.is_title_generating; - thread.diff_stats = info.diff_stats; + if let Some(info) = live_info_by_session.get(&thread.session_info.session_id) { + thread.apply_active_info(info); } + let session_id = &thread.session_info.session_id; + let is_thread_workspace_active = match &thread.workspace { ThreadEntryWorkspace::Open(thread_workspace) => active_workspace .as_ref() @@ -916,7 +856,7 @@ impl Sidebar { b_time.cmp(&a_time) }); } else { - for info in &live_infos { + for info in live_infos { if info.status == AgentThreadStatus::Running { has_running_threads = true; } @@ -964,7 +904,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: workspace_highlight_positions, has_running_threads, waiting_thread_count, @@ -988,7 +928,7 @@ impl Sidebar { entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, - workspace: workspace.clone(), + workspace: representative_workspace.clone(), highlight_positions: Vec::new(), has_running_threads, waiting_thread_count, @@ -1002,7 +942,7 @@ impl Sidebar { if show_new_thread_entry { entries.push(ListEntry::NewThread { path_list: path_list.clone(), - workspace: workspace.clone(), + workspace: representative_workspace.clone(), is_active_draft: is_draft_for_workspace, }); } @@ -1611,7 +1551,7 @@ impl Sidebar { true, &path_list, &label, - &workspace, + workspace, &highlight_positions, *has_running_threads, *waiting_thread_count, @@ -3018,9 +2958,7 @@ impl Sidebar { bar.child(toggle_button).child(action_buttons) } } -} -impl Sidebar { fn toggle_archive(&mut self, _: &ToggleArchive, window: &mut Window, cx: &mut Context) { match &self.view { SidebarView::ThreadList => self.show_archive(window, cx), @@ -3211,24 +3149,8 @@ fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, ) -> impl Iterator { - enum ThreadInfoIterator> { - Empty, - Threads(T), - } - - impl> Iterator for ThreadInfoIterator { - type Item = ActiveThreadInfo; - - fn next(&mut self) -> Option { - match self { - ThreadInfoIterator::Empty => None, - ThreadInfoIterator::Threads(threads) => threads.next(), - } - } - } - let Some(agent_panel) = workspace.read(cx).panel::(cx) else { - return ThreadInfoIterator::Empty; + return None.into_iter().flatten(); }; let agent_panel = agent_panel.read(cx); @@ -3274,7 +3196,7 @@ fn all_thread_infos_for_workspace( } }); - ThreadInfoIterator::Threads(threads) + Some(threads).into_iter().flatten() } #[cfg(test)] @@ -5833,10 +5755,9 @@ mod tests { assert_eq!( visible_entries_as_strings(&sidebar, cx), vec![ - "v [wt-feature-a]", - " Thread A", - "v [wt-feature-b]", - " Thread B", + "v [project]", + " Thread A {wt-feature-a}", + " Thread B {wt-feature-b}", ] ); @@ -7139,4 +7060,92 @@ mod tests { entries_after ); } + + #[gpui::test] + async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) { + // When a multi-root workspace (e.g. [/other, /project]) shares a + // repo with a single-root workspace (e.g. [/project]), linked + // worktree threads from the shared repo should only appear under + // the dedicated group [project], not under [other, project]. + init_test(cx); + let fs = FakeFs::new(cx.executor()); + + fs.insert_tree( + "/project", + serde_json::json!({ + ".git": { + "worktrees": { + "feature-a": { + "commondir": "../../", + "HEAD": "ref: refs/heads/feature-a", + }, + }, + }, + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/wt-feature-a", + serde_json::json!({ + ".git": "gitdir: /project/.git/worktrees/feature-a", + "src": {}, + }), + ) + .await; + fs.insert_tree( + "/other", + serde_json::json!({ + ".git": {}, + "src": {}, + }), + ) + .await; + + fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| { + state.worktrees.push(git::repository::Worktree { + path: std::path::PathBuf::from("/wt-feature-a"), + ref_name: Some("refs/heads/feature-a".into()), + sha: "aaa".into(), + }); + }) + .unwrap(); + + cx.update(|cx| ::set_global(fs.clone(), cx)); + + let project_only = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await; + project_only + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let multi_root = + project::Project::test(fs.clone(), ["/other".as_ref(), "/project".as_ref()], cx).await; + multi_root + .update(cx, |p, cx| p.git_scans_complete(cx)) + .await; + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project_only.clone(), window, cx) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.test_add_workspace(multi_root.clone(), window, cx); + }); + let sidebar = setup_sidebar(&multi_workspace, cx); + + let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]); + save_named_thread_metadata("wt-thread", "Worktree Thread", &wt_paths, cx).await; + + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec![ + "v [project]", + " Worktree Thread {wt-feature-a}", + "v [other, project]", + " [+ New Thread]", + ] + ); + } }