From 60f4aa333be1b2661c1a60d5750701122c6c5d8c Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 12 Dec 2025 14:15:58 -0300 Subject: [PATCH 01/67] edit prediction cli: Improve error handling (#44718) We were panicking whenever something went wrong with an example in the CLI. This can be very disruptive when running many examples, and e.g a single request fails. Instead, if running more than one example, errors will now be logged alongside instructions to explore and re-run the example by itself. CleanShot 2025-12-12 at 13 32 04@2x You can still opt in to stop as soon as en error occurs with the new `--failfast` argument. Release Notes: - N/A --- crates/edit_prediction_cli/src/distill.rs | 16 +- .../edit_prediction_cli/src/format_prompt.rs | 67 ++--- .../edit_prediction_cli/src/load_project.rs | 248 ++++++++---------- crates/edit_prediction_cli/src/main.rs | 243 ++++++++++++----- crates/edit_prediction_cli/src/paths.rs | 2 + crates/edit_prediction_cli/src/predict.rs | 120 ++++----- crates/edit_prediction_cli/src/progress.rs | 58 +++- .../src/retrieve_context.rs | 67 +++-- crates/edit_prediction_cli/src/score.rs | 5 +- 9 files changed, 478 insertions(+), 348 deletions(-) diff --git a/crates/edit_prediction_cli/src/distill.rs b/crates/edit_prediction_cli/src/distill.rs index 495b3cd88cbd05ad1917517580b913aacf4fb107..085c5f744a1837cbb97f4c33b6f89b6031088e2b 100644 --- a/crates/edit_prediction_cli/src/distill.rs +++ b/crates/edit_prediction_cli/src/distill.rs @@ -1,14 +1,22 @@ +use anyhow::{Result, anyhow}; use std::mem; use crate::example::Example; -pub async fn run_distill(example: &mut Example) { - let [prediction]: [_; 1] = mem::take(&mut example.predictions) - .try_into() - .expect("Run predict first with a single repetition"); +pub async fn run_distill(example: &mut Example) -> Result<()> { + let [prediction]: [_; 1] = + mem::take(&mut example.predictions) + .try_into() + .map_err(|preds: Vec<_>| { + anyhow!( + "Example has {} predictions, but it should have exactly one", + preds.len() + ) + })?; example.expected_patch = prediction.actual_patch; example.prompt = None; example.predictions = Vec::new(); example.score = Vec::new(); + Ok(()) } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index 017e11a54c77e06bde7b74ed3f924692e33cd480..f8fd9b2023a84abcf59bcb5ba54d2d228a0c6484 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -6,6 +6,7 @@ use crate::{ progress::{Progress, Step}, retrieve_context::run_context_retrieval, }; +use anyhow::{Context as _, Result, ensure}; use edit_prediction::{ EditPredictionStore, zeta2::{zeta2_output_for_patch, zeta2_prompt_input}, @@ -19,8 +20,8 @@ pub async fn run_format_prompt( prompt_format: PromptFormat, app_state: Arc, mut cx: AsyncApp, -) { - run_context_retrieval(example, app_state.clone(), cx.clone()).await; +) -> Result<()> { + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; let _step_progress = Progress::global().start(Step::FormatPrompt, &example.name); @@ -34,29 +35,33 @@ pub async fn run_format_prompt( }); } PromptFormat::Zeta2 => { - run_load_project(example, app_state, cx.clone()).await; + run_load_project(example, app_state, cx.clone()).await?; - let ep_store = cx - .update(|cx| EditPredictionStore::try_global(cx).unwrap()) - .unwrap(); + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; - let state = example.state.as_ref().unwrap(); - let snapshot = state - .buffer - .read_with(&cx, |buffer, _| buffer.snapshot()) - .unwrap(); + let state = example.state.as_ref().context("state must be set")?; + let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; let project = state.project.clone(); - let (_, input) = ep_store - .update(&mut cx, |ep_store, _cx| { - zeta2_prompt_input( - &snapshot, - example.context.as_ref().unwrap().files.clone(), - ep_store.edit_history_for_project(&project), - example.cursor_path.clone(), - example.buffer.as_ref().unwrap().cursor_offset, - ) - }) - .unwrap(); + let (_, input) = ep_store.update(&mut cx, |ep_store, _cx| { + anyhow::Ok(zeta2_prompt_input( + &snapshot, + example + .context + .as_ref() + .context("context must be set")? + .files + .clone(), + ep_store.edit_history_for_project(&project), + example.cursor_path.clone(), + example + .buffer + .as_ref() + .context("buffer must be set")? + .cursor_offset, + )) + })??; let prompt = format_zeta_prompt(&input); let expected_output = zeta2_output_for_patch(&input, &example.expected_patch.clone()); example.prompt = Some(ExamplePrompt { @@ -66,6 +71,7 @@ pub async fn run_format_prompt( }); } }; + Ok(()) } pub struct TeacherPrompt; @@ -91,7 +97,7 @@ impl TeacherPrompt { prompt } - pub fn parse(example: &Example, response: &str) -> String { + pub fn parse(example: &Example, response: &str) -> Result { // Ideally, we should always be able to find cursor position in the retrieved context. // In reality, sometimes we don't find it for these reasons: // 1. `example.cursor_position` contains _more_ context than included in the retrieved context @@ -102,7 +108,7 @@ impl TeacherPrompt { let cursor_file = &example .buffer .as_ref() - .expect("`buffer` should be filled in in the context collection step") + .context("`buffer` should be filled in in the context collection step")? .content; // Extract updated (new) editable region from the model response @@ -111,9 +117,10 @@ impl TeacherPrompt { // Reconstruct old editable region we sent to the model let old_editable_region = Self::format_editable_region(example); let old_editable_region = Self::extract_editable_region(&old_editable_region); - if !cursor_file.contains(&old_editable_region) { - panic!("Something's wrong: editable_region is not found in the cursor file") - } + ensure!( + cursor_file.contains(&old_editable_region), + "Something's wrong: editable_region is not found in the cursor file" + ); // Apply editable region to a larger context and compute diff. // This is needed to get a better context lines around the editable region @@ -128,7 +135,7 @@ impl TeacherPrompt { diff = diff, }; - diff + Ok(diff) } fn format_edit_history(edit_history: &str) -> String { @@ -152,9 +159,7 @@ impl TeacherPrompt { } fn format_context(example: &Example) -> String { - if example.context.is_none() { - panic!("Missing context retriever step"); - } + assert!(example.context.is_some(), "Missing context retriever step"); let mut prompt = String::new(); zeta_prompt::write_related_files(&mut prompt, &example.context.as_ref().unwrap().files); diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs index 4d98ae9f3b85f4e6253d9ead4d846ed3d9deee89..4517e6ccbebca76a7ba8ce73322d6467000fc189 100644 --- a/crates/edit_prediction_cli/src/load_project.rs +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -4,7 +4,7 @@ use crate::{ paths::{REPOS_DIR, WORKTREES_DIR}, progress::{InfoStyle, Progress, Step, StepProgress}, }; -use anyhow::{Result, anyhow}; +use anyhow::{Context as _, Result}; use collections::HashMap; use edit_prediction::EditPredictionStore; use edit_prediction::udiff::OpenedBuffers; @@ -25,38 +25,38 @@ use std::{ use util::{paths::PathStyle, rel_path::RelPath}; use zeta_prompt::CURSOR_MARKER; -pub async fn run_load_project(example: &mut Example, app_state: Arc, mut cx: AsyncApp) { +pub async fn run_load_project( + example: &mut Example, + app_state: Arc, + mut cx: AsyncApp, +) -> Result<()> { if example.state.is_some() { - return; + return Ok(()); } let progress = Progress::global().start(Step::LoadProject, &example.name); - let project = setup_project(example, &app_state, &progress, &mut cx).await; - - let _open_buffers = apply_edit_history(example, &project, &mut cx) - .await - .unwrap(); - - let (buffer, cursor_position) = cursor_position(example, &project, &mut cx).await; - let (example_buffer, language_name) = buffer - .read_with(&cx, |buffer, _cx| { - let cursor_point = cursor_position.to_point(&buffer); - let language_name = buffer - .language() - .map(|l| l.name().to_string()) - .unwrap_or_else(|| "Unknown".to_string()); - ( - ExampleBuffer { - content: buffer.text(), - cursor_row: cursor_point.row, - cursor_column: cursor_point.column, - cursor_offset: cursor_position.to_offset(&buffer), - }, - language_name, - ) - }) - .unwrap(); + let project = setup_project(example, &app_state, &progress, &mut cx).await?; + + let _open_buffers = apply_edit_history(example, &project, &mut cx).await?; + + let (buffer, cursor_position) = cursor_position(example, &project, &mut cx).await?; + let (example_buffer, language_name) = buffer.read_with(&cx, |buffer, _cx| { + let cursor_point = cursor_position.to_point(&buffer); + let language_name = buffer + .language() + .map(|l| l.name().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + ( + ExampleBuffer { + content: buffer.text(), + cursor_row: cursor_point.row, + cursor_column: cursor_point.column, + cursor_offset: cursor_position.to_offset(&buffer), + }, + language_name, + ) + })?; progress.set_info(language_name, InfoStyle::Normal); @@ -67,16 +67,15 @@ pub async fn run_load_project(example: &mut Example, app_state: Arc, cursor_position, _open_buffers, }); + Ok(()) } async fn cursor_position( example: &Example, project: &Entity, cx: &mut AsyncApp, -) -> (Entity, Anchor) { - let language_registry = project - .read_with(cx, |project, _| project.languages().clone()) - .unwrap(); +) -> Result<(Entity, Anchor)> { + let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; let result = language_registry .load_language_for_file_path(&example.cursor_path) .await; @@ -84,17 +83,18 @@ async fn cursor_position( if let Err(error) = result && !error.is::() { - panic!("Failed to load language for file path: {}", error); + return Err(error); } - let worktree = project - .read_with(cx, |project, cx| { - project.visible_worktrees(cx).next().unwrap() - }) - .unwrap(); + let worktree = project.read_with(cx, |project, cx| { + project + .visible_worktrees(cx) + .next() + .context("No visible worktrees") + })??; let cursor_path = RelPath::new(&example.cursor_path, PathStyle::Posix) - .unwrap() + .context("Failed to create RelPath")? .into_arc(); let cursor_buffer = project .update(cx, |project, cx| { @@ -105,15 +105,12 @@ async fn cursor_position( }, cx, ) - }) - .unwrap() - .await - .unwrap(); + })? + .await?; let cursor_offset_within_excerpt = example .cursor_position .find(CURSOR_MARKER) - .ok_or_else(|| anyhow!("missing cursor marker")) - .unwrap(); + .context("missing cursor marker")?; let mut cursor_excerpt = example.cursor_position.clone(); cursor_excerpt.replace_range( cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), @@ -123,22 +120,21 @@ async fn cursor_position( let text = buffer.text(); let mut matches = text.match_indices(&cursor_excerpt); - let (excerpt_offset, _) = matches.next().unwrap_or_else(|| { - panic!( + let (excerpt_offset, _) = matches.next().with_context(|| { + format!( "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Example: {}\nCursor excerpt did not exist in buffer.", example.name - ); - }); - assert!(matches.next().is_none(), "More than one cursor position match found for {}", &example.name); - excerpt_offset - }).unwrap(); + ) + })?; + anyhow::ensure!(matches.next().is_none(), "More than one cursor position match found for {}", &example.name); + Ok(excerpt_offset) + })??; let cursor_offset = excerpt_offset + cursor_offset_within_excerpt; - let cursor_anchor = cursor_buffer - .read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset)) - .unwrap(); + let cursor_anchor = + cursor_buffer.read_with(cx, |buffer, _| buffer.anchor_after(cursor_offset))?; - (cursor_buffer, cursor_anchor) + Ok((cursor_buffer, cursor_anchor)) } async fn setup_project( @@ -146,67 +142,54 @@ async fn setup_project( app_state: &Arc, step_progress: &StepProgress, cx: &mut AsyncApp, -) -> Entity { +) -> Result> { let ep_store = cx - .update(|cx| EditPredictionStore::try_global(cx).unwrap()) - .unwrap(); + .update(|cx| EditPredictionStore::try_global(cx))? + .context("Store should be initialized at init")?; - let worktree_path = setup_worktree(example, step_progress).await; + let worktree_path = setup_worktree(example, step_progress).await?; if let Some(project) = app_state.project_cache.get(&example.repository_url) { - ep_store - .update(cx, |ep_store, _| { - ep_store.clear_history_for_project(&project); - }) - .unwrap(); - let buffer_store = project - .read_with(cx, |project, _| project.buffer_store().clone()) - .unwrap(); - let buffers = buffer_store - .read_with(cx, |buffer_store, _| { - buffer_store.buffers().collect::>() - }) - .unwrap(); + ep_store.update(cx, |ep_store, _| { + ep_store.clear_history_for_project(&project); + })?; + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; + let buffers = buffer_store.read_with(cx, |buffer_store, _| { + buffer_store.buffers().collect::>() + })?; for buffer in buffers { buffer - .update(cx, |buffer, cx| buffer.reload(cx)) - .unwrap() + .update(cx, |buffer, cx| buffer.reload(cx))? .await .ok(); } - return project; + return Ok(project); } - let project = cx - .update(|cx| { - Project::local( - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - None, - cx, - ) - }) - .unwrap(); + let project = cx.update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + cx, + ) + })?; project .update(cx, |project, cx| { project.disable_worktree_scanner(cx); project.create_worktree(&worktree_path, true, cx) - }) - .unwrap() - .await - .unwrap(); + })? + .await?; app_state .project_cache .insert(example.repository_url.clone(), project.clone()); - let buffer_store = project - .read_with(cx, |project, _| project.buffer_store().clone()) - .unwrap(); + let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; cx.subscribe(&buffer_store, { let project = project.clone(); move |_, event, cx| match event { @@ -215,15 +198,14 @@ async fn setup_project( } _ => {} } - }) - .unwrap() + })? .detach(); - project + Ok(project) } -async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> PathBuf { - let (repo_owner, repo_name) = example.repo_name().expect("failed to get repo name"); +async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Result { + let (repo_owner, repo_name) = example.repo_name().context("failed to get repo name")?; let repo_dir = REPOS_DIR.join(repo_owner.as_ref()).join(repo_name.as_ref()); let worktree_path = WORKTREES_DIR .join(repo_owner.as_ref()) @@ -232,14 +214,13 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Path if !repo_dir.is_dir() { step_progress.set_substatus(format!("cloning {}", repo_name)); - fs::create_dir_all(&repo_dir).unwrap(); - run_git(&repo_dir, &["init"]).await.unwrap(); + fs::create_dir_all(&repo_dir)?; + run_git(&repo_dir, &["init"]).await?; run_git( &repo_dir, &["remote", "add", "origin", &example.repository_url], ) - .await - .unwrap(); + .await?; } // Resolve the example to a revision, fetching it if needed. @@ -259,34 +240,25 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Path .await .is_err() { - run_git(&repo_dir, &["fetch", "origin"]).await.unwrap(); + run_git(&repo_dir, &["fetch", "origin"]).await?; } - let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]) - .await - .unwrap(); + let revision = run_git(&repo_dir, &["rev-parse", "FETCH_HEAD"]).await?; revision }; // Create the worktree for this example if needed. step_progress.set_substatus("preparing worktree"); if worktree_path.is_dir() { - run_git(&worktree_path, &["clean", "--force", "-d"]) - .await - .unwrap(); - run_git(&worktree_path, &["reset", "--hard", "HEAD"]) - .await - .unwrap(); - run_git(&worktree_path, &["checkout", revision.as_str()]) - .await - .unwrap(); + run_git(&worktree_path, &["clean", "--force", "-d"]).await?; + run_git(&worktree_path, &["reset", "--hard", "HEAD"]).await?; + run_git(&worktree_path, &["checkout", revision.as_str()]).await?; } else { let worktree_path_string = worktree_path.to_string_lossy(); run_git( &repo_dir, &["branch", "-f", &example.name, revision.as_str()], ) - .await - .unwrap(); + .await?; run_git( &repo_dir, &[ @@ -297,8 +269,7 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Path &example.name, ], ) - .await - .unwrap(); + .await?; } drop(repo_lock); @@ -309,30 +280,25 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Path .current_dir(&worktree_path) .args(&["apply", "-"]) .stdin(std::process::Stdio::piped()) - .spawn() - .unwrap(); - - let mut stdin = apply_process.stdin.take().unwrap(); - stdin - .write_all(example.uncommitted_diff.as_bytes()) - .await - .unwrap(); - stdin.close().await.unwrap(); + .spawn()?; + + let mut stdin = apply_process.stdin.take().context("Failed to get stdin")?; + stdin.write_all(example.uncommitted_diff.as_bytes()).await?; + stdin.close().await?; drop(stdin); - let apply_result = apply_process.output().await.unwrap(); - if !apply_result.status.success() { - panic!( - "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", - apply_result.status, - String::from_utf8_lossy(&apply_result.stderr), - String::from_utf8_lossy(&apply_result.stdout), - ); - } + let apply_result = apply_process.output().await?; + anyhow::ensure!( + apply_result.status.success(), + "Failed to apply uncommitted diff patch with status: {}\nstderr:\n{}\nstdout:\n{}", + apply_result.status, + String::from_utf8_lossy(&apply_result.stderr), + String::from_utf8_lossy(&apply_result.stdout), + ); } step_progress.clear_substatus(); - worktree_path + Ok(worktree_path) } async fn apply_edit_history( diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 075f8862e6f86276a0df550c6d27f8c15a5d1293..3b185103390016f60fc4f621f280d16a58c363e5 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -16,12 +16,14 @@ use edit_prediction::EditPredictionStore; use gpui::Application; use reqwest_client::ReqwestClient; use serde::{Deserialize, Serialize}; +use std::fmt::Display; use std::{path::PathBuf, sync::Arc}; use crate::distill::run_distill; use crate::example::{group_examples_by_repo, read_examples, write_examples}; use crate::format_prompt::run_format_prompt; use crate::load_project::run_load_project; +use crate::paths::FAILED_EXAMPLES_DIR; use crate::predict::run_prediction; use crate::progress::Progress; use crate::retrieve_context::run_context_retrieval; @@ -42,6 +44,8 @@ struct EpArgs { output: Option, #[arg(long, short, global = true)] in_place: bool, + #[arg(long, short, global = true)] + failfast: bool, } #[derive(Subcommand, Debug)] @@ -67,6 +71,58 @@ enum Command { Clean, } +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Command::ParseExample => write!(f, "parse-example"), + Command::LoadProject => write!(f, "load-project"), + Command::Context => write!(f, "context"), + Command::FormatPrompt(format_prompt_args) => write!( + f, + "format-prompt --prompt-format={}", + format_prompt_args + .prompt_format + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Predict(predict_args) => { + write!( + f, + "predict --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Score(predict_args) => { + write!( + f, + "score --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ) + } + Command::Distill => write!(f, "distill"), + Command::Eval(predict_args) => write!( + f, + "eval --provider={:?}", + predict_args + .provider + .to_possible_value() + .unwrap() + .get_name() + ), + Command::Clean => write!(f, "clean"), + } + } +} + #[derive(Debug, Args)] struct FormatPromptArgs { #[clap(long)] @@ -145,71 +201,140 @@ fn main() { EditPredictionStore::global(&app_state.client, &app_state.user_store, cx); cx.spawn(async move |cx| { - if let Command::Predict(args) = &command { - predict::sync_batches(&args.provider).await - }; - - let total_examples = examples.len(); - Progress::global().set_total_examples(total_examples); - - let mut grouped_examples = group_examples_by_repo(&mut examples); - let example_batches = grouped_examples.chunks_mut(args.max_parallelism); - - for example_batch in example_batches { - let futures = example_batch.into_iter().map(|repo_examples| async { - for example in repo_examples.iter_mut() { - match &command { - Command::ParseExample => {} - Command::LoadProject => { - run_load_project(example, app_state.clone(), cx.clone()).await; - } - Command::Context => { - run_context_retrieval(example, app_state.clone(), cx.clone()).await; - } - Command::FormatPrompt(args) => { - run_format_prompt( - example, - args.prompt_format, - app_state.clone(), - cx.clone(), - ) - .await; - } - Command::Predict(args) => { - run_prediction( - example, - Some(args.provider), - args.repetitions, - app_state.clone(), - cx.clone(), - ) - .await; - } - Command::Distill => { - run_distill(example).await; - } - Command::Score(args) | Command::Eval(args) => { - run_scoring(example, &args, app_state.clone(), cx.clone()).await; + let result = async { + if let Command::Predict(args) = &command { + predict::sync_batches(&args.provider).await?; + } + + let total_examples = examples.len(); + Progress::global().set_total_examples(total_examples); + + let mut grouped_examples = group_examples_by_repo(&mut examples); + let example_batches = grouped_examples.chunks_mut(args.max_parallelism); + + for example_batch in example_batches { + let futures = example_batch.into_iter().map(|repo_examples| async { + for example in repo_examples.iter_mut() { + let result = async { + match &command { + Command::ParseExample => {} + Command::LoadProject => { + run_load_project(example, app_state.clone(), cx.clone()) + .await?; + } + Command::Context => { + run_context_retrieval( + example, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::FormatPrompt(args) => { + run_format_prompt( + example, + args.prompt_format, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Predict(args) => { + run_prediction( + example, + Some(args.provider), + args.repetitions, + app_state.clone(), + cx.clone(), + ) + .await?; + } + Command::Distill => { + run_distill(example).await?; + } + Command::Score(args) | Command::Eval(args) => { + run_scoring(example, &args, app_state.clone(), cx.clone()) + .await?; + } + Command::Clean => { + unreachable!() + } + } + anyhow::Ok(()) } - Command::Clean => { - unreachable!() + .await; + + if let Err(e) = result { + Progress::global().increment_failed(); + let failed_example_path = + FAILED_EXAMPLES_DIR.join(format!("{}.json", example.name)); + app_state + .fs + .write( + &failed_example_path, + &serde_json::to_vec_pretty(&example).unwrap(), + ) + .await + .unwrap(); + let err_path = + FAILED_EXAMPLES_DIR.join(format!("{}_err.txt", example.name)); + app_state + .fs + .write(&err_path, e.to_string().as_bytes()) + .await + .unwrap(); + + let msg = format!( + indoc::indoc! {" + While processing {}: + + {:?} + + Written to: \x1b[36m{}\x1b[0m + + Explore this example data with: + fx \x1b[36m{}\x1b[0m + + Re-run this example with: + cargo run -p edit_prediction_cli -- {} \x1b[36m{}\x1b[0m + "}, + example.name, + e, + err_path.display(), + failed_example_path.display(), + command, + failed_example_path.display(), + ); + if args.failfast || total_examples == 1 { + Progress::global().finalize(); + panic!("{}", msg); + } else { + log::error!("{}", msg); + } } } - } - }); - futures::future::join_all(futures).await; - } - Progress::global().clear(); + }); + futures::future::join_all(futures).await; + } + Progress::global().finalize(); - if args.output.is_some() || !matches!(command, Command::Eval(_)) { - write_examples(&examples, output.as_ref()); + if args.output.is_some() || !matches!(command, Command::Eval(_)) { + write_examples(&examples, output.as_ref()); + } + + match &command { + Command::Predict(args) => predict::sync_batches(&args.provider).await?, + Command::Eval(_) => score::print_report(&examples), + _ => (), + }; + + anyhow::Ok(()) } + .await; - match &command { - Command::Predict(args) => predict::sync_batches(&args.provider).await, - Command::Eval(_) => score::print_report(&examples), - _ => (), - }; + if let Err(e) = result { + panic!("Fatal error: {:?}", e); + } let _ = cx.update(|cx| cx.quit()); }) diff --git a/crates/edit_prediction_cli/src/paths.rs b/crates/edit_prediction_cli/src/paths.rs index 0f470fae556b6d61739ab77083d7edbedf77ef89..e5d420d0e3dbeda9c50b8e5a3683238149dbc604 100644 --- a/crates/edit_prediction_cli/src/paths.rs +++ b/crates/edit_prediction_cli/src/paths.rs @@ -18,6 +18,8 @@ pub static RUN_DIR: LazyLock = LazyLock::new(|| { }); pub static LATEST_EXAMPLE_RUN_DIR: LazyLock = LazyLock::new(|| DATA_DIR.join("latest")); pub static LLM_CACHE_DB: LazyLock = LazyLock::new(|| CACHE_DIR.join("llm_cache.sqlite")); +pub static FAILED_EXAMPLES_DIR: LazyLock = + LazyLock::new(|| ensure_dir(&RUN_DIR.join("failed"))); fn ensure_dir(path: &Path) -> PathBuf { std::fs::create_dir_all(path).expect("Failed to create directory"); diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 3f690266e3165b2d52f642457e7aebf959a40a03..3e6104e3a8afc3adc609df094a70fc34138c1619 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -9,6 +9,7 @@ use crate::{ progress::{InfoStyle, Progress, Step}, retrieve_context::run_context_retrieval, }; +use anyhow::Context as _; use edit_prediction::{DebugEvent, EditPredictionStore}; use futures::{FutureExt as _, StreamExt as _, future::Shared}; use gpui::{AppContext as _, AsyncApp, Task}; @@ -26,14 +27,14 @@ pub async fn run_prediction( repetition_count: usize, app_state: Arc, mut cx: AsyncApp, -) { +) -> anyhow::Result<()> { if !example.predictions.is_empty() { - return; + return Ok(()); } - let provider = provider.unwrap(); + let provider = provider.context("provider is required")?; - run_context_retrieval(example, app_state.clone(), cx.clone()).await; + run_context_retrieval(example, app_state.clone(), cx.clone()).await?; if matches!( provider, @@ -42,14 +43,14 @@ pub async fn run_prediction( let _step_progress = Progress::global().start(Step::Predict, &example.name); if example.prompt.is_none() { - run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await; + run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await?; } let batched = matches!(provider, PredictionProvider::Teacher); return predict_anthropic(example, repetition_count, batched).await; } - run_load_project(example, app_state.clone(), cx.clone()).await; + run_load_project(example, app_state.clone(), cx.clone()).await?; let _step_progress = Progress::global().start(Step::Predict, &example.name); @@ -62,10 +63,9 @@ pub async fn run_prediction( .get_or_init(|| { let client = app_state.client.clone(); cx.spawn(async move |cx| { - client - .sign_in_with_optional_connect(true, cx) - .await - .unwrap(); + if let Err(e) = client.sign_in_with_optional_connect(true, cx).await { + eprintln!("Authentication failed: {}", e); + } }) .shared() }) @@ -73,33 +73,30 @@ pub async fn run_prediction( .await; } - let ep_store = cx - .update(|cx| EditPredictionStore::try_global(cx).unwrap()) - .unwrap(); - - ep_store - .update(&mut cx, |store, _cx| { - let model = match provider { - PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, - PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, - PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, - PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, - PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching => { - unreachable!() - } - }; - store.set_edit_prediction_model(model); - }) - .unwrap(); - let state = example.state.as_ref().unwrap(); + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + ep_store.update(&mut cx, |store, _cx| { + let model = match provider { + PredictionProvider::Zeta1 => edit_prediction::EditPredictionModel::Zeta1, + PredictionProvider::Zeta2 => edit_prediction::EditPredictionModel::Zeta2, + PredictionProvider::Sweep => edit_prediction::EditPredictionModel::Sweep, + PredictionProvider::Mercury => edit_prediction::EditPredictionModel::Mercury, + PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching => { + unreachable!() + } + }; + store.set_edit_prediction_model(model); + })?; + let state = example.state.as_ref().context("state must be set")?; let run_dir = RUN_DIR.join(&example.name); let updated_example = Arc::new(Mutex::new(example.clone())); let current_run_ix = Arc::new(AtomicUsize::new(0)); - let mut debug_rx = ep_store - .update(&mut cx, |store, cx| store.debug_info(&state.project, cx)) - .unwrap(); + let mut debug_rx = + ep_store.update(&mut cx, |store, cx| store.debug_info(&state.project, cx))?; let debug_task = cx.background_spawn({ let updated_example = updated_example.clone(); let current_run_ix = current_run_ix.clone(); @@ -153,14 +150,14 @@ pub async fn run_prediction( run_dir.clone() }; - fs::create_dir_all(&run_dir).unwrap(); + fs::create_dir_all(&run_dir)?; if LATEST_EXAMPLE_RUN_DIR.is_symlink() { - fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR).unwrap(); + fs::remove_file(&*LATEST_EXAMPLE_RUN_DIR)?; } #[cfg(unix)] - std::os::unix::fs::symlink(&run_dir, &*LATEST_EXAMPLE_RUN_DIR).unwrap(); + std::os::unix::fs::symlink(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; #[cfg(windows)] - std::os::windows::fs::symlink_dir(&run_dir, &*LATEST_EXAMPLE_RUN_DIR).unwrap(); + std::os::windows::fs::symlink_dir(&run_dir, &*LATEST_EXAMPLE_RUN_DIR)?; updated_example .lock() @@ -181,10 +178,8 @@ pub async fn run_prediction( cloud_llm_client::PredictEditsRequestTrigger::Cli, cx, ) - }) - .unwrap() - .await - .unwrap(); + })? + .await?; let actual_patch = prediction .and_then(|prediction| { @@ -213,20 +208,23 @@ pub async fn run_prediction( } } - ep_store - .update(&mut cx, |store, _| { - store.remove_project(&state.project); - }) - .unwrap(); - debug_task.await.unwrap(); + ep_store.update(&mut cx, |store, _| { + store.remove_project(&state.project); + })?; + debug_task.await?; *example = Arc::into_inner(updated_example) - .unwrap() + .ok_or_else(|| anyhow::anyhow!("Failed to unwrap Arc"))? .into_inner() - .unwrap(); + .map_err(|_| anyhow::anyhow!("Failed to unwrap Mutex"))?; + Ok(()) } -async fn predict_anthropic(example: &mut Example, _repetition_count: usize, batched: bool) { +async fn predict_anthropic( + example: &mut Example, + _repetition_count: usize, + batched: bool, +) -> anyhow::Result<()> { let llm_model_name = "claude-sonnet-4-5"; let max_tokens = 16384; let llm_client = if batched { @@ -234,12 +232,9 @@ async fn predict_anthropic(example: &mut Example, _repetition_count: usize, batc } else { AnthropicClient::plain() }; - let llm_client = llm_client.expect("Failed to create LLM client"); + let llm_client = llm_client.context("Failed to create LLM client")?; - let prompt = example - .prompt - .as_ref() - .unwrap_or_else(|| panic!("Prompt is required for an example {}", &example.name)); + let prompt = example.prompt.as_ref().context("Prompt is required")?; let messages = vec![anthropic::Message { role: anthropic::Role::User, @@ -251,11 +246,10 @@ async fn predict_anthropic(example: &mut Example, _repetition_count: usize, batc let Some(response) = llm_client .generate(llm_model_name, max_tokens, messages) - .await - .unwrap() + .await? else { // Request stashed for batched processing - return; + return Ok(()); }; let actual_output = response @@ -268,7 +262,7 @@ async fn predict_anthropic(example: &mut Example, _repetition_count: usize, batc .collect::>() .join("\n"); - let actual_patch = TeacherPrompt::parse(example, &actual_output); + let actual_patch = TeacherPrompt::parse(example, &actual_output)?; let prediction = ExamplePrediction { actual_patch, @@ -277,19 +271,21 @@ async fn predict_anthropic(example: &mut Example, _repetition_count: usize, batc }; example.predictions.push(prediction); + Ok(()) } -pub async fn sync_batches(provider: &PredictionProvider) { +pub async fn sync_batches(provider: &PredictionProvider) -> anyhow::Result<()> { match provider { PredictionProvider::Teacher => { let cache_path = crate::paths::LLM_CACHE_DB.as_ref(); let llm_client = - AnthropicClient::batch(cache_path).expect("Failed to create LLM client"); + AnthropicClient::batch(cache_path).context("Failed to create LLM client")?; llm_client .sync_batches() .await - .expect("Failed to sync batches"); + .context("Failed to sync batches")?; } _ => (), - } + }; + Ok(()) } diff --git a/crates/edit_prediction_cli/src/progress.rs b/crates/edit_prediction_cli/src/progress.rs index 8195485d70c70c0cbfb38e2de83a055598d5e4e5..ddc710f202cc98e5932c234cb6bebcc93b28171c 100644 --- a/crates/edit_prediction_cli/src/progress.rs +++ b/crates/edit_prediction_cli/src/progress.rs @@ -20,6 +20,7 @@ struct ProgressInner { max_example_name_len: usize, status_lines_displayed: usize, total_examples: usize, + failed_examples: usize, last_line_is_logging: bool, } @@ -78,7 +79,7 @@ impl Step { static GLOBAL: OnceLock> = OnceLock::new(); static LOGGER: ProgressLogger = ProgressLogger; -const RIGHT_MARGIN: usize = 4; +const MARGIN: usize = 4; const MAX_STATUS_LINES: usize = 10; impl Progress { @@ -95,6 +96,7 @@ impl Progress { max_example_name_len: 0, status_lines_displayed: 0, total_examples: 0, + failed_examples: 0, last_line_is_logging: false, }), }); @@ -110,6 +112,11 @@ impl Progress { inner.total_examples = total; } + pub fn increment_failed(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.failed_examples += 1; + } + /// Prints a message to stderr, clearing and redrawing status lines to avoid corruption. /// This should be used for any output that needs to appear above the status lines. fn log(&self, message: &str) { @@ -119,7 +126,7 @@ impl Progress { if !inner.last_line_is_logging { let reset = "\x1b[0m"; let dim = "\x1b[2m"; - let divider = "─".repeat(inner.terminal_width.saturating_sub(RIGHT_MARGIN)); + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); eprintln!("{dim}{divider}{reset}"); inner.last_line_is_logging = true; } @@ -180,7 +187,7 @@ impl Progress { if inner.last_line_is_logging { let reset = "\x1b[0m"; let dim = "\x1b[2m"; - let divider = "─".repeat(inner.terminal_width.saturating_sub(RIGHT_MARGIN)); + let divider = "─".repeat(inner.terminal_width.saturating_sub(MARGIN)); eprintln!("{dim}{divider}{reset}"); inner.last_line_is_logging = false; } @@ -229,7 +236,7 @@ impl Progress { let duration_with_margin = format!("{duration} "); let padding_needed = inner .terminal_width - .saturating_sub(RIGHT_MARGIN) + .saturating_sub(MARGIN) .saturating_sub(duration_with_margin.len()) .saturating_sub(strip_ansi_len(&prefix)); let padding = " ".repeat(padding_needed); @@ -263,20 +270,33 @@ impl Progress { // Build the done/in-progress/total label let done_count = inner.completed.len(); let in_progress_count = inner.in_progress.len(); + let failed_count = inner.failed_examples; + + let failed_label = if failed_count > 0 { + format!(" {} failed ", failed_count) + } else { + String::new() + }; + let range_label = format!( " {}/{}/{} ", done_count, in_progress_count, inner.total_examples ); - // Print a divider line with range label aligned with timestamps + // Print a divider line with failed count on left, range label on right + let failed_visible_len = strip_ansi_len(&failed_label); let range_visible_len = range_label.len(); - let left_divider_len = inner + let middle_divider_len = inner .terminal_width - .saturating_sub(RIGHT_MARGIN) + .saturating_sub(MARGIN * 2) + .saturating_sub(failed_visible_len) .saturating_sub(range_visible_len); - let left_divider = "─".repeat(left_divider_len); - let right_divider = "─".repeat(RIGHT_MARGIN); - eprintln!("{dim}{left_divider}{reset}{range_label}{dim}{right_divider}{reset}"); + let left_divider = "─".repeat(MARGIN); + let middle_divider = "─".repeat(middle_divider_len); + let right_divider = "─".repeat(MARGIN); + eprintln!( + "{dim}{left_divider}{reset}{failed_label}{dim}{middle_divider}{reset}{range_label}{dim}{right_divider}{reset}" + ); let mut tasks: Vec<_> = inner.in_progress.iter().collect(); tasks.sort_by_key(|(name, _)| *name); @@ -304,7 +324,7 @@ impl Progress { let duration_with_margin = format!("{elapsed} "); let padding_needed = inner .terminal_width - .saturating_sub(RIGHT_MARGIN) + .saturating_sub(MARGIN) .saturating_sub(duration_with_margin.len()) .saturating_sub(strip_ansi_len(&prefix)); let padding = " ".repeat(padding_needed); @@ -324,9 +344,23 @@ impl Progress { let _ = std::io::stderr().flush(); } - pub fn clear(&self) { + pub fn finalize(&self) { let mut inner = self.inner.lock().unwrap(); Self::clear_status_lines(&mut inner); + + // Print summary if there were failures + if inner.failed_examples > 0 { + let total_processed = inner.completed.len() + inner.failed_examples; + let percentage = if total_processed > 0 { + inner.failed_examples as f64 / total_processed as f64 * 100.0 + } else { + 0.0 + }; + eprintln!( + "\n{} of {} examples failed ({:.1}%)", + inner.failed_examples, total_processed, percentage + ); + } } } diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs index c066cf3caa9ece27144222ef94e3ac72c2285be8..a07c7ec8752ff987b8783c4fa15904078bd5612d 100644 --- a/crates/edit_prediction_cli/src/retrieve_context.rs +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -4,6 +4,7 @@ use crate::{ load_project::run_load_project, progress::{InfoStyle, Progress, Step, StepProgress}, }; +use anyhow::Context as _; use collections::HashSet; use edit_prediction::{DebugEvent, EditPredictionStore}; use futures::{FutureExt as _, StreamExt as _, channel::mpsc}; @@ -17,12 +18,12 @@ pub async fn run_context_retrieval( example: &mut Example, app_state: Arc, mut cx: AsyncApp, -) { +) -> anyhow::Result<()> { if example.context.is_some() { - return; + return Ok(()); } - run_load_project(example, app_state.clone(), cx.clone()).await; + run_load_project(example, app_state.clone(), cx.clone()).await?; let step_progress: Arc = Progress::global() .start(Step::Context, &example.name) @@ -31,25 +32,21 @@ pub async fn run_context_retrieval( let state = example.state.as_ref().unwrap(); let project = state.project.clone(); - let _lsp_handle = project - .update(&mut cx, |project, cx| { - project.register_buffer_with_language_servers(&state.buffer, cx) - }) - .unwrap(); - wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx).await; - - let ep_store = cx - .update(|cx| EditPredictionStore::try_global(cx).unwrap()) - .unwrap(); - - let mut events = ep_store - .update(&mut cx, |store, cx| { - store.register_buffer(&state.buffer, &project, cx); - store.set_use_context(true); - store.refresh_context(&project, &state.buffer, state.cursor_position, cx); - store.debug_info(&project, cx) - }) - .unwrap(); + let _lsp_handle = project.update(&mut cx, |project, cx| { + project.register_buffer_with_language_servers(&state.buffer, cx) + })?; + wait_for_language_servers_to_start(&project, &state.buffer, &step_progress, &mut cx).await?; + + let ep_store = cx.update(|cx| { + EditPredictionStore::try_global(cx).context("EditPredictionStore not initialized") + })??; + + let mut events = ep_store.update(&mut cx, |store, cx| { + store.register_buffer(&state.buffer, &project, cx); + store.set_use_context(true); + store.refresh_context(&project, &state.buffer, state.cursor_position, cx); + store.debug_info(&project, cx) + })?; while let Some(event) = events.next().await { match event { @@ -60,9 +57,8 @@ pub async fn run_context_retrieval( } } - let context_files = ep_store - .update(&mut cx, |store, cx| store.context_for_project(&project, cx)) - .unwrap(); + let context_files = + ep_store.update(&mut cx, |store, cx| store.context_for_project(&project, cx))?; let excerpt_count: usize = context_files.iter().map(|f| f.excerpts.len()).sum(); step_progress.set_info(format!("{} excerpts", excerpt_count), InfoStyle::Normal); @@ -70,6 +66,7 @@ pub async fn run_context_retrieval( example.context = Some(ExampleContext { files: context_files, }); + Ok(()) } async fn wait_for_language_servers_to_start( @@ -77,10 +74,8 @@ async fn wait_for_language_servers_to_start( buffer: &Entity, step_progress: &Arc, cx: &mut AsyncApp, -) { - let lsp_store = project - .read_with(cx, |project, _| project.lsp_store()) - .unwrap(); +) -> anyhow::Result<()> { + let lsp_store = project.read_with(cx, |project, _| project.lsp_store())?; let (language_server_ids, mut starting_language_server_ids) = buffer .update(cx, |buffer, cx| { @@ -123,7 +118,7 @@ async fn wait_for_language_servers_to_start( } }, _ = timeout.clone().fuse() => { - panic!("LSP wait timed out after 5 minutes"); + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); } } } @@ -132,8 +127,7 @@ async fn wait_for_language_servers_to_start( if !language_server_ids.is_empty() { project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .unwrap() + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? .detach(); } @@ -175,10 +169,8 @@ async fn wait_for_language_servers_to_start( ]; project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) - .unwrap() - .await - .unwrap(); + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? + .await?; let mut pending_language_server_ids = HashSet::from_iter(language_server_ids.into_iter()); while !pending_language_server_ids.is_empty() { @@ -189,11 +181,12 @@ async fn wait_for_language_servers_to_start( } }, _ = timeout.clone().fuse() => { - panic!("LSP wait timed out after 5 minutes"); + return Err(anyhow::anyhow!("LSP wait timed out after 5 minutes")); } } } drop(subscriptions); step_progress.clear_substatus(); + Ok(()) } diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index b87d8e4df24c8cb12676ed71fe1ea930a841791d..314d19b67259e6a4a0fcff932826325f4366ddde 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -15,7 +15,7 @@ pub async fn run_scoring( args: &PredictArgs, app_state: Arc, cx: AsyncApp, -) { +) -> anyhow::Result<()> { run_prediction( example, Some(args.provider), @@ -23,7 +23,7 @@ pub async fn run_scoring( app_state, cx, ) - .await; + .await?; let _progress = Progress::global().start(Step::Score, &example.name); @@ -43,6 +43,7 @@ pub async fn run_scoring( } example.score = scores; + Ok(()) } fn parse_patch(patch: &str) -> Vec> { From e1d236eaf09bd0120111675962ebebd44e9c6cbf Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Fri, 12 Dec 2025 23:18:13 +0200 Subject: [PATCH 02/67] ep: Apply diff to editable region only and edit history fixes (#44737) Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld Co-authored-by: Agus Zubiaga --- crates/edit_prediction/src/edit_prediction.rs | 3 ++- crates/edit_prediction/src/zeta2.rs | 21 +++++++++++-------- .../edit_prediction_cli/src/format_prompt.rs | 6 +++--- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 6a7c6232d08b15fccacdd80a446432e453a80e20..d9d9c2243d81640a55133843669514d551f64902 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -586,10 +586,11 @@ impl EditPredictionStore { pub fn edit_history_for_project( &self, project: &Entity, + cx: &App, ) -> Vec> { self.projects .get(&project.entity_id()) - .map(|project_state| project_state.events.iter().cloned().collect()) + .map(|project_state| project_state.events(cx)) .unwrap_or_default() } diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs index 8586e6caaea1fdc9c865ddba8894f680d766b4a9..9706e2b9ecd03f6e8ba592210722725f420643d3 100644 --- a/crates/edit_prediction/src/zeta2.rs +++ b/crates/edit_prediction/src/zeta2.rs @@ -228,13 +228,16 @@ pub fn zeta2_prompt_input( } #[cfg(feature = "cli-support")] -pub fn zeta2_output_for_patch(input: &zeta_prompt::ZetaPromptInput, patch: &str) -> String { - eprintln!("{}", patch); - eprintln!("---------------------"); - eprintln!("{}", input.cursor_excerpt); - crate::udiff::apply_diff_to_string( - patch, - &input.cursor_excerpt[input.editable_range_in_excerpt.clone()], - ) - .unwrap() +pub fn zeta2_output_for_patch(input: &zeta_prompt::ZetaPromptInput, patch: &str) -> Result { + let text = &input.cursor_excerpt; + let editable_region = input.editable_range_in_excerpt.clone(); + let old_prefix = &text[..editable_region.start]; + let old_suffix = &text[editable_region.end..]; + + let new = crate::udiff::apply_diff_to_string(patch, text)?; + if !new.starts_with(old_prefix) || !new.ends_with(old_suffix) { + anyhow::bail!("Patch shouldn't affect text outside of editable region"); + } + + Ok(new[editable_region.start..new.len() - old_suffix.len()].to_string()) } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index f8fd9b2023a84abcf59bcb5ba54d2d228a0c6484..c778b708b701492b0cc85a0030a1e9d090ce0724 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -44,7 +44,7 @@ pub async fn run_format_prompt( let state = example.state.as_ref().context("state must be set")?; let snapshot = state.buffer.read_with(&cx, |buffer, _| buffer.snapshot())?; let project = state.project.clone(); - let (_, input) = ep_store.update(&mut cx, |ep_store, _cx| { + let (_, input) = ep_store.update(&mut cx, |ep_store, cx| { anyhow::Ok(zeta2_prompt_input( &snapshot, example @@ -53,7 +53,7 @@ pub async fn run_format_prompt( .context("context must be set")? .files .clone(), - ep_store.edit_history_for_project(&project), + ep_store.edit_history_for_project(&project, cx), example.cursor_path.clone(), example .buffer @@ -63,7 +63,7 @@ pub async fn run_format_prompt( )) })??; let prompt = format_zeta_prompt(&input); - let expected_output = zeta2_output_for_patch(&input, &example.expected_patch.clone()); + let expected_output = zeta2_output_for_patch(&input, &example.expected_patch.clone())?; example.prompt = Some(ExamplePrompt { input: prompt, expected_output, From 329ec645da5a92b18a2d152748f0a37ae167db6b Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Sat, 13 Dec 2025 06:27:09 +0800 Subject: [PATCH 03/67] gpui: Fix tab jitter from oversized scrolling (#42434) --- crates/gpui/src/elements/div.rs | 55 +++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 821f155f96d168e5319d9a8981ca4be75df7b854..c80acacce3d714c56dca0cdb65a4477b4c3b3b0e 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -3193,7 +3193,11 @@ impl ScrollHandle { match active_item.strategy { ScrollStrategy::FirstVisible => { if state.overflow.y == Overflow::Scroll { - if bounds.top() + scroll_offset.y < state.bounds.top() { + let child_height = bounds.size.height; + let viewport_height = state.bounds.size.height; + if child_height > viewport_height { + scroll_offset.y = state.bounds.top() - bounds.top(); + } else if bounds.top() + scroll_offset.y < state.bounds.top() { scroll_offset.y = state.bounds.top() - bounds.top(); } else if bounds.bottom() + scroll_offset.y > state.bounds.bottom() { scroll_offset.y = state.bounds.bottom() - bounds.bottom(); @@ -3206,7 +3210,11 @@ impl ScrollHandle { } if state.overflow.x == Overflow::Scroll { - if bounds.left() + scroll_offset.x < state.bounds.left() { + let child_width = bounds.size.width; + let viewport_width = state.bounds.size.width; + if child_width > viewport_width { + scroll_offset.x = state.bounds.left() - bounds.left(); + } else if bounds.left() + scroll_offset.x < state.bounds.left() { scroll_offset.x = state.bounds.left() - bounds.left(); } else if bounds.right() + scroll_offset.x > state.bounds.right() { scroll_offset.x = state.bounds.right() - bounds.right(); @@ -3268,3 +3276,46 @@ impl ScrollHandle { self.0.borrow().child_bounds.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_handle_aligns_wide_children_to_left_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(80.), px(20.))); + state.child_bounds = vec![Bounds::new(point(px(25.), px(0.)), size(px(200.), px(20.)))]; + state.overflow.x = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().x, px(-25.)); + } + + #[test] + fn scroll_handle_aligns_tall_children_to_top_edge() { + let handle = ScrollHandle::new(); + { + let mut state = handle.0.borrow_mut(); + state.bounds = Bounds::new(point(px(0.), px(0.)), size(px(20.), px(80.))); + state.child_bounds = vec![Bounds::new(point(px(0.), px(25.)), size(px(20.), px(200.)))]; + state.overflow.y = Overflow::Scroll; + state.active_item = Some(ScrollActiveItem { + index: 0, + strategy: ScrollStrategy::default(), + }); + } + + handle.scroll_to_active_item(); + + assert_eq!(handle.offset().y, px(-25.)); + } +} From fad06dd00cd6843dcfa284805cda070a18f3681c Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:59:35 -0500 Subject: [PATCH 04/67] git: Show all branches in branch picker empty state (#44742) This fixes an issue where a user could get confused by the branch picker because it would only show the 10 most recent branches, instead of all branches. Release Notes: - git: Show all branches in branch picker when search field is empty --- crates/git_ui/src/branch_picker.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 90b5c4bb284112c8a13ad406da2b7424e982298a..8a08736d8bace6a77963c4325406d340903f1b73 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -636,7 +636,6 @@ impl PickerDelegate for BranchListDelegate { return Task::ready(()); }; - const RECENT_BRANCHES_COUNT: usize = 10; let display_remotes = self.display_remotes; cx.spawn_in(window, async move |picker, cx| { let mut matches: Vec = if query.is_empty() { @@ -649,7 +648,6 @@ impl PickerDelegate for BranchListDelegate { !branch.is_remote() } }) - .take(RECENT_BRANCHES_COUNT) .map(|branch| Entry::Branch { branch, positions: Vec::new(), From e860252185bbefc30b1a9a051167a42c549bd6b0 Mon Sep 17 00:00:00 2001 From: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:01:16 +0100 Subject: [PATCH 05/67] gpui: Improve path rendering and bounds performance (#44655) --- crates/gpui/src/bounds_tree.rs | 464 +++++++++++++++++++++------------ 1 file changed, 297 insertions(+), 167 deletions(-) diff --git a/crates/gpui/src/bounds_tree.rs b/crates/gpui/src/bounds_tree.rs index d621609bf7334801059513e03dfd11b4036ea816..9cf86a2cc9b6def8fbf5ca7e94f7cd19236468cc 100644 --- a/crates/gpui/src/bounds_tree.rs +++ b/crates/gpui/src/bounds_tree.rs @@ -5,14 +5,91 @@ use std::{ ops::{Add, Sub}, }; +/// Maximum children per internal node (R-tree style branching factor). +/// Higher values = shorter tree = fewer cache misses, but more work per node. +const MAX_CHILDREN: usize = 12; + +/// A spatial tree optimized for finding maximum ordering among intersecting bounds. +/// +/// This is an R-tree variant specifically designed for the use case of assigning +/// z-order to overlapping UI elements. Key optimizations: +/// - Tracks the leaf with global max ordering for O(1) fast-path queries +/// - Uses higher branching factor (4) for lower tree height +/// - Aggressive pruning during search based on max_order metadata #[derive(Debug)] pub(crate) struct BoundsTree where U: Clone + Debug + Default + PartialEq, { - root: Option, + /// All nodes stored contiguously for cache efficiency. nodes: Vec>, - stack: Vec, + /// Index of the root node, if any. + root: Option, + /// Index of the leaf with the highest ordering (for fast-path lookups). + max_leaf: Option, + /// Reusable stack for tree traversal during insertion. + insert_path: Vec, + /// Reusable stack for search operations. + search_stack: Vec, +} + +/// A node in the bounds tree. +#[derive(Debug, Clone)] +struct Node +where + U: Clone + Debug + Default + PartialEq, +{ + /// Bounding box containing this node and all descendants. + bounds: Bounds, + /// Maximum ordering value in this subtree. + max_order: u32, + /// Node-specific data. + kind: NodeKind, +} + +#[derive(Debug, Clone)] +enum NodeKind { + /// Leaf node containing actual bounds data. + Leaf { + /// The ordering assigned to this bounds. + order: u32, + }, + /// Internal node with children. + Internal { + /// Indices of child nodes (2 to MAX_CHILDREN). + children: NodeChildren, + }, +} + +/// Fixed-size array for child indices, avoiding heap allocation. +#[derive(Debug, Clone)] +struct NodeChildren { + // Keeps an invariant where the max order child is always at the end + indices: [usize; MAX_CHILDREN], + len: u8, +} + +impl NodeChildren { + fn new() -> Self { + Self { + indices: [0; MAX_CHILDREN], + len: 0, + } + } + + fn push(&mut self, index: usize) { + debug_assert!((self.len as usize) < MAX_CHILDREN); + self.indices[self.len as usize] = index; + self.len += 1; + } + + fn len(&self) -> usize { + self.len as usize + } + + fn as_slice(&self) -> &[usize] { + &self.indices[..self.len as usize] + } } impl BoundsTree @@ -26,158 +103,250 @@ where + Half + Default, { + /// Clears all nodes from the tree. pub fn clear(&mut self) { - self.root = None; self.nodes.clear(); - self.stack.clear(); + self.root = None; + self.max_leaf = None; + self.insert_path.clear(); + self.search_stack.clear(); } + /// Inserts bounds into the tree and returns its assigned ordering. + /// + /// The ordering is one greater than the maximum ordering of any + /// existing bounds that intersect with the new bounds. pub fn insert(&mut self, new_bounds: Bounds) -> u32 { - // If the tree is empty, make the root the new leaf. - let Some(mut index) = self.root else { - let new_node = self.push_leaf(new_bounds, 1); - self.root = Some(new_node); - return 1; + // Find maximum ordering among intersecting bounds + let max_intersecting = self.find_max_ordering(&new_bounds); + let ordering = max_intersecting + 1; + + // Insert the new leaf + let new_leaf_idx = self.insert_leaf(new_bounds, ordering); + + // Update max_leaf tracking + self.max_leaf = match self.max_leaf { + None => Some(new_leaf_idx), + Some(old_idx) if self.nodes[old_idx].max_order < ordering => Some(new_leaf_idx), + some => some, }; - // Search for the best place to add the new leaf based on heuristics. - let mut max_intersecting_ordering = 0; - while let Node::Internal { - left, - right, - bounds: node_bounds, - .. - } = &mut self.nodes[index] - { - let left = *left; - let right = *right; - *node_bounds = node_bounds.union(&new_bounds); - self.stack.push(index); - - // Descend to the best-fit child, based on which one would increase - // the surface area the least. This attempts to keep the tree balanced - // in terms of surface area. If there is an intersection with the other child, - // add its keys to the intersections vector. - let left_cost = new_bounds.union(self.nodes[left].bounds()).half_perimeter(); - let right_cost = new_bounds - .union(self.nodes[right].bounds()) - .half_perimeter(); - if left_cost < right_cost { - max_intersecting_ordering = - self.find_max_ordering(right, &new_bounds, max_intersecting_ordering); - index = left; - } else { - max_intersecting_ordering = - self.find_max_ordering(left, &new_bounds, max_intersecting_ordering); - index = right; + ordering + } + + /// Finds the maximum ordering among all bounds that intersect with the query. + fn find_max_ordering(&mut self, query: &Bounds) -> u32 { + let Some(root_idx) = self.root else { + return 0; + }; + + // Fast path: check if the max-ordering leaf intersects + if let Some(max_idx) = self.max_leaf { + let max_node = &self.nodes[max_idx]; + if query.intersects(&max_node.bounds) { + return max_node.max_order; } } - // We've found a leaf ('index' now refers to a leaf node). - // We'll insert a new parent node above the leaf and attach our new leaf to it. - let sibling = index; - - // Check for collision with the located leaf node - let Node::Leaf { - bounds: sibling_bounds, - order: sibling_ordering, - .. - } = &self.nodes[index] - else { - unreachable!(); - }; - if sibling_bounds.intersects(&new_bounds) { - max_intersecting_ordering = cmp::max(max_intersecting_ordering, *sibling_ordering); + // Slow path: search the tree + self.search_stack.clear(); + self.search_stack.push(root_idx); + + let mut max_found = 0u32; + + while let Some(node_idx) = self.search_stack.pop() { + let node = &self.nodes[node_idx]; + + // Pruning: skip if this subtree can't improve our result + if node.max_order <= max_found { + continue; + } + + // Spatial pruning: skip if bounds don't intersect + if !query.intersects(&node.bounds) { + continue; + } + + match &node.kind { + NodeKind::Leaf { order } => { + max_found = cmp::max(max_found, *order); + } + NodeKind::Internal { children } => { + // Children are maintained with highest max_order at the end. + // Push in forward order to highest (last) is popped first. + for &child_idx in children.as_slice() { + if self.nodes[child_idx].max_order > max_found { + self.search_stack.push(child_idx); + } + } + } + } } - let ordering = max_intersecting_ordering + 1; - let new_node = self.push_leaf(new_bounds, ordering); - let new_parent = self.push_internal(sibling, new_node); + max_found + } - // If there was an old parent, we need to update its children indices. - if let Some(old_parent) = self.stack.last().copied() { - let Node::Internal { left, right, .. } = &mut self.nodes[old_parent] else { - unreachable!(); - }; + /// Inserts a leaf node with the given bounds and ordering. + /// Returns the index of the new leaf. + fn insert_leaf(&mut self, bounds: Bounds, order: u32) -> usize { + let new_leaf_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: bounds.clone(), + max_order: order, + kind: NodeKind::Leaf { order }, + }); - if *left == sibling { - *left = new_parent; + let Some(root_idx) = self.root else { + // Tree is empty, new leaf becomes root + self.root = Some(new_leaf_idx); + return new_leaf_idx; + }; + + // If root is a leaf, create internal node with both + if matches!(self.nodes[root_idx].kind, NodeKind::Leaf { .. }) { + let root_bounds = self.nodes[root_idx].bounds.clone(); + let root_order = self.nodes[root_idx].max_order; + + let mut children = NodeChildren::new(); + // Max end invariant + if order > root_order { + children.push(root_idx); + children.push(new_leaf_idx); } else { - *right = new_parent; + children.push(new_leaf_idx); + children.push(root_idx); } - } else { - // If the old parent was the root, the new parent is the new root. - self.root = Some(new_parent); + + let new_root_idx = self.nodes.len(); + self.nodes.push(Node { + bounds: root_bounds.union(&bounds), + max_order: cmp::max(root_order, order), + kind: NodeKind::Internal { children }, + }); + self.root = Some(new_root_idx); + return new_leaf_idx; } - for node_index in self.stack.drain(..).rev() { - let Node::Internal { - max_order: max_ordering, - .. - } = &mut self.nodes[node_index] - else { - unreachable!() + // Descend to find the best internal node to insert into + self.insert_path.clear(); + let mut current_idx = root_idx; + + loop { + let current = &self.nodes[current_idx]; + let NodeKind::Internal { children } = ¤t.kind else { + unreachable!("Should only traverse internal nodes"); }; - if *max_ordering >= ordering { - break; - } - *max_ordering = ordering; - } - ordering - } + self.insert_path.push(current_idx); + + // Find the best child to descend into + let mut best_child_idx = children.as_slice()[0]; + let mut best_child_pos = 0; + let mut best_cost = bounds + .union(&self.nodes[best_child_idx].bounds) + .half_perimeter(); - fn find_max_ordering(&self, index: usize, bounds: &Bounds, mut max_ordering: u32) -> u32 { - match &self.nodes[index] { - Node::Leaf { - bounds: node_bounds, - order: ordering, - .. - } => { - if bounds.intersects(node_bounds) { - max_ordering = cmp::max(*ordering, max_ordering); + for (pos, &child_idx) in children.as_slice().iter().enumerate().skip(1) { + let cost = bounds.union(&self.nodes[child_idx].bounds).half_perimeter(); + if cost < best_cost { + best_cost = cost; + best_child_idx = child_idx; + best_child_pos = pos; } } - Node::Internal { - left, - right, - bounds: node_bounds, - max_order: node_max_ordering, - .. - } => { - if bounds.intersects(node_bounds) && max_ordering < *node_max_ordering { - let left_max_ordering = self.nodes[*left].max_ordering(); - let right_max_ordering = self.nodes[*right].max_ordering(); - if left_max_ordering > right_max_ordering { - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); + + // Check if best child is a leaf or internal + if matches!(self.nodes[best_child_idx].kind, NodeKind::Leaf { .. }) { + // Best child is a leaf. Check if current node has room for another child. + if children.len() < MAX_CHILDREN { + // Add new leaf directly to this node + let node = &mut self.nodes[current_idx]; + + if let NodeKind::Internal { children } = &mut node.kind { + children.push(new_leaf_idx); + // Swap new leaf only if it has the highest max_order + if order <= node.max_order { + let last = children.len() - 1; + children.indices.swap(last - 1, last); + } + } + + node.bounds = node.bounds.union(&bounds); + node.max_order = cmp::max(node.max_order, order); + break; + } else { + // Node is full, create new internal with [best_leaf, new_leaf] + let sibling_bounds = self.nodes[best_child_idx].bounds.clone(); + let sibling_order = self.nodes[best_child_idx].max_order; + + let mut new_children = NodeChildren::new(); + // Max end invariant + if order > sibling_order { + new_children.push(best_child_idx); + new_children.push(new_leaf_idx); } else { - max_ordering = self.find_max_ordering(*right, bounds, max_ordering); - max_ordering = self.find_max_ordering(*left, bounds, max_ordering); + new_children.push(new_leaf_idx); + new_children.push(best_child_idx); + } + + let new_internal_idx = self.nodes.len(); + let new_internal_max = cmp::max(sibling_order, order); + self.nodes.push(Node { + bounds: sibling_bounds.union(&bounds), + max_order: new_internal_max, + kind: NodeKind::Internal { + children: new_children, + }, + }); + + // Replace the leaf with the new internal in parent + let parent = &mut self.nodes[current_idx]; + if let NodeKind::Internal { children } = &mut parent.kind { + let children_len = children.len(); + + children.indices[best_child_pos] = new_internal_idx; + + // If new internal has highest max_order, swap it to the end + // to maintain sorting invariant + if new_internal_max > parent.max_order { + children.indices.swap(best_child_pos, children_len - 1); + } } + break; } + } else { + // Best child is internal, continue descent + current_idx = best_child_idx; } } - max_ordering - } - fn push_leaf(&mut self, bounds: Bounds, order: u32) -> usize { - self.nodes.push(Node::Leaf { bounds, order }); - self.nodes.len() - 1 - } + // Propagate bounds and max_order updates up the tree + let mut updated_child_idx = None; + for &node_idx in self.insert_path.iter().rev() { + let node = &mut self.nodes[node_idx]; + node.bounds = node.bounds.union(&bounds); - fn push_internal(&mut self, left: usize, right: usize) -> usize { - let left_node = &self.nodes[left]; - let right_node = &self.nodes[right]; - let new_bounds = left_node.bounds().union(right_node.bounds()); - let max_ordering = cmp::max(left_node.max_ordering(), right_node.max_ordering()); - self.nodes.push(Node::Internal { - bounds: new_bounds, - left, - right, - max_order: max_ordering, - }); - self.nodes.len() - 1 + if node.max_order < order { + node.max_order = order; + + // Swap updated child to end (skip first iteration since the invariant is already handled by previous cases) + if let Some(child_idx) = updated_child_idx { + if let NodeKind::Internal { children } = &mut node.kind { + if let Some(pos) = children.as_slice().iter().position(|&c| c == child_idx) + { + let last = children.len() - 1; + if pos != last { + children.indices.swap(pos, last); + } + } + } + } + } + + updated_child_idx = Some(node_idx); + } + + new_leaf_idx } } @@ -187,50 +356,11 @@ where { fn default() -> Self { BoundsTree { - root: None, nodes: Vec::new(), - stack: Vec::new(), - } - } -} - -#[derive(Debug, Clone)] -enum Node -where - U: Clone + Debug + Default + PartialEq, -{ - Leaf { - bounds: Bounds, - order: u32, - }, - Internal { - left: usize, - right: usize, - bounds: Bounds, - max_order: u32, - }, -} - -impl Node -where - U: Clone + Debug + Default + PartialEq, -{ - fn bounds(&self) -> &Bounds { - match self { - Node::Leaf { bounds, .. } => bounds, - Node::Internal { bounds, .. } => bounds, - } - } - - fn max_ordering(&self) -> u32 { - match self { - Node::Leaf { - order: ordering, .. - } => *ordering, - Node::Internal { - max_order: max_ordering, - .. - } => *max_ordering, + root: None, + max_leaf: None, + insert_path: Vec::new(), + search_stack: Vec::new(), } } } From 4754422ef4563754cc3a93b7bfc6db964c3ec5bd Mon Sep 17 00:00:00 2001 From: Haojian Wu Date: Sat, 13 Dec 2025 01:38:44 +0100 Subject: [PATCH 06/67] Add angled bracket highlighting for C++ (#44735) Enables rainbow bracket highlighting for angle brackets (< >) in C++. image Release Notes: - Added rainbow bracket coloring for C++ angle brackets (`<>`) --- crates/languages/src/cpp/brackets.scm | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/languages/src/cpp/brackets.scm b/crates/languages/src/cpp/brackets.scm index 2149bddc6c9a7ec04667d03da75580b676e12a28..9eaebba332861ef716902b3827d4940b71f37221 100644 --- a/crates/languages/src/cpp/brackets.scm +++ b/crates/languages/src/cpp/brackets.scm @@ -1,5 +1,6 @@ ("(" @open ")" @close) ("[" @open "]" @close) ("{" @open "}" @close) +("<" @open ">" @close) (("\"" @open "\"" @close) (#set! rainbow.exclude)) (("'" @open "'" @close) (#set! rainbow.exclude)) From 6e0ecbcb07380a607206902dd4ab1d8da49f0ff6 Mon Sep 17 00:00:00 2001 From: Josh Ayres Date: Fri, 12 Dec 2025 16:41:31 -0800 Subject: [PATCH 07/67] docs: Use `relative_line_numbers` instead of `toggle_relative_line_numbers` (#44749) Just a small docs change With the deprecation of `toggle_relative_line_numbers` the docs should reflect that Release Notes: - N/A --- docs/src/vim.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/vim.md b/docs/src/vim.md index c9a0cd09f2dafb9f07a26ef07b71205f5ddbdf15..9ba1b059223f147d73398a1ec91e6d818ff92c8a 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -566,7 +566,8 @@ You can change the following settings to modify vim mode's behavior: | use_system_clipboard | Determines how system clipboard is used:
  • "always": use for all operations
  • "never": only use when explicitly specified
  • "on_yank": use for yank operations
| "always" | | use_multiline_find | deprecated | | use_smartcase_find | If `true`, `f` and `t` motions are case-insensitive when the target letter is lowercase. | false | -| toggle_relative_line_numbers | If `true`, line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | false | +| toggle_relative_line_numbers | deprecated | false | +| relative_line_numbers | If "enabled", line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | "disabled" | | custom_digraphs | An object that allows you to add custom digraphs. Read below for an example. | {} | | highlight_on_yank_duration | The duration of the highlight animation(in ms). Set to `0` to disable | 200 | @@ -590,7 +591,7 @@ Here's an example of these settings changed: "default_mode": "insert", "use_system_clipboard": "never", "use_smartcase_find": true, - "toggle_relative_line_numbers": true, + "relative_line_numbers": "enabled", "highlight_on_yank_duration": 50, "custom_digraphs": { "fz": "🧟‍♀️" From 56daba28d40301ee4c05546fadb691d070b7b2b6 Mon Sep 17 00:00:00 2001 From: Michael Benfield Date: Fri, 12 Dec 2025 16:56:06 -0800 Subject: [PATCH 08/67] supports_streaming_tools member (#44753) Release Notes: - N/A --- crates/cloud_llm_client/src/cloud_llm_client.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 917929a985c85610b907e682792e132cb84d8403..2c5b2649000bb071b9d206d9d2c204f1eea9bda1 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -371,6 +371,8 @@ pub struct LanguageModel { pub supports_images: bool, pub supports_thinking: bool, pub supports_max_mode: bool, + #[serde(default)] + pub supports_streaming_tools: bool, // only used by OpenAI and xAI #[serde(default)] pub supports_parallel_tool_calls: bool, From 0283bfb04949295086b5ce6c892defa9c3ecc008 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 13 Dec 2025 13:06:30 -0300 Subject: [PATCH 09/67] Enable configuring edit prediction providers through the settings UI (#44505) - Edit prediction providers can now be configured through the settings UI - Cleaned up the status bar menu to only show _configured_ providers - Added to the status bar icon button tooltip the name of the active provider - Only display the data collection functionality under "Privacy" for the Zed models - Moved the Codestral edit prediction provider out of the Mistral section in the agent panel into the settings UI - Refined and improved UI and states for configuring GitHub Copilot as both an agent and edit prediction provider #### Todos before merge: - [x] UI: Unify with settings UI style and tidy it all up - [x] Unify Copilot modal `impl`s to use separate window - [x] Remove stop light icons from GitHub modal - [x] Make dismiss events work on GitHub modal - [ ] Investigate workarounds to tell if Copilot authenticated even when LSP not running Release Notes: - settings_ui: Added a section for configuring edit prediction providers under AI > Edit Predictions, including Codestral and GitHub Copilot. Once you've updated you can use the following link to open it: zed://settings/edit_predictions.providers --------- Co-authored-by: Ben Kunkle --- Cargo.lock | 8 +- assets/settings/default.json | 5 +- crates/agent_ui/src/agent_configuration.rs | 6 +- crates/copilot/src/copilot.rs | 57 +- crates/copilot/src/sign_in.rs | 660 +++++++++++++----- crates/edit_prediction/Cargo.toml | 1 - crates/edit_prediction/src/edit_prediction.rs | 19 +- crates/edit_prediction/src/mercury.rs | 82 +-- crates/edit_prediction/src/sweep_ai.rs | 73 +- .../src/zed_edit_prediction_delegate.rs | 2 +- crates/edit_prediction_ui/Cargo.toml | 3 +- .../src/edit_prediction_button.rs | 520 ++++++-------- .../src/edit_prediction_ui.rs | 2 - .../src/external_provider_api_token_modal.rs | 86 --- crates/language_model/Cargo.toml | 2 + .../src/api_key.rs | 21 +- crates/language_model/src/language_model.rs | 3 + crates/language_models/Cargo.toml | 1 - crates/language_models/src/language_models.rs | 2 - .../language_models/src/provider/anthropic.rs | 49 +- .../language_models/src/provider/bedrock.rs | 51 +- .../src/provider/copilot_chat.rs | 109 +-- .../language_models/src/provider/deepseek.rs | 49 +- crates/language_models/src/provider/google.rs | 43 +- .../language_models/src/provider/lmstudio.rs | 13 +- .../language_models/src/provider/mistral.rs | 236 ++----- crates/language_models/src/provider/ollama.rs | 49 +- .../language_models/src/provider/open_ai.rs | 56 +- .../src/provider/open_ai_compatible.rs | 28 +- .../src/provider/open_router.rs | 48 +- crates/language_models/src/provider/vercel.rs | 50 +- crates/language_models/src/provider/x_ai.rs | 51 +- crates/language_models/src/ui.rs | 4 - .../src/ui/instruction_list_item.rs | 69 -- .../settings/src/settings_content/language.rs | 4 +- crates/settings_ui/Cargo.toml | 5 +- crates/settings_ui/src/components.rs | 2 + .../settings_ui/src/components/input_field.rs | 1 + .../src/components/section_items.rs | 56 ++ crates/settings_ui/src/page_data.rs | 60 +- crates/settings_ui/src/pages.rs | 2 + .../pages/edit_prediction_provider_setup.rs | 365 ++++++++++ crates/settings_ui/src/settings_ui.rs | 222 +++--- crates/ui/src/components.rs | 4 + crates/ui/src/components/ai.rs | 3 + .../src/components/ai}/configured_api_card.rs | 17 +- .../ai/copilot_configuration_callout.rs | 0 crates/ui/src/components/button.rs | 2 + .../ui/src/components/button/button_link.rs | 102 +++ crates/ui/src/components/divider.rs | 18 +- crates/ui/src/components/inline_code.rs | 64 ++ crates/ui/src/components/label/label_like.rs | 2 +- .../src/components/list/list_bullet_item.rs | 88 ++- crates/workspace/src/notifications.rs | 2 +- crates/zed_env_vars/src/zed_env_vars.rs | 5 +- 55 files changed, 1907 insertions(+), 1575 deletions(-) delete mode 100644 crates/edit_prediction_ui/src/external_provider_api_token_modal.rs rename crates/{language_models => language_model}/src/api_key.rs (95%) delete mode 100644 crates/language_models/src/ui.rs delete mode 100644 crates/language_models/src/ui/instruction_list_item.rs create mode 100644 crates/settings_ui/src/components/section_items.rs create mode 100644 crates/settings_ui/src/pages.rs create mode 100644 crates/settings_ui/src/pages/edit_prediction_provider_setup.rs create mode 100644 crates/ui/src/components/ai.rs rename crates/{language_models/src/ui => ui/src/components/ai}/configured_api_card.rs (84%) create mode 100644 crates/ui/src/components/ai/copilot_configuration_callout.rs create mode 100644 crates/ui/src/components/button/button_link.rs create mode 100644 crates/ui/src/components/inline_code.rs diff --git a/Cargo.lock b/Cargo.lock index 981f59cb5eae413f165fdee7e8cce7c827b8c25c..cc7f8b0a85fd21dd7cae57e1ffc5348d70defbed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5111,7 +5111,6 @@ dependencies = [ "cloud_llm_client", "collections", "copilot", - "credentials_provider", "ctor", "db", "edit_prediction_context", @@ -5275,7 +5274,6 @@ dependencies = [ "text", "theme", "ui", - "ui_input", "util", "workspace", "zed_actions", @@ -8802,6 +8800,7 @@ dependencies = [ "cloud_api_types", "cloud_llm_client", "collections", + "credentials_provider", "futures 0.3.31", "gpui", "http_client", @@ -8820,6 +8819,7 @@ dependencies = [ "telemetry_events", "thiserror 2.0.17", "util", + "zed_env_vars", ] [[package]] @@ -8876,7 +8876,6 @@ dependencies = [ "util", "vercel", "x_ai", - "zed_env_vars", ] [[package]] @@ -14778,6 +14777,8 @@ dependencies = [ "assets", "bm25", "client", + "copilot", + "edit_prediction", "editor", "feature_flags", "fs", @@ -14786,6 +14787,7 @@ dependencies = [ "gpui", "heck 0.5.0", "language", + "language_models", "log", "menu", "node_runtime", diff --git a/assets/settings/default.json b/assets/settings/default.json index 2eea3c34c6be3f34b5db9d5849b9070c5cfc7963..58564138227f361e5432d377358b18734f250d72 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1410,8 +1410,9 @@ "proxy_no_verify": null, }, "codestral": { - "model": null, - "max_tokens": null, + "api_url": "https://codestral.mistral.ai", + "model": "codestral-latest", + "max_tokens": 150, }, // Whether edit predictions are enabled when editing text threads in the agent panel. // This setting has no effect if globally disabled. diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 327f699b4dbf5512a60637d8fce2edfba75280f0..8619b085c00268d6d157dee37411ff36ba4d5680 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -34,9 +34,9 @@ use project::{ }; use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ - Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, - Divider, DividerColor, ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize, - PopoverMenu, Switch, Tooltip, WithScrollbar, prelude::*, + ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider, + DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip, + WithScrollbar, prelude::*, }; use util::ResultExt as _; use workspace::{Workspace, create_and_open_local_file}; diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 4e6520906074c1384a4e500d89be43659c162718..45f0796bf53acfef1fb1e81146c0de7c5187fb99 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -4,7 +4,7 @@ pub mod copilot_responses; pub mod request; mod sign_in; -use crate::sign_in::initiate_sign_in_within_workspace; +use crate::sign_in::initiate_sign_out; use ::fs::Fs; use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; @@ -28,12 +28,10 @@ use project::DisableAiSettings; use request::StatusNotification; use semver::Version; use serde_json::json; -use settings::Settings; -use settings::SettingsStore; -use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace}; -use std::collections::hash_map::Entry; +use settings::{Settings, SettingsStore}; use std::{ any::TypeId, + collections::hash_map::Entry, env, ffi::OsString, mem, @@ -42,12 +40,14 @@ use std::{ sync::Arc, }; use sum_tree::Dimensions; -use util::rel_path::RelPath; -use util::{ResultExt, fs::remove_matching}; +use util::{ResultExt, fs::remove_matching, rel_path::RelPath}; use workspace::Workspace; pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate; -pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_and_sign_in}; +pub use crate::sign_in::{ + ConfigurationMode, ConfigurationView, CopilotCodeVerification, initiate_sign_in, + reinstall_and_sign_in, +}; actions!( copilot, @@ -98,21 +98,14 @@ pub fn init( .detach(); cx.observe_new(|workspace: &mut Workspace, _window, _cx| { - workspace.register_action(|workspace, _: &SignIn, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); - } + workspace.register_action(|_, _: &SignIn, window, cx| { + initiate_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &Reinstall, window, cx| { - if let Some(copilot) = Copilot::global(cx) { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - } + workspace.register_action(|_, _: &Reinstall, window, cx| { + reinstall_and_sign_in(window, cx); }); - workspace.register_action(|workspace, _: &SignOut, _window, cx| { - if let Some(copilot) = Copilot::global(cx) { - sign_out_within_workspace(workspace, copilot, cx); - } + workspace.register_action(|_, _: &SignOut, window, cx| { + initiate_sign_out(window, cx); }); }) .detach(); @@ -375,7 +368,7 @@ impl Copilot { } } - fn start_copilot( + pub fn start_copilot( &mut self, check_edit_prediction_provider: bool, awaiting_sign_in_after_start: bool, @@ -563,6 +556,14 @@ impl Copilot { let server = start_language_server.await; this.update(cx, |this, cx| { cx.notify(); + + if env::var("ZED_FORCE_COPILOT_ERROR").is_ok() { + this.server = CopilotServer::Error( + "Forced error for testing (ZED_FORCE_COPILOT_ERROR)".into(), + ); + return; + } + match server { Ok((server, status)) => { this.server = CopilotServer::Running(RunningCopilotServer { @@ -584,7 +585,17 @@ impl Copilot { .ok(); } - pub(crate) fn sign_in(&mut self, cx: &mut Context) -> Task> { + pub fn is_authenticated(&self) -> bool { + return matches!( + self.server, + CopilotServer::Running(RunningCopilotServer { + sign_in_status: SignInStatus::Authorized, + .. + }) + ); + } + + pub fn sign_in(&mut self, cx: &mut Context) -> Task> { if let CopilotServer::Running(server) = &mut self.server { let task = match &server.sign_in_status { SignInStatus::Authorized => Task::ready(Ok(())).shared(), diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs index 464a114d4ea11bca5597a6a91fd831ade050baaa..0bcb11e18be1994ea92703973ad1278c5d5aa4f8 100644 --- a/crates/copilot/src/sign_in.rs +++ b/crates/copilot/src/sign_in.rs @@ -1,160 +1,151 @@ use crate::{Copilot, Status, request::PromptUserDeviceFlow}; +use anyhow::Context as _; use gpui::{ - Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity, - EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent, - ParentElement, Render, Styled, Subscription, Transformation, Window, div, percentage, svg, + App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled, + Subscription, Window, WindowBounds, WindowOptions, div, point, }; -use std::time::Duration; -use ui::{Button, Label, Vector, VectorName, prelude::*}; +use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*}; use util::ResultExt as _; -use workspace::notifications::NotificationId; -use workspace::{ModalView, Toast, Workspace}; +use workspace::{Toast, Workspace, notifications::NotificationId}; const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot"; +const ERROR_LABEL: &str = + "Copilot had issues starting. You can try reinstalling it and signing in again."; struct CopilotStatusToast; pub fn initiate_sign_in(window: &mut Window, cx: &mut App) { + let is_reinstall = false; + initiate_sign_in_impl(is_reinstall, window, cx) +} + +pub fn initiate_sign_out(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; - let Some(workspace) = window.root::().flatten() else { - return; - }; - workspace.update(cx, |workspace, cx| { - let is_reinstall = false; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx) - }); + + copilot_toast(Some("Signing out of Copilot…"), window, cx); + + let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); + window + .spawn(cx, async move |cx| match sign_out_task.await { + Ok(()) => { + cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx)) + } + Err(err) => cx.update(|window, cx| { + if let Some(workspace) = window.root::().flatten() { + workspace.update(cx, |workspace, cx| { + workspace.show_error(&err, cx); + }) + } else { + log::error!("{:?}", err); + } + }), + }) + .detach(); } pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) { let Some(copilot) = Copilot::global(cx) else { return; }; + let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); + let is_reinstall = true; + initiate_sign_in_impl(is_reinstall, window, cx); +} + +fn open_copilot_code_verification_window(copilot: &Entity, window: &Window, cx: &mut App) { + let current_window_center = window.bounds().center(); + let height = px(450.); + let width = px(350.); + let window_bounds = WindowBounds::Windowed(gpui::bounds( + current_window_center - point(height / 2.0, width / 2.0), + gpui::size(height, width), + )); + cx.open_window( + WindowOptions { + kind: gpui::WindowKind::PopUp, + window_bounds: Some(window_bounds), + is_resizable: false, + is_movable: true, + titlebar: Some(gpui::TitlebarOptions { + appears_transparent: true, + ..Default::default() + }), + ..Default::default() + }, + |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)), + ) + .context("Failed to open Copilot code verification window") + .log_err(); +} + +fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) { + const NOTIFICATION_ID: NotificationId = NotificationId::unique::(); + let Some(workspace) = window.root::().flatten() else { return; }; - workspace.update(cx, |workspace, cx| { - reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx); - }); -} -pub fn reinstall_and_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - window: &mut Window, - cx: &mut Context, -) { - let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx)); - let is_reinstall = true; - initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx); + workspace.update(cx, |workspace, cx| match message { + Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx), + None => workspace.dismiss_toast(&NOTIFICATION_ID, cx), + }); } -pub fn initiate_sign_in_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - is_reinstall: bool, - window: &mut Window, - cx: &mut Context, -) { +pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) { + let Some(copilot) = Copilot::global(cx) else { + return; + }; if matches!(copilot.read(cx).status(), Status::Disabled) { copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx)); } match copilot.read(cx).status() { Status::Starting { task } => { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - if is_reinstall { - "Copilot is reinstalling..." - } else { - "Copilot is starting..." - }, - ), + copilot_toast( + Some(if is_reinstall { + "Copilot is reinstalling…" + } else { + "Copilot is starting…" + }), + window, cx, ); - cx.spawn_in(window, async move |workspace, cx| { - task.await; - if let Some(copilot) = cx.update(|_window, cx| Copilot::global(cx)).ok().flatten() { - workspace - .update_in(cx, |workspace, window, cx| { - match copilot.read(cx).status() { - Status::Authorized => workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Copilot has started.", - ), - cx, - ), - _ => { - workspace.dismiss_toast( - &NotificationId::unique::(), - cx, - ); - copilot - .update(cx, |copilot, cx| copilot.sign_in(cx)) - .detach_and_log_err(cx); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); - } + window + .spawn(cx, async move |cx| { + task.await; + cx.update(|window, cx| { + let Some(copilot) = Copilot::global(cx) else { + return; + }; + match copilot.read(cx).status() { + Status::Authorized => { + copilot_toast(Some("Copilot has started."), window, cx) } - }) - .log_err(); - } - }) - .detach(); + _ => { + copilot_toast(None, window, cx); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + open_copilot_code_verification_window(&copilot, window, cx); + } + } + }) + .log_err(); + }) + .detach(); } _ => { copilot .update(cx, |copilot, cx| copilot.sign_in(cx)) .detach(); - workspace.toggle_modal(window, cx, |_, cx| { - CopilotCodeVerification::new(&copilot, cx) - }); + open_copilot_code_verification_window(&copilot, window, cx); } } } -pub fn sign_out_within_workspace( - workspace: &mut Workspace, - copilot: Entity, - cx: &mut Context, -) { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signing out of Copilot...", - ), - cx, - ); - let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx)); - cx.spawn(async move |workspace, cx| match sign_out_task.await { - Ok(()) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - "Signed out of Copilot.", - ), - cx, - ) - }) - .ok(); - } - Err(err) => { - workspace - .update(cx, |workspace, cx| { - workspace.show_error(&err, cx); - }) - .ok(); - } - }) - .detach(); -} - pub struct CopilotCodeVerification { status: Status, connect_clicked: bool, @@ -170,23 +161,27 @@ impl Focusable for CopilotCodeVerification { } impl EventEmitter for CopilotCodeVerification {} -impl ModalView for CopilotCodeVerification { - fn on_before_dismiss( - &mut self, - _: &mut Window, - cx: &mut Context, - ) -> workspace::DismissDecision { - self.copilot.update(cx, |copilot, cx| { - if matches!(copilot.status(), Status::SigningIn { .. }) { - copilot.sign_out(cx).detach_and_log_err(cx); + +impl CopilotCodeVerification { + pub fn new(copilot: &Entity, window: &mut Window, cx: &mut Context) -> Self { + window.on_window_should_close(cx, |window, cx| { + if let Some(this) = window.root::().flatten() { + this.update(cx, |this, cx| { + this.before_dismiss(cx); + }); } + true }); - workspace::DismissDecision::Dismiss(true) - } -} + cx.subscribe_in( + &cx.entity(), + window, + |this, _, _: &DismissEvent, window, cx| { + window.remove_window(); + this.before_dismiss(cx); + }, + ) + .detach(); -impl CopilotCodeVerification { - pub fn new(copilot: &Entity, cx: &mut Context) -> Self { let status = copilot.read(cx).status(); Self { status, @@ -215,45 +210,45 @@ impl CopilotCodeVerification { .read_from_clipboard() .map(|item| item.text().as_ref() == Some(&data.user_code)) .unwrap_or(false); - h_flex() - .w_full() - .p_1() - .border_1() - .border_muted(cx) - .rounded_sm() - .cursor_pointer() - .justify_between() - .on_mouse_down(gpui::MouseButton::Left, { + + ButtonLike::new("copy-button") + .full_width() + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .size(ButtonSize::Medium) + .child( + h_flex() + .w_full() + .p_1() + .justify_between() + .child(Label::new(data.user_code.clone())) + .child(Label::new(if copied { "Copied!" } else { "Copy" })), + ) + .on_click({ let user_code = data.user_code.clone(); move |_, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone())); window.refresh(); } }) - .child(div().flex_1().child(Label::new(data.user_code.clone()))) - .child(div().flex_none().px_1().child(Label::new(if copied { - "Copied!" - } else { - "Copy" - }))) } fn render_prompting_modal( connect_clicked: bool, data: &PromptUserDeviceFlow, - cx: &mut Context, ) -> impl Element { let connect_button_label = if connect_clicked { - "Waiting for connection..." + "Waiting for connection…" } else { "Connect to GitHub" }; + v_flex() .flex_1() - .gap_2() + .gap_2p5() .items_center() - .child(Headline::new("Use GitHub Copilot in Zed.").size(HeadlineSize::Large)) + .text_center() + .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large)) .child( Label::new("Using Copilot requires an active subscription on GitHub.") .color(Color::Muted), @@ -261,83 +256,119 @@ impl CopilotCodeVerification { .child(Self::render_device_code(data, cx)) .child( Label::new("Paste this code into GitHub after clicking the button below.") - .size(ui::LabelSize::Small), - ) - .child( - Button::new("connect-button", connect_button_label) - .on_click({ - let verification_uri = data.verification_uri.clone(); - cx.listener(move |this, _, _window, cx| { - cx.open_url(&verification_uri); - this.connect_clicked = true; - }) - }) - .full_width() - .style(ButtonStyle::Filled), + .color(Color::Muted), ) .child( - Button::new("copilot-enable-cancel-button", "Cancel") - .full_width() - .on_click(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })), + v_flex() + .w_full() + .gap_1() + .child( + Button::new("connect-button", connect_button_label) + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .on_click({ + let verification_uri = data.verification_uri.clone(); + cx.listener(move |this, _, _window, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }), + ) + .child( + Button::new("copilot-enable-cancel-button", "Cancel") + .full_width() + .size(ButtonSize::Medium) + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); + })), + ), ) } fn render_enabled_modal(cx: &mut Context) -> impl Element { v_flex() .gap_2() + .text_center() + .justify_center() .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) - .child(Label::new( - "You can update your settings or sign out from the Copilot menu in the status bar.", - )) + .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted)) .child( Button::new("copilot-enabled-done-button", "Done") .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } fn render_unauthorized_modal(cx: &mut Context) -> impl Element { - v_flex() - .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large)) + let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription."; - .child(Label::new( - "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", - ).color(Color::Warning)) + v_flex() + .gap_2() + .text_center() + .justify_center() + .child( + Headline::new("You must have an active GitHub Copilot subscription.") + .size(HeadlineSize::Large), + ) + .child(Label::new(description).color(Color::Warning)) .child( Button::new("copilot-subscribe-button", "Subscribe on GitHub") .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)), ) .child( Button::new("copilot-subscribe-cancel-button", "Cancel") .full_width() + .size(ButtonSize::Medium) .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) } - fn render_loading(window: &mut Window, _: &mut Context) -> impl Element { - let loading_icon = svg() - .size_8() - .path(IconName::ArrowCircle.path()) - .text_color(window.text_style().color) - .with_animation( - "icon_circle_arrow", - Animation::new(Duration::from_secs(2)).repeat(), - |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))), - ); + fn render_error_modal(_cx: &mut Context) -> impl Element { + v_flex() + .gap_2() + .text_center() + .justify_center() + .child(Headline::new("An Error Happened").size(HeadlineSize::Large)) + .child(Label::new(ERROR_LABEL).color(Color::Muted)) + .child( + Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In") + .full_width() + .style(ButtonStyle::Outlined) + .size(ButtonSize::Medium) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)), + ) + } - h_flex().justify_center().child(loading_icon) + fn before_dismiss( + &mut self, + cx: &mut Context<'_, CopilotCodeVerification>, + ) -> workspace::DismissDecision { + self.copilot.update(cx, |copilot, cx| { + if matches!(copilot.status(), Status::SigningIn { .. }) { + copilot.sign_out(cx).detach_and_log_err(cx); + } + }); + workspace::DismissDecision::Dismiss(true) } } impl Render for CopilotCodeVerification { - 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 prompt = match &self.status { - Status::SigningIn { prompt: None } => { - Self::render_loading(window, cx).into_any_element() - } + Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2) + .into_any_element(), Status::SigningIn { prompt: Some(prompt), } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(), @@ -349,17 +380,20 @@ impl Render for CopilotCodeVerification { self.connect_clicked = false; Self::render_enabled_modal(cx).into_any_element() } + Status::Error(..) => Self::render_error_modal(cx).into_any_element(), _ => div().into_any_element(), }; v_flex() - .id("copilot code verification") + .id("copilot_code_verification") .track_focus(&self.focus_handle(cx)) - .elevation_3(cx) - .w_96() - .items_center() - .p_4() + .size_full() + .px_4() + .py_8() .gap_2() + .items_center() + .justify_center() + .elevation_3(cx) .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| { cx.emit(DismissEvent); })) @@ -373,3 +407,243 @@ impl Render for CopilotCodeVerification { .child(prompt) } } + +pub struct ConfigurationView { + copilot_status: Option, + is_authenticated: fn(cx: &App) -> bool, + edit_prediction: bool, + _subscription: Option, +} + +pub enum ConfigurationMode { + Chat, + EditPrediction, +} + +impl ConfigurationView { + pub fn new( + is_authenticated: fn(cx: &App) -> bool, + mode: ConfigurationMode, + cx: &mut Context, + ) -> Self { + let copilot = Copilot::global(cx); + + Self { + copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), + is_authenticated, + edit_prediction: matches!(mode, ConfigurationMode::EditPrediction), + _subscription: copilot.as_ref().map(|copilot| { + cx.observe(copilot, |this, model, cx| { + this.copilot_status = Some(model.read(cx).status()); + cx.notify(); + }) + }), + } + } +} + +impl ConfigurationView { + fn is_starting(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Starting { .. })) + } + + fn is_signing_in(&self) -> bool { + matches!( + &self.copilot_status, + Some(Status::SigningIn { .. }) + | Some(Status::SignedOut { + awaiting_signing_in: true + }) + ) + } + + fn is_error(&self) -> bool { + matches!(&self.copilot_status, Some(Status::Error(_))) + } + + fn has_no_status(&self) -> bool { + self.copilot_status.is_none() + } + + fn loading_message(&self) -> Option { + if self.is_starting() { + Some("Starting Copilot…".into()) + } else if self.is_signing_in() { + Some("Signing into Copilot…".into()) + } else { + None + } + } + + fn render_loading_button( + &self, + label: impl Into, + edit_prediction: bool, + ) -> impl IntoElement { + ButtonLike::new("loading_button") + .disabled(true) + .style(ButtonStyle::Outlined) + .when(edit_prediction, |this| this.size(ButtonSize::Medium)) + .child( + h_flex() + .w_full() + .gap_1() + .justify_center() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_rotate_animation(4), + ) + .child(Label::new(label)), + ) + } + + fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Sign in to GitHub" + } else { + "Sign in to use GitHub Copilot" + }; + + Button::new("sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Github) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| initiate_sign_in(window, cx)) + } + + fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement { + let label = if edit_prediction { + "Reinstall and Sign in" + } else { + "Reinstall Copilot and Sign in" + }; + + Button::new("reinstall_and_sign_in", label) + .map(|this| { + if edit_prediction { + this.size(ButtonSize::Medium) + } else { + this.full_width() + } + }) + .style(ButtonStyle::Outlined) + .icon(IconName::Download) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)) + } + + fn render_for_edit_prediction(&self) -> impl IntoElement { + let container = |description: SharedString, action: AnyElement| { + h_flex() + .pt_2p5() + .w_full() + .justify_between() + .child( + v_flex() + .w_full() + .max_w_1_2() + .child(Label::new("Authenticate To Use")) + .child( + Label::new(description) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child(action) + }; + + let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into(); + let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into(); + + if let Some(msg) = self.loading_message() { + container( + start_label, + self.render_loading_button(msg, true).into_any_element(), + ) + .into_any_element() + } else if self.is_error() { + container( + ERROR_LABEL.into(), + self.render_reinstall_button(true).into_any_element(), + ) + .into_any_element() + } else if self.has_no_status() { + container( + no_status_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } else { + container( + start_label, + self.render_sign_in_button(true).into_any_element(), + ) + .into_any_element() + } + } + + fn render_for_chat(&self) -> impl IntoElement { + let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; + let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider."; + + if let Some(msg) = self.loading_message() { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_loading_button(msg, false)) + .into_any_element() + } else if self.is_error() { + v_flex() + .gap_2() + .child(Label::new(ERROR_LABEL)) + .child(self.render_reinstall_button(false)) + .into_any_element() + } else if self.has_no_status() { + v_flex() + .gap_2() + .child(Label::new(no_status_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } else { + v_flex() + .gap_2() + .child(Label::new(start_label)) + .child(self.render_sign_in_button(false)) + .into_any_element() + } + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_authenticated = self.is_authenticated; + + if is_authenticated(cx) { + return ConfiguredApiCard::new("Authorized") + .button_label("Sign Out") + .on_click(|_, window, cx| { + initiate_sign_out(window, cx); + }) + .into_any_element(); + } + + if self.edit_prediction { + self.render_for_edit_prediction().into_any_element() + } else { + self.render_for_chat().into_any_element() + } + } +} diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 53ddb99bd3f458a540c6593a2b1d6b1b547e463b..5f1799e2dc4bb5460a900664472ad33e3035d4f1 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -23,7 +23,6 @@ client.workspace = true cloud_llm_client.workspace = true collections.workspace = true copilot.workspace = true -credentials_provider.workspace = true db.workspace = true edit_prediction_types.workspace = true edit_prediction_context.workspace = true diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index d9d9c2243d81640a55133843669514d551f64902..8b96466667bbac8fba92549487821f0d450670ac 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -72,6 +72,7 @@ pub use crate::prediction::EditPrediction; pub use crate::prediction::EditPredictionId; use crate::prediction::EditPredictionResult; pub use crate::sweep_ai::SweepAi; +pub use language_model::ApiKeyState; pub use telemetry_events::EditPredictionRating; pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate; @@ -536,22 +537,12 @@ impl EditPredictionStore { self.edit_prediction_model = model; } - pub fn has_sweep_api_token(&self) -> bool { - self.sweep_ai - .api_token - .clone() - .now_or_never() - .flatten() - .is_some() + 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) -> bool { - self.mercury - .api_token - .clone() - .now_or_never() - .flatten() - .is_some() + pub fn has_mercury_api_token(&self, cx: &App) -> bool { + self.mercury.api_token.read(cx).has_key() } #[cfg(feature = "cli-support")] diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index f3a3afc53fc5e175fdbda2dc6b5867da6fd38feb..ac9f8f535572dddb56ffcfde9a5f2040a65cf168 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -1,40 +1,34 @@ +use crate::{ + DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, + EditPredictionStartedDebugEvent, open_ai_response::text_from_response, + prediction::EditPredictionResult, +}; use anyhow::{Context as _, Result}; -use credentials_provider::CredentialsProvider; -use futures::{AsyncReadExt as _, FutureExt, future::Shared}; +use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Task, + App, AppContext as _, Entity, SharedString, Task, http_client::{self, AsyncBody, Method}, }; use language::{OffsetRangeExt as _, ToOffset, ToPoint as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant}; use zeta_prompt::ZetaPromptInput; -use crate::{ - DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput, - EditPredictionStartedDebugEvent, open_ai_response::text_from_response, - prediction::EditPredictionResult, -}; - const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; const MAX_CONTEXT_TOKENS: usize = 150; const MAX_REWRITE_TOKENS: usize = 350; pub struct Mercury { - pub api_token: Shared>>, + pub api_token: Entity, } impl Mercury { - pub fn new(cx: &App) -> Self { + pub fn new(cx: &mut App) -> Self { Mercury { - api_token: load_api_token(cx).shared(), + api_token: mercury_api_token(cx), } } - pub fn set_api_token(&mut self, api_token: Option, cx: &mut App) -> Task> { - self.api_token = Task::ready(api_token.clone()).shared(); - store_api_token_in_keychain(api_token, cx) - } - pub(crate) fn request_prediction( &self, EditPredictionModelInput { @@ -48,7 +42,10 @@ impl Mercury { }: EditPredictionModelInput, cx: &mut App, ) -> Task>> { - let Some(api_token) = self.api_token.clone().now_or_never().flatten() else { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else { return Task::ready(Ok(None)); }; let full_path: Arc = snapshot @@ -299,45 +296,16 @@ fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce( prompt.push_str(delimiters.end); } -pub const MERCURY_CREDENTIALS_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions"; +pub const MERCURY_CREDENTIALS_URL: SharedString = + SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions"); pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token"; +pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock = env_var!("MERCURY_AI_TOKEN"); +pub static MERCURY_API_KEY: std::sync::OnceLock> = std::sync::OnceLock::new(); -pub fn load_api_token(cx: &App) -> Task> { - if let Some(api_token) = std::env::var("MERCURY_AI_TOKEN") - .ok() - .filter(|value| !value.is_empty()) - { - return Task::ready(Some(api_token)); - } - let credentials_provider = ::global(cx); - cx.spawn(async move |cx| { - let (_, credentials) = credentials_provider - .read_credentials(MERCURY_CREDENTIALS_URL, &cx) - .await - .ok()??; - String::from_utf8(credentials).ok() - }) -} - -fn store_api_token_in_keychain(api_token: Option, cx: &App) -> Task> { - let credentials_provider = ::global(cx); - - cx.spawn(async move |cx| { - if let Some(api_token) = api_token { - credentials_provider - .write_credentials( - MERCURY_CREDENTIALS_URL, - MERCURY_CREDENTIALS_USERNAME, - api_token.as_bytes(), - cx, - ) - .await - .context("Failed to save Mercury API token to system keychain") - } else { - credentials_provider - .delete_credentials(MERCURY_CREDENTIALS_URL, cx) - .await - .context("Failed to delete Mercury API token from system keychain") - } - }) +pub fn mercury_api_token(cx: &mut App) -> Entity { + MERCURY_API_KEY + .get_or_init(|| { + cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone())) + }) + .clone() } diff --git a/crates/edit_prediction/src/sweep_ai.rs b/crates/edit_prediction/src/sweep_ai.rs index f65749ceadf6e05fc3b56838c03234b2f83dc51e..7d020c219b47aa8bcf6fb89e516b7f8ff93da497 100644 --- a/crates/edit_prediction/src/sweep_ai.rs +++ b/crates/edit_prediction/src/sweep_ai.rs @@ -1,11 +1,11 @@ -use anyhow::{Context as _, Result}; -use credentials_provider::CredentialsProvider; -use futures::{AsyncReadExt as _, FutureExt, future::Shared}; +use anyhow::Result; +use futures::AsyncReadExt as _; use gpui::{ - App, AppContext as _, Task, + App, AppContext as _, Entity, SharedString, Task, http_client::{self, AsyncBody, Method}, }; use language::{Point, ToOffset as _}; +use language_model::{ApiKeyState, EnvVar, env_var}; use lsp::DiagnosticSeverity; use serde::{Deserialize, Serialize}; use std::{ @@ -20,30 +20,28 @@ use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredicti const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; pub struct SweepAi { - pub api_token: Shared>>, + pub api_token: Entity, pub debug_info: Arc, } impl SweepAi { - pub fn new(cx: &App) -> Self { + pub fn new(cx: &mut App) -> Self { SweepAi { - api_token: load_api_token(cx).shared(), + api_token: sweep_api_token(cx), debug_info: debug_info(cx), } } - pub fn set_api_token(&mut self, api_token: Option, cx: &mut App) -> Task> { - self.api_token = Task::ready(api_token.clone()).shared(); - store_api_token_in_keychain(api_token, cx) - } - pub fn request_prediction_with_sweep( &self, inputs: EditPredictionModelInput, cx: &mut App, ) -> Task>> { let debug_info = self.debug_info.clone(); - let Some(api_token) = self.api_token.clone().now_or_never().flatten() else { + self.api_token.update(cx, |key_state, cx| { + _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx); + }); + let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else { return Task::ready(Ok(None)); }; let full_path: Arc = inputs @@ -270,47 +268,18 @@ impl SweepAi { } } -pub const SWEEP_CREDENTIALS_URL: &str = "https://autocomplete.sweep.dev"; +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"); +pub static SWEEP_API_KEY: std::sync::OnceLock> = std::sync::OnceLock::new(); -pub fn load_api_token(cx: &App) -> Task> { - if let Some(api_token) = std::env::var("SWEEP_AI_TOKEN") - .ok() - .filter(|value| !value.is_empty()) - { - return Task::ready(Some(api_token)); - } - let credentials_provider = ::global(cx); - cx.spawn(async move |cx| { - let (_, credentials) = credentials_provider - .read_credentials(SWEEP_CREDENTIALS_URL, &cx) - .await - .ok()??; - String::from_utf8(credentials).ok() - }) -} - -fn store_api_token_in_keychain(api_token: Option, cx: &App) -> Task> { - let credentials_provider = ::global(cx); - - cx.spawn(async move |cx| { - if let Some(api_token) = api_token { - credentials_provider - .write_credentials( - SWEEP_CREDENTIALS_URL, - SWEEP_CREDENTIALS_USERNAME, - api_token.as_bytes(), - cx, - ) - .await - .context("Failed to save Sweep API token to system keychain") - } else { - credentials_provider - .delete_credentials(SWEEP_CREDENTIALS_URL, cx) - .await - .context("Failed to delete Sweep API token from system keychain") - } - }) +pub fn sweep_api_token(cx: &mut App) -> Entity { + SWEEP_API_KEY + .get_or_init(|| { + cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone())) + }) + .clone() } #[derive(Debug, Clone, Serialize)] diff --git a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs index 6dcf7092240de64381ded611b47c2dd5940d6770..0a87ca661435de4d22e6f258c30ff406f0deecc2 100644 --- a/crates/edit_prediction/src/zed_edit_prediction_delegate.rs +++ b/crates/edit_prediction/src/zed_edit_prediction_delegate.rs @@ -100,7 +100,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate { ) -> bool { let store = self.store.read(cx); if store.edit_prediction_model == EditPredictionModel::Sweep { - store.has_sweep_api_token() + store.has_sweep_api_token(cx) } else { true } diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index d6fc45512132197a3b9e7bd200c3005efa52ae10..63d674250001483bb8963ce62b44af524686399e 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -20,8 +20,8 @@ cloud_llm_client.workspace = true codestral.workspace = true command_palette_hooks.workspace = true copilot.workspace = true -edit_prediction.workspace = true edit_prediction_types.workspace = true +edit_prediction.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true @@ -41,7 +41,6 @@ telemetry.workspace = true text.workspace = true theme.workspace = true ui.workspace = true -ui_input.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 04c7614689c5fdc076ab0aa9c4b4fe7d68e2f582..b008f09ec8886086578b571b3655dac566fb6c5d 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -3,7 +3,9 @@ use client::{Client, UserStore, zed_urls}; use cloud_llm_client::UsageLimit; use codestral::CodestralEditPredictionDelegate; use copilot::{Copilot, Status}; -use edit_prediction::{MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag}; +use edit_prediction::{ + EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag, +}; use edit_prediction_types::EditPredictionDelegateHandle; use editor::{ Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll, @@ -42,12 +44,9 @@ use workspace::{ StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle, notifications::NotificationId, }; -use zed_actions::OpenBrowser; +use zed_actions::{OpenBrowser, OpenSettingsAt}; -use crate::{ - ExternalProviderApiKeyModal, RatePredictions, - rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag, -}; +use crate::{RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag}; actions!( edit_prediction, @@ -248,45 +247,21 @@ impl Render for EditPredictionButton { EditPredictionProvider::Codestral => { let enabled = self.editor_enabled.unwrap_or(true); let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx); - let fs = self.fs.clone(); let this = cx.weak_entity(); + let tooltip_meta = if has_api_key { + "Powered by Codestral" + } else { + "Missing API key for Codestral" + }; + div().child( PopoverMenu::new("codestral") .menu(move |window, cx| { - if has_api_key { - this.update(cx, |this, cx| { - this.build_codestral_context_menu(window, cx) - }) - .ok() - } else { - Some(ContextMenu::build(window, cx, |menu, _, _| { - let fs = fs.clone(); - - menu.entry( - "Configure Codestral API Key", - None, - move |window, cx| { - window.dispatch_action( - zed_actions::agent::OpenSettings.boxed_clone(), - cx, - ); - }, - ) - .separator() - .entry( - "Use Zed AI instead", - None, - move |_, cx| { - set_completion_provider( - fs.clone(), - cx, - EditPredictionProvider::Zed, - ) - }, - ) - })) - } + this.update(cx, |this, cx| { + this.build_codestral_context_menu(window, cx) + }) + .ok() }) .anchor(Corner::BottomRight) .trigger_with_tooltip( @@ -304,7 +279,14 @@ impl Render for EditPredictionButton { cx.theme().colors().status_bar_background, )) }), - move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx), + move |_window, cx| { + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + tooltip_meta, + cx, + ) + }, ) .with_handle(self.popover_menu_handle.clone()), ) @@ -313,6 +295,7 @@ impl Render for EditPredictionButton { let enabled = self.editor_enabled.unwrap_or(true); let ep_icon; + let tooltip_meta; let mut missing_token = false; match provider { @@ -320,15 +303,25 @@ impl Render for EditPredictionButton { EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, ) => { ep_icon = IconName::SweepAi; + tooltip_meta = if missing_token { + "Missing API key for Sweep" + } else { + "Powered by Sweep" + }; missing_token = edit_prediction::EditPredictionStore::try_global(cx) - .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token()); + .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx)); } EditPredictionProvider::Experimental( EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, ) => { ep_icon = IconName::Inception; missing_token = edit_prediction::EditPredictionStore::try_global(cx) - .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token()); + .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx)); + tooltip_meta = if missing_token { + "Missing API key for Mercury" + } else { + "Powered by Mercury" + }; } _ => { ep_icon = if enabled { @@ -336,6 +329,7 @@ impl Render for EditPredictionButton { } else { IconName::ZedPredictDisabled }; + tooltip_meta = "Powered by Zeta" } }; @@ -400,33 +394,26 @@ impl Render for EditPredictionButton { }) .when(!self.popover_menu_handle.is_deployed(), |element| { let user = user.clone(); + element.tooltip(move |_window, cx| { - if enabled { + let description = if enabled { if show_editor_predictions { - Tooltip::for_action("Edit Prediction", &ToggleMenu, cx) + tooltip_meta } else if user.is_none() { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Sign In To Use", - cx, - ) + "Sign In To Use" } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Hidden For This File", - cx, - ) + "Hidden For This File" } } else { - Tooltip::with_meta( - "Edit Prediction", - Some(&ToggleMenu), - "Disabled For This File", - cx, - ) - } + "Disabled For This File" + }; + + Tooltip::with_meta( + "Edit Prediction", + Some(&ToggleMenu), + description, + cx, + ) }) }); @@ -519,6 +506,12 @@ impl EditPredictionButton { providers.push(EditPredictionProvider::Zed); + if cx.has_flag::() { + providers.push(EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + )); + } + if let Some(copilot) = Copilot::global(cx) { if matches!(copilot.read(cx).status(), Status::Authorized) { providers.push(EditPredictionProvider::Copilot); @@ -537,24 +530,28 @@ impl EditPredictionButton { providers.push(EditPredictionProvider::Codestral); } - if cx.has_flag::() { + let ep_store = EditPredictionStore::try_global(cx); + + if cx.has_flag::() + && ep_store + .as_ref() + .is_some_and(|ep_store| ep_store.read(cx).has_sweep_api_token(cx)) + { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, )); } - if cx.has_flag::() { + if cx.has_flag::() + && ep_store + .as_ref() + .is_some_and(|ep_store| ep_store.read(cx).has_mercury_api_token(cx)) + { providers.push(EditPredictionProvider::Experimental( EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, )); } - if cx.has_flag::() { - providers.push(EditPredictionProvider::Experimental( - EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - )); - } - providers } @@ -562,13 +559,10 @@ impl EditPredictionButton { &self, mut menu: ContextMenu, current_provider: EditPredictionProvider, - cx: &App, + cx: &mut App, ) -> ContextMenu { let available_providers = self.get_available_providers(cx); - const ZED_AI_CALLOUT: &str = - "Zed's edit prediction is powered by Zeta, an open-source, dataset mode."; - let providers: Vec<_> = available_providers .into_iter() .filter(|p| *p != EditPredictionProvider::None) @@ -581,153 +575,32 @@ impl EditPredictionButton { let is_current = provider == current_provider; let fs = self.fs.clone(); - menu = match provider { - EditPredictionProvider::Zed => menu.item( - ContextMenuEntry::new("Zed AI") - .toggleable(IconPosition::Start, is_current) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| Label::new(ZED_AI_CALLOUT).into_any_element(), - ) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Copilot => menu.item( - ContextMenuEntry::new("GitHub Copilot") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Supermaven => menu.item( - ContextMenuEntry::new("Supermaven") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), - EditPredictionProvider::Codestral => menu.item( - ContextMenuEntry::new("Codestral") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), + let name = match provider { + EditPredictionProvider::Zed => "Zed AI", + EditPredictionProvider::Copilot => "GitHub Copilot", + EditPredictionProvider::Supermaven => "Supermaven", + EditPredictionProvider::Codestral => "Codestral", EditPredictionProvider::Experimental( EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, - ) => { - let has_api_token = edit_prediction::EditPredictionStore::try_global(cx) - .map_or(false, |ep_store| ep_store.read(cx).has_sweep_api_token()); - - let should_open_modal = !has_api_token || is_current; - - let entry = if has_api_token { - ContextMenuEntry::new("Sweep") - .toggleable(IconPosition::Start, is_current) - } else { - ContextMenuEntry::new("Sweep") - .icon(IconName::XCircle) - .icon_color(Color::Error) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| { - Label::new("Click to configure your Sweep API token") - .into_any_element() - }, - ) - }; - - let entry = entry.handler(move |window, cx| { - if should_open_modal { - if let Some(workspace) = window.root::().flatten() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - ExternalProviderApiKeyModal::new( - window, - cx, - |api_key, store, cx| { - store - .sweep_ai - .set_api_token(api_key, cx) - .detach_and_log_err(cx); - }, - ) - }); - }); - }; - } else { - set_completion_provider(fs.clone(), cx, provider); - } - }); - - menu.item(entry) - } + ) => "Sweep", EditPredictionProvider::Experimental( EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME, - ) => { - let has_api_token = edit_prediction::EditPredictionStore::try_global(cx) - .map_or(false, |ep_store| ep_store.read(cx).has_mercury_api_token()); - - let should_open_modal = !has_api_token || is_current; - - let entry = if has_api_token { - ContextMenuEntry::new("Mercury") - .toggleable(IconPosition::Start, is_current) - } else { - ContextMenuEntry::new("Mercury") - .icon(IconName::XCircle) - .icon_color(Color::Error) - .documentation_aside( - DocumentationSide::Left, - DocumentationEdge::Bottom, - |_| { - Label::new("Click to configure your Mercury API token") - .into_any_element() - }, - ) - }; - - let entry = entry.handler(move |window, cx| { - if should_open_modal { - if let Some(workspace) = window.root::().flatten() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(window, cx, |window, cx| { - ExternalProviderApiKeyModal::new( - window, - cx, - |api_key, store, cx| { - store - .mercury - .set_api_token(api_key, cx) - .detach_and_log_err(cx); - }, - ) - }); - }); - }; - } else { - set_completion_provider(fs.clone(), cx, provider); - } - }); - - menu.item(entry) - } + ) => "Mercury", EditPredictionProvider::Experimental( EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, - ) => menu.item( - ContextMenuEntry::new("Zeta2") - .toggleable(IconPosition::Start, is_current) - .handler(move |_, cx| { - set_completion_provider(fs.clone(), cx, provider); - }), - ), + ) => "Zeta2", EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => { continue; } }; + + menu = menu.item( + ContextMenuEntry::new(name) + .toggleable(IconPosition::Start, is_current) + .handler(move |_, cx| { + set_completion_provider(fs.clone(), cx, provider); + }), + ) } } @@ -832,14 +705,7 @@ impl EditPredictionButton { let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle); let eager_mode = matches!(current_mode, EditPredictionsMode::Eager); - if matches!( - provider, - EditPredictionProvider::Zed - | EditPredictionProvider::Copilot - | EditPredictionProvider::Supermaven - | EditPredictionProvider::Codestral - ) { - menu = menu + menu = menu .separator() .header("Display Modes") .item( @@ -868,104 +734,111 @@ impl EditPredictionButton { } }), ); - } menu = menu.separator().header("Privacy"); - if let Some(provider) = &self.edit_prediction_provider { - let data_collection = provider.data_collection_state(cx); - - if data_collection.is_supported() { - let provider = provider.clone(); - let enabled = data_collection.is_enabled(); - let is_open_source = data_collection.is_project_open_source(); - let is_collecting = data_collection.is_enabled(); - let (icon_name, icon_color) = if is_open_source && is_collecting { - (IconName::Check, Color::Success) - } else { - (IconName::Check, Color::Accent) - }; - - menu = menu.item( - ContextMenuEntry::new("Training Data Collection") - .toggleable(IconPosition::Start, data_collection.is_enabled()) - .icon(icon_name) - .icon_color(icon_color) - .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { - let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { - (true, true) => ( - "Project identified as open source, and you're sharing data.", - Color::Default, - IconName::Check, - Color::Success, - ), - (true, false) => ( - "Project identified as open source, but you're not sharing data.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, true) => ( - "Project not identified as open source. No data captured.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - (false, false) => ( - "Project not identified as open source, and setting turned off.", - Color::Muted, - IconName::Close, - Color::Muted, - ), - }; - v_flex() - .gap_2() - .child( - Label::new(indoc!{ - "Help us improve our open dataset model by sharing data from open source repositories. \ - Zed must detect a license file in your repo for this setting to take effect. \ - Files with sensitive data and secrets are excluded by default." - }) - ) - .child( - h_flex() - .items_start() - .pt_2() - .pr_1() - .flex_1() - .gap_1p5() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) - .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) - ) - .into_any_element() - }) - .handler(move |_, cx| { - provider.toggle_data_collection(cx); - - if !enabled { - telemetry::event!( - "Data Collection Enabled", - source = "Edit Prediction Status Menu" - ); - } else { - telemetry::event!( - "Data Collection Disabled", - source = "Edit Prediction Status Menu" - ); - } - }) - ); + if matches!( + provider, + EditPredictionProvider::Zed + | EditPredictionProvider::Experimental( + EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, + ) + ) { + if let Some(provider) = &self.edit_prediction_provider { + let data_collection = provider.data_collection_state(cx); + + if data_collection.is_supported() { + let provider = provider.clone(); + let enabled = data_collection.is_enabled(); + let is_open_source = data_collection.is_project_open_source(); + let is_collecting = data_collection.is_enabled(); + let (icon_name, icon_color) = if is_open_source && is_collecting { + (IconName::Check, Color::Success) + } else { + (IconName::Check, Color::Accent) + }; - if is_collecting && !is_open_source { menu = menu.item( - ContextMenuEntry::new("No data captured.") - .disabled(true) - .icon(IconName::Close) - .icon_color(Color::Error) - .icon_size(IconSize::Small), + ContextMenuEntry::new("Training Data Collection") + .toggleable(IconPosition::Start, data_collection.is_enabled()) + .icon(icon_name) + .icon_color(icon_color) + .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| { + let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) { + (true, true) => ( + "Project identified as open source, and you're sharing data.", + Color::Default, + IconName::Check, + Color::Success, + ), + (true, false) => ( + "Project identified as open source, but you're not sharing data.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, true) => ( + "Project not identified as open source. No data captured.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + (false, false) => ( + "Project not identified as open source, and setting turned off.", + Color::Muted, + IconName::Close, + Color::Muted, + ), + }; + v_flex() + .gap_2() + .child( + Label::new(indoc!{ + "Help us improve our open dataset model by sharing data from open source repositories. \ + Zed must detect a license file in your repo for this setting to take effect. \ + Files with sensitive data and secrets are excluded by default." + }) + ) + .child( + h_flex() + .items_start() + .pt_2() + .pr_1() + .flex_1() + .gap_1p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color))) + .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx))) + ) + .into_any_element() + }) + .handler(move |_, cx| { + provider.toggle_data_collection(cx); + + if !enabled { + telemetry::event!( + "Data Collection Enabled", + source = "Edit Prediction Status Menu" + ); + } else { + telemetry::event!( + "Data Collection Disabled", + source = "Edit Prediction Status Menu" + ); + } + }) ); + + if is_collecting && !is_open_source { + menu = menu.item( + ContextMenuEntry::new("No data captured.") + .disabled(true) + .icon(IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::Small), + ); + } } } } @@ -1087,10 +960,7 @@ impl EditPredictionButton { let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx); - menu.separator() - .entry("Configure Codestral API Key", None, move |window, cx| { - window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx); - }) + menu }) } @@ -1210,6 +1080,22 @@ impl EditPredictionButton { } menu = self.add_provider_switching_section(menu, provider, cx); + menu = menu.separator().item( + ContextMenuEntry::new("Configure Providers") + .icon(IconName::Settings) + .icon_position(IconPosition::Start) + .icon_color(Color::Muted) + .handler(move |window, cx| { + window.dispatch_action( + OpenSettingsAt { + path: "edit_predictions.providers".to_string(), + } + .boxed_clone(), + cx, + ); + }), + ); + menu }) } diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs index c177b5233c33feb4f5ff82f60bf3fb6981cf3ee8..74c81fbfe16eec7846e70aefd59bbfeb282072dc 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -1,6 +1,5 @@ mod edit_prediction_button; mod edit_prediction_context_view; -mod external_provider_api_token_modal; mod rate_prediction_modal; use std::any::{Any as _, TypeId}; @@ -17,7 +16,6 @@ use ui::{App, prelude::*}; use workspace::{SplitDirection, Workspace}; pub use edit_prediction_button::{EditPredictionButton, ToggleMenu}; -pub use external_provider_api_token_modal::ExternalProviderApiKeyModal; use crate::rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag; diff --git a/crates/edit_prediction_ui/src/external_provider_api_token_modal.rs b/crates/edit_prediction_ui/src/external_provider_api_token_modal.rs deleted file mode 100644 index bc312836e9fdd30237156ac532a055d1e23a2589..0000000000000000000000000000000000000000 --- a/crates/edit_prediction_ui/src/external_provider_api_token_modal.rs +++ /dev/null @@ -1,86 +0,0 @@ -use edit_prediction::EditPredictionStore; -use gpui::{ - DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, -}; -use ui::{Button, ButtonStyle, Clickable, Headline, HeadlineSize, prelude::*}; -use ui_input::InputField; -use workspace::ModalView; - -pub struct ExternalProviderApiKeyModal { - api_key_input: Entity, - focus_handle: FocusHandle, - on_confirm: Box, &mut EditPredictionStore, &mut App)>, -} - -impl ExternalProviderApiKeyModal { - pub fn new( - window: &mut Window, - cx: &mut Context, - on_confirm: impl Fn(Option, &mut EditPredictionStore, &mut App) + 'static, - ) -> Self { - let api_key_input = cx.new(|cx| InputField::new(window, cx, "Enter your API key")); - - Self { - api_key_input, - focus_handle: cx.focus_handle(), - on_confirm: Box::new(on_confirm), - } - } - - fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { - cx.emit(DismissEvent); - } - - fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { - let api_key = self.api_key_input.read(cx).text(cx); - let api_key = (!api_key.trim().is_empty()).then_some(api_key); - - if let Some(ep_store) = EditPredictionStore::try_global(cx) { - ep_store.update(cx, |ep_store, cx| (self.on_confirm)(api_key, ep_store, cx)) - } - - cx.emit(DismissEvent); - } -} - -impl EventEmitter for ExternalProviderApiKeyModal {} - -impl ModalView for ExternalProviderApiKeyModal {} - -impl Focusable for ExternalProviderApiKeyModal { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for ExternalProviderApiKeyModal { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .key_context("ExternalApiKeyModal") - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::confirm)) - .elevation_2(cx) - .w(px(400.)) - .p_4() - .gap_3() - .child(Headline::new("API Token").size(HeadlineSize::Small)) - .child(self.api_key_input.clone()) - .child( - h_flex() - .justify_end() - .gap_2() - .child(Button::new("cancel", "Cancel").on_click(cx.listener( - |_, _, _window, cx| { - cx.emit(DismissEvent); - }, - ))) - .child( - Button::new("save", "Save") - .style(ButtonStyle::Filled) - .on_click(cx.listener(|this, _, window, cx| { - this.confirm(&menu::Confirm, window, cx); - })), - ), - ) - } -} diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index 7c6470f4fa0c1eac847c1194e967b451093a76ad..0a6d440a6bbc4cb1f45663d78eecb57bec43f1f5 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -18,6 +18,7 @@ test-support = [] [dependencies] anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true +credentials_provider.workspace = true base64.workspace = true client.workspace = true cloud_api_types.workspace = true @@ -41,6 +42,7 @@ smol.workspace = true telemetry_events.workspace = true thiserror.workspace = true util.workspace = true +zed_env_vars.workspace = true [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/api_key.rs b/crates/language_model/src/api_key.rs similarity index 95% rename from crates/language_models/src/api_key.rs rename to crates/language_model/src/api_key.rs index 122234b6ced6d0bf1b7a0d684683c841824ccd2d..754fde069295d8799820020bef286b1a1a3c590c 100644 --- a/crates/language_models/src/api_key.rs +++ b/crates/language_model/src/api_key.rs @@ -2,7 +2,6 @@ use anyhow::{Result, anyhow}; use credentials_provider::CredentialsProvider; use futures::{FutureExt, future}; use gpui::{AsyncApp, Context, SharedString, Task}; -use language_model::AuthenticateError; use std::{ fmt::{Display, Formatter}, sync::Arc, @@ -10,13 +9,16 @@ use std::{ use util::ResultExt as _; use zed_env_vars::EnvVar; +use crate::AuthenticateError; + /// Manages a single API key for a language model provider. API keys either come from environment /// variables or the system keychain. /// /// Keys from the system keychain are associated with a provider URL, and this ensures that they are /// only used with that URL. pub struct ApiKeyState { - url: SharedString, + pub url: SharedString, + env_var: EnvVar, load_status: LoadStatus, load_task: Option>>, } @@ -35,9 +37,10 @@ pub struct ApiKey { } impl ApiKeyState { - pub fn new(url: SharedString) -> Self { + pub fn new(url: SharedString, env_var: EnvVar) -> Self { Self { url, + env_var, load_status: LoadStatus::NotPresent, load_task: None, } @@ -47,6 +50,10 @@ impl ApiKeyState { matches!(self.load_status, LoadStatus::Loaded { .. }) } + pub fn env_var_name(&self) -> &SharedString { + &self.env_var.name + } + pub fn is_from_env_var(&self) -> bool { match &self.load_status { LoadStatus::Loaded(ApiKey { @@ -136,14 +143,13 @@ impl ApiKeyState { pub fn handle_url_change( &mut self, url: SharedString, - env_var: &EnvVar, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, cx: &mut Context, ) { if url != self.url { if !self.is_from_env_var() { // loading will continue even though this result task is dropped - let _task = self.load_if_needed(url, env_var, get_this, cx); + let _task = self.load_if_needed(url, get_this, cx); } } } @@ -156,7 +162,6 @@ impl ApiKeyState { pub fn load_if_needed( &mut self, url: SharedString, - env_var: &EnvVar, get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static, cx: &mut Context, ) -> Task> { @@ -166,10 +171,10 @@ impl ApiKeyState { return Task::ready(Ok(())); } - if let Some(key) = &env_var.value + if let Some(key) = &self.env_var.value && !key.is_empty() { - let api_key = ApiKey::from_env(env_var.name.clone(), key); + let api_key = ApiKey::from_env(self.env_var.name.clone(), key); self.url = url; self.load_status = LoadStatus::Loaded(api_key); self.load_task = None; diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index cb03b84cbf34d3003e53befa518ecd91626a13e9..e158bb256be42291549c2379ae7ec19402166543 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -1,3 +1,4 @@ +mod api_key; mod model; mod rate_limiter; mod registry; @@ -30,6 +31,7 @@ use std::{fmt, io}; use thiserror::Error; use util::serde::is_default; +pub use crate::api_key::{ApiKey, ApiKeyState}; pub use crate::model::*; pub use crate::rate_limiter::*; pub use crate::registry::*; @@ -37,6 +39,7 @@ pub use crate::request::*; pub use crate::role::*; pub use crate::telemetry::*; pub use crate::tool_schema::LanguageModelToolSchemaFormat; +pub use zed_env_vars::{EnvVar, env_var}; pub const ANTHROPIC_PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("anthropic"); diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 6c5704312d94e2c98ff62c49d3d5b57c1b274057..5531e698ab7fccae736e800f38b16e35bcd35ac4 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -60,7 +60,6 @@ ui_input.workspace = true util.workspace = true vercel = { workspace = true, features = ["schemars"] } x_ai = { workspace = true, features = ["schemars"] } -zed_env_vars.workspace = true [dev-dependencies] editor = { workspace = true, features = ["test-support"] } diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index d771dba3733540cdb720416c21d5d0cb76b9d3be..1038f5e233e0a5970b0e8bd969a65f6f0e2a7550 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -7,10 +7,8 @@ use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; -mod api_key; pub mod provider; mod settings; -pub mod ui; use crate::provider::anthropic::AnthropicLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 1affe38a08d22e2aaed8c1207513ce41a13b8e59..f9e1e60cf648d3a67cec425ebd1f09ad7b564665 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -8,25 +8,21 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B use gpui::{AnyView, App, AsyncApp, Context, Entity, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, ConfigurationViewTargetAgent, LanguageModel, - LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelId, - LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, - LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, - LanguageModelToolResultContent, MessageContent, RateLimiter, Role, + ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, + RateLimiter, Role, StopReason, env_var, }; -use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason}; use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::api_key::ApiKeyState; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; pub use settings::AnthropicAvailableModel as AvailableModel; @@ -65,12 +61,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -79,17 +71,13 @@ impl AnthropicLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -937,14 +925,12 @@ impl Render for ConfigurationView { .child( List::new() .child( - InstructionListItem::new( - "Create one by visiting", - Some("Anthropic's settings"), - Some("https://console.anthropic.com/settings/keys") - ) + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys")) ) .child( - InstructionListItem::text_only("Paste your API key below and hit enter to start using the agent") + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") ) ) .child(self.api_key_editor.clone()) @@ -953,7 +939,8 @@ impl Render for ConfigurationView { format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), ) .size(LabelSize::Small) - .color(Color::Muted), + .color(Color::Muted) + .mt_0p5(), ) .into_any_element() } else { diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index e478c193a27a9e30301ae9233ea666c8160b25f5..b85a038bb235d97bd9de8614f19764ecabf7bbfe 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -2,7 +2,6 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; use anyhow::{Context as _, Result, anyhow}; use aws_config::stalled_stream_protection::StalledStreamProtectionConfig; use aws_config::{BehaviorVersion, Region}; @@ -44,7 +43,7 @@ use serde_json::Value; use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore}; use smol::lock::OnceCell; use strum::{EnumIter, IntoEnumIterator, IntoStaticStr}; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; @@ -1250,18 +1249,14 @@ impl Render for ConfigurationView { .child( List::new() .child( - InstructionListItem::new( - "Grant permissions to the strategy you'll use according to the:", - Some("Prerequisites"), - Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"), - ) + ListBulletItem::new("") + .child(Label::new("Grant permissions to the strategy you'll use according to the:")) + .child(ButtonLink::new("Prerequisites", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html")) ) .child( - InstructionListItem::new( - "Select the models you would like access to:", - Some("Bedrock Model Catalog"), - Some("https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"), - ) + ListBulletItem::new("") + .child(Label::new("Select the models you would like access to:")) + .child(ButtonLink::new("Bedrock Model Catalog", "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess")) ) ) .child(self.render_static_credentials_ui()) @@ -1302,22 +1297,22 @@ impl ConfigurationView { ) .child( List::new() - .child(InstructionListItem::new( - "Create an IAM user in the AWS console with programmatic access", - Some("IAM Console"), - Some("https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"), - )) - .child(InstructionListItem::new( - "Attach the necessary Bedrock permissions to this ", - Some("user"), - Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"), - )) - .child(InstructionListItem::text_only( - "Copy the access key ID and secret access key when provided", - )) - .child(InstructionListItem::text_only( - "Enter these credentials below", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create an IAM user in the AWS console with programmatic access")) + .child(ButtonLink::new("IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users")) + ) + .child( + ListBulletItem::new("") + .child(Label::new("Attach the necessary Bedrock permissions to this")) + .child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html")) + ) + .child( + ListBulletItem::new("Copy the access key ID and secret access key when provided") + ) + .child( + ListBulletItem::new("Enter these credentials below") + ) ) .child(self.access_key_id_editor.clone()) .child(self.secret_access_key_editor.clone()) diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 92ac342a39ff04ae42f5b01b5777a5d16563c37f..70198b337e467e1618192e781d3e3be305fea9c5 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -14,7 +14,7 @@ use copilot::{Copilot, Status}; use futures::future::BoxFuture; use futures::stream::BoxStream; use futures::{FutureExt, Stream, StreamExt}; -use gpui::{Action, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, svg}; +use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task}; use http_client::StatusCode; use language::language_settings::all_language_settings; use language_model::{ @@ -26,11 +26,9 @@ use language_model::{ StopReason, TokenUsage, }; use settings::SettingsStore; -use ui::{CommonAnimationExt, prelude::*}; +use ui::prelude::*; use util::debug_panic; -use crate::ui::ConfiguredApiCard; - const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("GitHub Copilot Chat"); @@ -179,8 +177,18 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider { _: &mut Window, cx: &mut App, ) -> AnyView { - let state = self.state.clone(); - cx.new(|cx| ConfigurationView::new(state, cx)).into() + cx.new(|cx| { + copilot::ConfigurationView::new( + |cx| { + CopilotChat::global(cx) + .map(|m| m.read(cx).is_authenticated()) + .unwrap_or(false) + }, + copilot::ConfigurationMode::Chat, + cx, + ) + }) + .into() } fn reset_credentials(&self, _cx: &mut App) -> Task> { @@ -1474,92 +1482,3 @@ mod tests { ); } } -struct ConfigurationView { - copilot_status: Option, - state: Entity, - _subscription: Option, -} - -impl ConfigurationView { - pub fn new(state: Entity, cx: &mut Context) -> Self { - let copilot = Copilot::global(cx); - - Self { - copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()), - state, - _subscription: copilot.as_ref().map(|copilot| { - cx.observe(copilot, |this, model, cx| { - this.copilot_status = Some(model.read(cx).status()); - cx.notify(); - }) - }), - } - } -} - -impl Render for ConfigurationView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.state.read(cx).is_authenticated(cx) { - ConfiguredApiCard::new("Authorized") - .button_label("Sign Out") - .on_click(|_, window, cx| { - window.dispatch_action(copilot::SignOut.boxed_clone(), cx); - }) - .into_any_element() - } else { - let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4); - - const ERROR_LABEL: &str = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different Assistant provider."; - - match &self.copilot_status { - Some(status) => match status { - Status::Starting { task: _ } => h_flex() - .gap_2() - .child(loading_icon) - .child(Label::new("Starting Copilot…")) - .into_any_element(), - Status::SigningIn { prompt: _ } - | Status::SignedOut { - awaiting_signing_in: true, - } => h_flex() - .gap_2() - .child(loading_icon) - .child(Label::new("Signing into Copilot…")) - .into_any_element(), - Status::Error(_) => { - const LABEL: &str = "Copilot had issues starting. Please try restarting it. If the issue persists, try reinstalling Copilot."; - v_flex() - .gap_6() - .child(Label::new(LABEL)) - .child(svg().size_8().path(IconName::CopilotError.path())) - .into_any_element() - } - _ => { - const LABEL: &str = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription."; - - v_flex() - .gap_2() - .child(Label::new(LABEL)) - .child( - Button::new("sign_in", "Sign in to use GitHub Copilot") - .full_width() - .style(ButtonStyle::Outlined) - .icon_color(Color::Muted) - .icon(IconName::Github) - .icon_position(IconPosition::Start) - .icon_size(IconSize::Small) - .on_click(|_, window, cx| { - copilot::initiate_sign_in(window, cx) - }), - ) - .into_any_element() - } - }, - None => v_flex() - .gap_6() - .child(Label::new(ERROR_LABEL)) - .into_any_element(), - } - } - } -} diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index 91b83bb9f1d0f08fe70f5e750ff8ce993a7afd7f..b00a5d82f5665a5c87c662d1af84fbeb9ac07ebb 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -7,11 +7,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; pub use settings::DeepseekAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; @@ -19,13 +19,9 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::{Arc, LazyLock}; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek"); @@ -67,12 +63,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = DeepSeekLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -81,17 +73,13 @@ impl DeepSeekLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -632,12 +620,15 @@ impl Render for ConfigurationView { .child(Label::new("To use DeepSeek in Zed, you need an API key:")) .child( List::new() - .child(InstructionListItem::new( - "Get your API key from the", - Some("DeepSeek console"), - Some("https://platform.deepseek.com/api_keys"), - )) - .child(InstructionListItem::text_only( + .child( + ListBulletItem::new("") + .child(Label::new("Get your API key from the")) + .child(ButtonLink::new( + "DeepSeek console", + "https://platform.deepseek.com/api_keys", + )), + ) + .child(ListBulletItem::new( "Paste your API key below and hit enter to start using the assistant", )), ) diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index c5a5affcd3d9e8c34f6306f86cb5348f86397892..989b99061b6d0f4c6680f08616c55946138ae0fe 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -9,7 +9,7 @@ use google_ai::{ use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, ConfigurationViewTargetAgent, LanguageModelCompletionError, + AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason, }; @@ -28,14 +28,11 @@ use std::sync::{ atomic::{self, AtomicU64}, }; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::EnvVar; -use crate::api_key::ApiKey; -use crate::api_key::ApiKeyState; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; +use language_model::{ApiKey, ApiKeyState}; const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::GOOGLE_PROVIDER_NAME; @@ -87,12 +84,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = GoogleLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -101,17 +94,13 @@ impl GoogleLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -873,14 +862,14 @@ impl Render for ConfigurationView { }))) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("Google AI's console"), - Some("https://aistudio.google.com/app/apikey"), - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey")) + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ) ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index a16bd351a9d779bcba5b2a4111fc62e0dc9dc639..8e42d12db4c24ef6a66ddef470a34c620ed7ee00 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -20,11 +20,10 @@ use settings::{Settings, SettingsStore}; use std::pin::Pin; use std::str::FromStr; use std::{collections::BTreeMap, sync::Arc}; -use ui::{ButtonLike, Indicator, List, prelude::*}; +use ui::{ButtonLike, Indicator, InlineCode, List, ListBulletItem, prelude::*}; use util::ResultExt; use crate::AllLanguageModelSettings; -use crate::ui::InstructionListItem; const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download"; const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models"; @@ -686,12 +685,14 @@ impl Render for ConfigurationView { .child( v_flex().gap_1().child(Label::new(lmstudio_intro)).child( List::new() - .child(InstructionListItem::text_only( + .child(ListBulletItem::new( "LM Studio needs to be running with at least one model downloaded.", )) - .child(InstructionListItem::text_only( - "To get your first model, try running `lms get qwen2.5-coder-7b`", - )), + .child( + ListBulletItem::new("") + .child(Label::new("To get your first model, try running")) + .child(InlineCode::new("lms get qwen2.5-coder-7b")), + ), ), ) .child( diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 8372a8c95e579f1d860fd9bb25656731ee2c7e50..1078e2d7f7841d7ad05284e10a9f862236966ebc 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -1,31 +1,27 @@ use anyhow::{Result, anyhow}; use collections::BTreeMap; -use fs::Fs; + use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; -use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse}; +pub use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse}; pub use settings::MistralAvailableModel as AvailableModel; -use settings::{EditPredictionProvider, Settings, SettingsStore, update_settings_file}; +use settings::{Settings, SettingsStore}; use std::collections::HashMap; use std::pin::Pin; use std::str::FromStr; -use std::sync::{Arc, LazyLock}; +use std::sync::{Arc, LazyLock, OnceLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral"); @@ -35,6 +31,7 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); const CODESTRAL_API_KEY_ENV_VAR_NAME: &str = "CODESTRAL_API_KEY"; static CODESTRAL_API_KEY_ENV_VAR: LazyLock = env_var!(CODESTRAL_API_KEY_ENV_VAR_NAME); +static CODESTRAL_API_KEY: OnceLock> = OnceLock::new(); #[derive(Default, Clone, Debug, PartialEq)] pub struct MistralSettings { @@ -44,12 +41,22 @@ pub struct MistralSettings { pub struct MistralLanguageModelProvider { http_client: Arc, - state: Entity, + pub state: Entity, } pub struct State { api_key_state: ApiKeyState, - codestral_api_key_state: ApiKeyState, + codestral_api_key_state: Entity, +} + +pub fn codestral_api_key(cx: &mut App) -> Entity { + return CODESTRAL_API_KEY + .get_or_init(|| { + cx.new(|_| { + ApiKeyState::new(CODESTRAL_API_URL.into(), CODESTRAL_API_KEY_ENV_VAR.clone()) + }) + }) + .clone(); } impl State { @@ -63,39 +70,19 @@ impl State { .store(api_url, api_key, |this| &mut this.api_key_state, cx) } - fn set_codestral_api_key( - &mut self, - api_key: Option, - cx: &mut Context, - ) -> Task> { - self.codestral_api_key_state.store( - CODESTRAL_API_URL.into(), - api_key, - |this| &mut this.codestral_api_key_state, - cx, - ) - } - fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = MistralLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } fn authenticate_codestral( &mut self, cx: &mut Context, ) -> Task> { - self.codestral_api_key_state.load_if_needed( - CODESTRAL_API_URL.into(), - &CODESTRAL_API_KEY_ENV_VAR, - |this| &mut this.codestral_api_key_state, - cx, - ) + self.codestral_api_key_state.update(cx, |state, cx| { + state.load_if_needed(CODESTRAL_API_URL.into(), |state| state, cx) + }) } } @@ -116,18 +103,14 @@ impl MistralLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), - codestral_api_key_state: ApiKeyState::new(CODESTRAL_API_URL.into()), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), + codestral_api_key_state: codestral_api_key(cx), } }); @@ -142,7 +125,11 @@ impl MistralLanguageModelProvider { } pub fn codestral_api_key(&self, url: &str, cx: &App) -> Option> { - self.state.read(cx).codestral_api_key_state.key(url) + self.state + .read(cx) + .codestral_api_key_state + .read(cx) + .key(url) } fn create_language_model(&self, model: mistral::Model) -> Arc { @@ -159,7 +146,7 @@ impl MistralLanguageModelProvider { &crate::AllLanguageModelSettings::get_global(cx).mistral } - fn api_url(cx: &App) -> SharedString { + pub fn api_url(cx: &App) -> SharedString { let api_url = &Self::settings(cx).api_url; if api_url.is_empty() { mistral::MISTRAL_API_URL.into() @@ -747,7 +734,6 @@ struct RawToolCall { struct ConfigurationView { api_key_editor: Entity, - codestral_api_key_editor: Entity, state: Entity, load_credentials_task: Option>, } @@ -756,8 +742,6 @@ impl ConfigurationView { fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { let api_key_editor = cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); - let codestral_api_key_editor = - cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")); cx.observe(&state, |_, _, cx| { cx.notify(); @@ -774,12 +758,6 @@ impl ConfigurationView { // We don't log an error, because "not signed in" is also an error. let _ = task.await; } - if let Some(task) = state - .update(cx, |state, cx| state.authenticate_codestral(cx)) - .log_err() - { - let _ = task.await; - } this.update(cx, |this, cx| { this.load_credentials_task = None; @@ -791,7 +769,6 @@ impl ConfigurationView { Self { api_key_editor, - codestral_api_key_editor, state, load_credentials_task, } @@ -829,110 +806,9 @@ impl ConfigurationView { .detach_and_log_err(cx); } - fn save_codestral_api_key( - &mut self, - _: &menu::Confirm, - window: &mut Window, - cx: &mut Context, - ) { - let api_key = self - .codestral_api_key_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - if api_key.is_empty() { - return; - } - - // url changes can cause the editor to be displayed again - self.codestral_api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| { - state.set_codestral_api_key(Some(api_key), cx) - })? - .await?; - cx.update(|_window, cx| { - set_edit_prediction_provider(EditPredictionProvider::Codestral, cx) - }) - }) - .detach_and_log_err(cx); - } - - fn reset_codestral_api_key(&mut self, window: &mut Window, cx: &mut Context) { - self.codestral_api_key_editor - .update(cx, |editor, cx| editor.set_text("", window, cx)); - - let state = self.state.clone(); - cx.spawn_in(window, async move |_, cx| { - state - .update(cx, |state, cx| state.set_codestral_api_key(None, cx))? - .await?; - cx.update(|_window, cx| set_edit_prediction_provider(EditPredictionProvider::Zed, cx)) - }) - .detach_and_log_err(cx); - } - fn should_render_api_key_editor(&self, cx: &mut Context) -> bool { !self.state.read(cx).is_authenticated() } - - fn render_codestral_api_key_editor(&mut self, cx: &mut Context) -> AnyElement { - let key_state = &self.state.read(cx).codestral_api_key_state; - let should_show_editor = !key_state.has_key(); - let env_var_set = key_state.is_from_env_var(); - let configured_card_label = if env_var_set { - format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable") - } else { - "Codestral API key configured".to_string() - }; - - if should_show_editor { - v_flex() - .id("codestral") - .size_full() - .mt_2() - .on_action(cx.listener(Self::save_codestral_api_key)) - .child(Label::new( - "To use Codestral as an edit prediction provider, \ - you need to add a Codestral-specific API key. Follow these steps:", - )) - .child( - List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("the Codestral section of Mistral's console"), - Some("https://console.mistral.ai/codestral"), - )) - .child(InstructionListItem::text_only("Paste your API key below and hit enter")), - ) - .child(self.codestral_api_key_editor.clone()) - .child( - Label::new( - format!("You can also assign the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable and restart Zed."), - ) - .size(LabelSize::Small).color(Color::Muted), - ).into_any() - } else { - ConfiguredApiCard::new(configured_card_label) - .disabled(env_var_set) - .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))) - .when(env_var_set, |this| { - this.tooltip_label(format!( - "To reset your API key, \ - unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable." - )) - }) - .on_click( - cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)), - ) - .into_any_element() - } - } } impl Render for ConfigurationView { @@ -958,17 +834,17 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("Mistral's console"), - Some("https://console.mistral.ai/api-keys"), - )) - .child(InstructionListItem::text_only( - "Ensure your Mistral account has credits", - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys")) + ) + .child( + ListBulletItem::new("Ensure your Mistral account has credits") + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") + ), ) .child(self.api_key_editor.clone()) .child( @@ -977,7 +853,6 @@ impl Render for ConfigurationView { ) .size(LabelSize::Small).color(Color::Muted), ) - .child(self.render_codestral_api_key_editor(cx)) .into_any() } else { v_flex() @@ -994,24 +869,11 @@ impl Render for ConfigurationView { )) }), ) - .child(self.render_codestral_api_key_editor(cx)) .into_any() } } } -fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) { - let fs = ::global(cx); - update_settings_file(fs, cx, move |settings, _| { - settings - .project - .all_languages - .features - .get_or_insert_default() - .edit_prediction_provider = Some(provider); - }); -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index 8345db3cce9fc51c487ec039c4257bfb39b162c3..c961001e65be662e0023b3199f68dfbf4989e604 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -5,11 +5,11 @@ use futures::{Stream, TryFutureExt, stream}; use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, - LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse, + LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; use menu; use ollama::{ @@ -22,13 +22,13 @@ use std::pin::Pin; use std::sync::LazyLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::{collections::HashMap, sync::Arc}; -use ui::{ButtonLike, ElevationIndex, List, Tooltip, prelude::*}; +use ui::{ + ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, InlineCode, List, ListBulletItem, + Tooltip, prelude::*, +}; use ui_input::InputField; -use zed_env_vars::{EnvVar, env_var}; use crate::AllLanguageModelSettings; -use crate::api_key::ApiKeyState; -use crate::ui::{ConfiguredApiCard, InstructionListItem}; const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download"; const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library"; @@ -80,12 +80,9 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = OllamaLanguageModelProvider::api_url(cx); - let task = self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + let task = self + .api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx); // Always try to fetch models - if no API key is needed (local Ollama), it will work // If API key is needed and provided, it will work @@ -185,7 +182,7 @@ impl OllamaLanguageModelProvider { http_client, fetched_models: Default::default(), fetch_model_task: None, - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }), }; @@ -733,15 +730,17 @@ impl ConfigurationView { .child(Label::new("To use local Ollama:")) .child( List::new() - .child(InstructionListItem::new( - "Download and install Ollama from", - Some("ollama.com"), - Some("https://ollama.com/download"), - )) - .child(InstructionListItem::text_only( - "Start Ollama and download a model: `ollama run gpt-oss:20b`", - )) - .child(InstructionListItem::text_only( + .child( + ListBulletItem::new("") + .child(Label::new("Download and install Ollama from")) + .child(ButtonLink::new("ollama.com", "https://ollama.com/download")), + ) + .child( + ListBulletItem::new("") + .child(Label::new("Start Ollama and download a model:")) + .child(InlineCode::new("ollama run gpt-oss:20b")), + ) + .child(ListBulletItem::new( "Click 'Connect' below to start using Ollama in Zed", )), ) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 403b025f518681f335f28e35d11450bef046fca2..afaffba3e53eb2496f9fae795d69b9e9c9f57249 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, - RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var, }; use menu; use open_ai::{ @@ -20,13 +20,9 @@ use std::pin::Pin; use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID; const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME; @@ -62,12 +58,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = OpenAiLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -76,17 +68,13 @@ impl OpenAiLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -790,17 +778,17 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("OpenAI's console"), - Some("https://platform.openai.com/api-keys"), - )) - .child(InstructionListItem::text_only( - "Ensure your OpenAI account has credits", - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys")) + ) + .child( + ListBulletItem::new("Ensure your OpenAI account has credits") + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index a30c8bfa5d3a728d6dd388f8e768cd470ee9736d..e6e7a9984da3d48b9e3c0f9571b8e916359fba03 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -4,10 +4,10 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, }; use menu; use open_ai::{ResponseStreamEvent, stream_completion}; @@ -16,9 +16,7 @@ use std::sync::Arc; use ui::{ElevationIndex, Tooltip, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::EnvVar; -use crate::api_key::ApiKeyState; use crate::provider::open_ai::{OpenAiEventMapper, into_open_ai}; pub use settings::OpenAiCompatibleAvailableModel as AvailableModel; pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities; @@ -38,7 +36,6 @@ pub struct OpenAiCompatibleLanguageModelProvider { pub struct State { id: Arc, - api_key_env_var: EnvVar, api_key_state: ApiKeyState, settings: OpenAiCompatibleSettings, } @@ -56,12 +53,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = SharedString::new(self.settings.api_url.clone()); - self.api_key_state.load_if_needed( - api_url, - &self.api_key_env_var, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -83,7 +76,6 @@ impl OpenAiCompatibleLanguageModelProvider { let api_url = SharedString::new(settings.api_url.as_str()); this.api_key_state.handle_url_change( api_url, - &this.api_key_env_var, |this| &mut this.api_key_state, cx, ); @@ -95,8 +87,10 @@ impl OpenAiCompatibleLanguageModelProvider { let settings = resolve_settings(&id, cx).cloned().unwrap_or_default(); State { id: id.clone(), - api_key_env_var: EnvVar::new(api_key_env_var_name), - api_key_state: ApiKeyState::new(SharedString::new(settings.api_url.as_str())), + api_key_state: ApiKeyState::new( + SharedString::new(settings.api_url.as_str()), + EnvVar::new(api_key_env_var_name), + ), settings, } }); @@ -437,7 +431,7 @@ impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let state = self.state.read(cx); let env_var_set = state.api_key_state.is_from_env_var(); - let env_var_name = &state.api_key_env_var.name; + let env_var_name = state.api_key_state.env_var_name(); let api_key_section = if self.should_render_editor(cx) { v_flex() diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index 7b10ebf963033603ede691fa72d2fa523bcdbab9..ad2e90d9dd5f4ece7e2582a867da50f6962c981c 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -4,11 +4,12 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat, - LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent, + LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role, + StopReason, TokenUsage, env_var, }; use open_router::{ Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models, @@ -17,13 +18,9 @@ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsSto use std::pin::Pin; use std::str::FromStr as _; use std::sync::{Arc, LazyLock}; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; -use zed_env_vars::{EnvVar, env_var}; - -use crate::ui::ConfiguredApiCard; -use crate::{api_key::ApiKeyState, ui::InstructionListItem}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter"); @@ -62,12 +59,9 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = OpenRouterLanguageModelProvider::api_url(cx); - let task = self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + let task = self + .api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx); cx.spawn(async move |this, cx| { let result = task.await; @@ -135,7 +129,7 @@ impl OpenRouterLanguageModelProvider { }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), http_client: http_client.clone(), available_models: Vec::new(), fetch_models_task: None, @@ -830,17 +824,15 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create an API key by visiting", - Some("OpenRouter's console"), - Some("https://openrouter.ai/keys"), - )) - .child(InstructionListItem::text_only( - "Ensure your OpenRouter account has credits", - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the assistant", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create an API key by visiting")) + .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys")) + ) + .child(ListBulletItem::new("Ensure your OpenRouter account has credits") + ) + .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/vercel.rs b/crates/language_models/src/provider/vercel.rs index 061dc1799922c03952b1a96e2785425f61bcf00b..4dfe848df80123dc4c37d27b81f76db359e076f9 100644 --- a/crates/language_models/src/provider/vercel.rs +++ b/crates/language_models/src/provider/vercel.rs @@ -4,26 +4,20 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, RateLimiter, Role, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::VercelAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; use vercel::{Model, VERCEL_API_URL}; -use zed_env_vars::{EnvVar, env_var}; - -use crate::{ - api_key::ApiKeyState, - ui::{ConfiguredApiCard, InstructionListItem}, -}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel"); @@ -59,12 +53,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = VercelLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -73,17 +63,13 @@ impl VercelLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -472,14 +458,14 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with Vercel v0, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("Vercel v0's console"), - Some("https://v0.dev/chat/settings/keys"), - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the agent", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("Vercel v0's console", "https://v0.dev/chat/settings/keys")) + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index cc54dfa0dd8a3f2ca6ab2b769a779afa8e73988b..19c50d71cf4e483b68d48c8b982a975f3091ff46 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -4,26 +4,21 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture}; use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window}; use http_client::HttpClient; use language_model::{ - AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, - LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, - LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, - LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, Role, + ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, + Role, env_var, }; use open_ai::ResponseStreamEvent; pub use settings::XaiAvailableModel as AvailableModel; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; -use ui::{List, prelude::*}; +use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; use x_ai::{Model, XAI_API_URL}; -use zed_env_vars::{EnvVar, env_var}; - -use crate::{ - api_key::ApiKeyState, - ui::{ConfiguredApiCard, InstructionListItem}, -}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI"); @@ -59,12 +54,8 @@ impl State { fn authenticate(&mut self, cx: &mut Context) -> Task> { let api_url = XAiLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ) + self.api_key_state + .load_if_needed(api_url, |this| &mut this.api_key_state, cx) } } @@ -73,17 +64,13 @@ impl XAiLanguageModelProvider { let state = cx.new(|cx| { cx.observe_global::(|this: &mut State, cx| { let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - &API_KEY_ENV_VAR, - |this| &mut this.api_key_state, - cx, - ); + this.api_key_state + .handle_url_change(api_url, |this| &mut this.api_key_state, cx); cx.notify(); }) .detach(); State { - api_key_state: ApiKeyState::new(Self::api_url(cx)), + api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), } }); @@ -474,14 +461,14 @@ impl Render for ConfigurationView { .child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:")) .child( List::new() - .child(InstructionListItem::new( - "Create one by visiting", - Some("xAI console"), - Some("https://console.x.ai/team/default/api-keys"), - )) - .child(InstructionListItem::text_only( - "Paste your API key below and hit enter to start using the agent", - )), + .child( + ListBulletItem::new("") + .child(Label::new("Create one by visiting")) + .child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys")) + ) + .child( + ListBulletItem::new("Paste your API key below and hit enter to start using the agent") + ), ) .child(self.api_key_editor.clone()) .child( diff --git a/crates/language_models/src/ui.rs b/crates/language_models/src/ui.rs deleted file mode 100644 index 1d7796ecc2b6c2a78b3ebc02dc9cd29bd8cfa2c6..0000000000000000000000000000000000000000 --- a/crates/language_models/src/ui.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod configured_api_card; -pub mod instruction_list_item; -pub use configured_api_card::ConfiguredApiCard; -pub use instruction_list_item::InstructionListItem; diff --git a/crates/language_models/src/ui/instruction_list_item.rs b/crates/language_models/src/ui/instruction_list_item.rs deleted file mode 100644 index bdb5fbe242ee902dc98a37addfaa0f103ef9ad20..0000000000000000000000000000000000000000 --- a/crates/language_models/src/ui/instruction_list_item.rs +++ /dev/null @@ -1,69 +0,0 @@ -use gpui::{AnyElement, IntoElement, ParentElement, SharedString}; -use ui::{ListItem, prelude::*}; - -/// A reusable list item component for adding LLM provider configuration instructions -pub struct InstructionListItem { - label: SharedString, - button_label: Option, - button_link: Option, -} - -impl InstructionListItem { - pub fn new( - label: impl Into, - button_label: Option>, - button_link: Option>, - ) -> Self { - Self { - label: label.into(), - button_label: button_label.map(|l| l.into()), - button_link: button_link.map(|l| l.into()), - } - } - - pub fn text_only(label: impl Into) -> Self { - Self { - label: label.into(), - button_label: None, - button_link: None, - } - } -} - -impl IntoElement for InstructionListItem { - type Element = AnyElement; - - fn into_element(self) -> Self::Element { - let item_content = if let (Some(button_label), Some(button_link)) = - (self.button_label, self.button_link) - { - let link = button_link; - let unique_id = SharedString::from(format!("{}-button", self.label)); - - h_flex() - .flex_wrap() - .child(Label::new(self.label)) - .child( - Button::new(unique_id, button_label) - .style(ButtonStyle::Subtle) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .on_click(move |_, _window, cx| cx.open_url(&link)), - ) - .into_any_element() - } else { - Label::new(self.label).into_any_element() - }; - - ListItem::new("list-item") - .selectable(false) - .start_slot( - Icon::new(IconName::Dash) - .size(IconSize::XSmall) - .color(Color::Hidden), - ) - .child(div().w_full().child(item_content)) - .into_any_element() - } -} diff --git a/crates/settings/src/settings_content/language.rs b/crates/settings/src/settings_content/language.rs index 25ff60e9f46cf797b815227222a3d27a6353c396..f9c85f18f380a7ad82b0d8bc202fe3763ba3a832 100644 --- a/crates/settings/src/settings_content/language.rs +++ b/crates/settings/src/settings_content/language.rs @@ -186,22 +186,20 @@ pub struct CopilotSettingsContent { pub enterprise_uri: Option, } +#[with_fallible_options] #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)] pub struct CodestralSettingsContent { /// Model to use for completions. /// /// Default: "codestral-latest" - #[serde(default)] pub model: Option, /// Maximum tokens to generate. /// /// Default: 150 - #[serde(default)] pub max_tokens: Option, /// Api URL to use for completions. /// /// Default: "https://codestral.mistral.ai" - #[serde(default)] pub api_url: Option, } diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index b5a259a3b9f901f4885b1cde8ad1e933efb263c0..256ec2de557e903405d1c3431ef44e98d757d3c6 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -18,6 +18,9 @@ test-support = [] [dependencies] anyhow.workspace = true bm25 = "2.3.2" +copilot.workspace = true +edit_prediction.workspace = true +language_models.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true @@ -38,8 +41,8 @@ strum.workspace = true telemetry.workspace = true theme.workspace = true title_bar.workspace = true -ui.workspace = true ui_input.workspace = true +ui.workspace = true util.workspace = true workspace.workspace = true zed_actions.workspace = true diff --git a/crates/settings_ui/src/components.rs b/crates/settings_ui/src/components.rs index b073372ac9b625036252e0a1722a960c8f6b3c45..f9754b0c749a77423930ef881e5b60ad3535b83d 100644 --- a/crates/settings_ui/src/components.rs +++ b/crates/settings_ui/src/components.rs @@ -2,10 +2,12 @@ mod dropdown; mod font_picker; mod icon_theme_picker; mod input_field; +mod section_items; mod theme_picker; pub use dropdown::*; pub use font_picker::font_picker; pub use icon_theme_picker::icon_theme_picker; pub use input_field::*; +pub use section_items::*; pub use theme_picker::theme_picker; diff --git a/crates/settings_ui/src/components/input_field.rs b/crates/settings_ui/src/components/input_field.rs index 57917c321127baf2e96e3862106461331afaf86f..575da7f7ae13f8a304b23d57dd41607e7b7c512a 100644 --- a/crates/settings_ui/src/components/input_field.rs +++ b/crates/settings_ui/src/components/input_field.rs @@ -13,6 +13,7 @@ pub struct SettingsInputField { tab_index: Option, } +// TODO: Update the `ui_input::InputField` to use `window.use_state` and `RenceOnce` and remove this component impl SettingsInputField { pub fn new() -> Self { Self { diff --git a/crates/settings_ui/src/components/section_items.rs b/crates/settings_ui/src/components/section_items.rs new file mode 100644 index 0000000000000000000000000000000000000000..69559d24f447f3d218b296600ed1ecdd9bf1dc30 --- /dev/null +++ b/crates/settings_ui/src/components/section_items.rs @@ -0,0 +1,56 @@ +use gpui::{IntoElement, ParentElement, Styled}; +use ui::{Divider, DividerColor, prelude::*}; + +#[derive(IntoElement)] +pub struct SettingsSectionHeader { + icon: Option, + label: SharedString, + no_padding: bool, +} + +impl SettingsSectionHeader { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + icon: None, + no_padding: false, + } + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn no_padding(mut self, no_padding: bool) -> Self { + self.no_padding = no_padding; + self + } +} + +impl RenderOnce for SettingsSectionHeader { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let label = Label::new(self.label) + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx); + + v_flex() + .w_full() + .when(!self.no_padding, |this| this.px_8()) + .gap_1p5() + .map(|this| { + if self.icon.is_some() { + this.child( + h_flex() + .gap_1p5() + .child(Icon::new(self.icon.unwrap()).color(Color::Muted)) + .child(label), + ) + } else { + this.child(label) + } + }) + .child(Divider::horizontal().color(DividerColor::BorderFaded)) + } +} diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 8652ccf68b48e8e858b96e4fe69edecd8ae29d25..b03ce327877f7251d41c39ee1eed5d424c18ce84 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -2330,8 +2330,12 @@ pub(crate) fn settings_data(cx: &App) -> Vec { // Note that `crates/json_schema_store` solves the same problem, there is probably a way to unify the two items.push(SettingsPageItem::SectionHeader(LANGUAGES_SECTION_HEADER)); items.extend(all_language_names(cx).into_iter().map(|language_name| { + let link = format!("languages.{language_name}"); SettingsPageItem::SubPageLink(SubPageLink { title: language_name, + description: None, + json_path: Some(link.leak()), + in_json: true, files: USER | PROJECT, render: Arc::new(|this, window, cx| { this.render_sub_page_items( @@ -6013,7 +6017,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "In Text Threads", + title: "Display In Text Threads", description: "Whether edit predictions are enabled when editing text threads in the agent panel.", field: Box::new(SettingField { json_path: Some("edit_prediction.in_text_threads"), @@ -6027,42 +6031,6 @@ pub(crate) fn settings_data(cx: &App) -> Vec { metadata: None, files: USER, }), - SettingsPageItem::SettingItem(SettingItem { - title: "Copilot Provider", - description: "Use GitHub Copilot as your edit prediction provider.", - field: Box::new( - SettingField { - json_path: Some("edit_prediction.copilot_provider"), - pick: |settings_content| { - settings_content.project.all_languages.edit_predictions.as_ref()?.copilot.as_ref() - }, - write: |settings_content, value| { - settings_content.project.all_languages.edit_predictions.get_or_insert_default().copilot = value; - }, - } - .unimplemented(), - ), - metadata: None, - files: USER | PROJECT, - }), - SettingsPageItem::SettingItem(SettingItem { - title: "Codestral Provider", - description: "Use Mistral's Codestral as your edit prediction provider.", - field: Box::new( - SettingField { - json_path: Some("edit_prediction.codestral_provider"), - pick: |settings_content| { - settings_content.project.all_languages.edit_predictions.as_ref()?.codestral.as_ref() - }, - write: |settings_content, value| { - settings_content.project.all_languages.edit_predictions.get_or_insert_default().codestral = value; - }, - } - .unimplemented(), - ), - metadata: None, - files: USER | PROJECT, - }), ] ); items @@ -7485,9 +7453,23 @@ fn non_editor_language_settings_data() -> Vec { fn edit_prediction_language_settings_section() -> Vec { vec![ SettingsPageItem::SectionHeader("Edit Predictions"), + SettingsPageItem::SubPageLink(SubPageLink { + title: "Configure Providers".into(), + json_path: Some("edit_predictions.providers"), + description: Some("Set up different edit prediction providers in complement to Zed's built-in Zeta model.".into()), + in_json: false, + files: USER, + render: Arc::new(|_, window, cx| { + let settings_window = cx.entity(); + let page = window.use_state(cx, |_, _| { + crate::pages::EditPredictionSetupPage::new(settings_window) + }); + page.into_any_element() + }), + }), SettingsPageItem::SettingItem(SettingItem { title: "Show Edit Predictions", - description: "Controls whether edit predictions are shown immediately or manually by triggering `editor::showeditprediction` (false).", + description: "Controls whether edit predictions are shown immediately or manually.", field: Box::new(SettingField { json_path: Some("languages.$(language).show_edit_predictions"), pick: |settings_content| { @@ -7505,7 +7487,7 @@ fn edit_prediction_language_settings_section() -> Vec { files: USER | PROJECT, }), SettingsPageItem::SettingItem(SettingItem { - title: "Edit Predictions Disabled In", + title: "Disable in Language Scopes", description: "Controls whether edit predictions are shown in the given language scopes.", field: Box::new( SettingField { diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b2c4818c1322216707f38bf93cefffeb14add03 --- /dev/null +++ b/crates/settings_ui/src/pages.rs @@ -0,0 +1,2 @@ +mod edit_prediction_provider_setup; +pub use edit_prediction_provider_setup::EditPredictionSetupPage; diff --git a/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs new file mode 100644 index 0000000000000000000000000000000000000000..fb8f967613fa195080f62c5ab2ce76a43f3d1e22 --- /dev/null +++ b/crates/settings_ui/src/pages/edit_prediction_provider_setup.rs @@ -0,0 +1,365 @@ +use edit_prediction::{ + ApiKeyState, Zeta2FeatureFlag, + mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token}, + sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token}, +}; +use feature_flags::FeatureFlagAppExt as _; +use gpui::{Entity, ScrollHandle, prelude::*}; +use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key}; +use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*}; + +use crate::{ + SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER, + components::{SettingsInputField, SettingsSectionHeader}, +}; + +pub struct EditPredictionSetupPage { + settings_window: Entity, + scroll_handle: ScrollHandle, +} + +impl EditPredictionSetupPage { + pub fn new(settings_window: Entity) -> Self { + Self { + settings_window, + scroll_handle: ScrollHandle::new(), + } + } +} + +impl Render for EditPredictionSetupPage { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let settings_window = self.settings_window.clone(); + + let providers = [ + Some(render_github_copilot_provider(window, cx).into_any_element()), + cx.has_flag::().then(|| { + render_api_key_provider( + IconName::Inception, + "Mercury", + "https://platform.inceptionlabs.ai/dashboard/api-keys".into(), + mercury_api_token(cx), + |_cx| MERCURY_CREDENTIALS_URL, + None, + window, + cx, + ) + .into_any_element() + }), + cx.has_flag::().then(|| { + render_api_key_provider( + IconName::SweepAi, + "Sweep", + "https://app.sweep.dev/".into(), + sweep_api_token(cx), + |_cx| SWEEP_CREDENTIALS_URL, + None, + window, + cx, + ) + .into_any_element() + }), + Some( + render_api_key_provider( + IconName::AiMistral, + "Codestral", + "https://console.mistral.ai/codestral".into(), + codestral_api_key(cx), + |cx| language_models::MistralLanguageModelProvider::api_url(cx), + Some(settings_window.update(cx, |settings_window, cx| { + let codestral_settings = codestral_settings(); + settings_window + .render_sub_page_items_section( + codestral_settings.iter().enumerate(), + None, + window, + cx, + ) + .into_any_element() + })), + window, + cx, + ) + .into_any_element(), + ), + ]; + + div() + .size_full() + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + .child( + v_flex() + .id("ep-setup-page") + .min_w_0() + .size_full() + .px_8() + .pb_16() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .children(providers.into_iter().flatten()), + ) + } +} + +fn render_api_key_provider( + icon: IconName, + title: &'static str, + link: SharedString, + api_key_state: Entity, + current_url: fn(&mut App) -> SharedString, + additional_fields: Option, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let weak_page = cx.weak_entity(); + _ = window.use_keyed_state(title, cx, |_, cx| { + let task = api_key_state.update(cx, |key_state, cx| { + key_state.load_if_needed(current_url(cx), |state| state, cx) + }); + cx.spawn(async move |_, cx| { + task.await.ok(); + weak_page + .update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }) + }); + + let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| { + ( + state.has_key(), + Some(state.env_var_name().clone()), + state.is_from_env_var(), + ) + }); + + let write_key = move |api_key: Option, cx: &mut App| { + api_key_state + .update(cx, |key_state, cx| { + let url = current_url(cx); + key_state.store(url, api_key, |key_state| key_state, cx) + }) + .detach_and_log_err(cx); + }; + + let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5(); + let header = SettingsSectionHeader::new(title) + .icon(icon) + .no_padding(true); + let button_link_label = format!("{} dashboard", title); + let description = h_flex() + .min_w_0() + .gap_0p5() + .child( + Label::new("Visit the") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + ButtonLink::new(button_link_label, link) + .no_icon(true) + .label_size(LabelSize::Small) + .label_color(Color::Muted), + ) + .child( + Label::new("to generate an API key.") + .size(LabelSize::Small) + .color(Color::Muted), + ); + let configured_card_label = if is_from_env_var { + "API Key Set in Environment Variable" + } else { + "API Key Configured" + }; + + let container = if has_key { + base_container.child(header).child( + ConfiguredApiCard::new(configured_card_label) + .button_label("Reset Key") + .button_tab_index(0) + .disabled(is_from_env_var) + .when_some(env_var_name, |this, env_var_name| { + this.when(is_from_env_var, |this| { + this.tooltip_label(format!( + "To reset your API key, unset the {} environment variable.", + env_var_name + )) + }) + }) + .on_click(move |_, _, cx| { + write_key(None, cx); + }), + ) + } else { + base_container.child(header).child( + h_flex() + .pt_2p5() + .w_full() + .justify_between() + .child( + v_flex() + .w_full() + .max_w_1_2() + .child(Label::new("API Key")) + .child(description) + .when_some(env_var_name, |this, env_var_name| { + this.child({ + let label = format!( + "Or set the {} env var and restart Zed.", + env_var_name.as_ref() + ); + Label::new(label).size(LabelSize::Small).color(Color::Muted) + }) + }), + ) + .child( + SettingsInputField::new() + .tab_index(0) + .with_placeholder("xxxxxxxxxxxxxxxxxxxx") + .on_confirm(move |api_key, cx| { + write_key(api_key.filter(|key| !key.is_empty()), cx); + }), + ), + ) + }; + + container.when_some(additional_fields, |this, additional_fields| { + this.child( + div() + .map(|this| if has_key { this.mt_1() } else { this.mt_4() }) + .px_neg_8() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(additional_fields), + ) + }) +} + +fn codestral_settings() -> Box<[SettingsPageItem]> { + Box::new([ + SettingsPageItem::SettingItem(SettingItem { + title: "API URL", + description: "The API URL to use for Codestral.", + field: Box::new(SettingField { + pick: |settings| { + settings + .project + .all_languages + .edit_predictions + .as_ref()? + .codestral + .as_ref()? + .api_url + .as_ref() + }, + write: |settings, value| { + settings + .project + .all_languages + .edit_predictions + .get_or_insert_default() + .codestral + .get_or_insert_default() + .api_url = value; + }, + json_path: Some("edit_predictions.codestral.api_url"), + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some(CODESTRAL_API_URL), + ..Default::default() + })), + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Max Tokens", + description: "The maximum number of tokens to generate.", + field: Box::new(SettingField { + pick: |settings| { + settings + .project + .all_languages + .edit_predictions + .as_ref()? + .codestral + .as_ref()? + .max_tokens + .as_ref() + }, + write: |settings, value| { + settings + .project + .all_languages + .edit_predictions + .get_or_insert_default() + .codestral + .get_or_insert_default() + .max_tokens = value; + }, + json_path: Some("edit_predictions.codestral.max_tokens"), + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Model", + description: "The Codestral model id to use.", + field: Box::new(SettingField { + pick: |settings| { + settings + .project + .all_languages + .edit_predictions + .as_ref()? + .codestral + .as_ref()? + .model + .as_ref() + }, + write: |settings, value| { + settings + .project + .all_languages + .edit_predictions + .get_or_insert_default() + .codestral + .get_or_insert_default() + .model = value; + }, + json_path: Some("edit_predictions.codestral.model"), + }), + metadata: Some(Box::new(SettingsFieldMetadata { + placeholder: Some("codestral-latest"), + ..Default::default() + })), + files: USER, + }), + ]) +} + +pub(crate) fn render_github_copilot_provider( + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { + let configuration_view = window.use_state(cx, |_, cx| { + copilot::ConfigurationView::new( + |cx| { + copilot::Copilot::global(cx) + .is_some_and(|copilot| copilot.read(cx).is_authenticated()) + }, + copilot::ConfigurationMode::EditPrediction, + cx, + ) + }); + + v_flex() + .id("github-copilot") + .min_w_0() + .gap_1p5() + .child( + SettingsSectionHeader::new("GitHub Copilot") + .icon(IconName::Copilot) + .no_padding(true), + ) + .child(configuration_view) +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 4464d3bdd951d4b7bf2511cfd718b0f297b8fc78..2c5585af5668a4b224d406413ab700bd8b2e349c 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -1,5 +1,6 @@ mod components; mod page_data; +mod pages; use anyhow::Result; use editor::{Editor, EditorEvent}; @@ -28,9 +29,8 @@ use std::{ }; use title_bar::platform_title_bar::PlatformTitleBar; use ui::{ - Banner, ContextMenu, Divider, DividerColor, DropdownMenu, DropdownStyle, IconButtonShape, - KeyBinding, KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, - prelude::*, + Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding, + KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, prelude::*, }; use ui_input::{NumberField, NumberFieldType}; use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; @@ -38,7 +38,8 @@ use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decor use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt}; use crate::components::{ - EnumVariantDropdown, SettingsInputField, font_picker, icon_theme_picker, theme_picker, + EnumVariantDropdown, SettingsInputField, SettingsSectionHeader, font_picker, icon_theme_picker, + theme_picker, }; const NAVBAR_CONTAINER_TAB_INDEX: isize = 0; @@ -613,7 +614,10 @@ pub fn open_settings_editor( app_id: Some(app_id.to_owned()), window_decorations: Some(window_decorations), window_min_size: Some(gpui::Size { - width: px(360.0), + // Don't make the settings window thinner than this, + // otherwise, it gets unusable. Users with smaller res monitors + // can customize the height, but not the width. + width: px(900.0), height: px(240.0), }), window_bounds: Some(WindowBounds::centered(scaled_bounds, cx)), @@ -834,18 +838,9 @@ impl SettingsPageItem { }; match self { - SettingsPageItem::SectionHeader(header) => v_flex() - .w_full() - .px_8() - .gap_1p5() - .child( - Label::new(SharedString::new_static(header)) - .size(LabelSize::Small) - .color(Color::Muted) - .buffer_font(cx), - ) - .child(Divider::horizontal().color(DividerColor::BorderFaded)) - .into_any_element(), + SettingsPageItem::SectionHeader(header) => { + SettingsSectionHeader::new(SharedString::new_static(header)).into_any_element() + } SettingsPageItem::SettingItem(setting_item) => { let (field_with_padding, _) = render_setting_item_inner(setting_item, true, false, cx); @@ -869,9 +864,20 @@ impl SettingsPageItem { .map(apply_padding) .child( v_flex() + .relative() .w_full() .max_w_1_2() - .child(Label::new(sub_page_link.title.clone())), + .child(Label::new(sub_page_link.title.clone())) + .when_some( + sub_page_link.description.as_ref(), + |this, description| { + this.child( + Label::new(description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }, + ), ) .child( Button::new( @@ -909,7 +915,13 @@ impl SettingsPageItem { this.push_sub_page(sub_page_link.clone(), header, cx) }) }), - ), + ) + .child(render_settings_item_link( + sub_page_link.title.clone(), + sub_page_link.json_path, + false, + cx, + )), ) .when(!is_last, |this| this.child(Divider::horizontal())) .into_any_element(), @@ -983,20 +995,6 @@ fn render_settings_item( let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx); let file_set_in = SettingsUiFile::from_settings(found_in_file.clone()); - let clipboard_has_link = cx - .read_from_clipboard() - .and_then(|entry| entry.text()) - .map_or(false, |maybe_url| { - setting_item.field.json_path().is_some() - && maybe_url.strip_prefix("zed://settings/") == setting_item.field.json_path() - }); - - let (link_icon, link_icon_color) = if clipboard_has_link { - (IconName::Check, Color::Success) - } else { - (IconName::Link, Color::Muted) - }; - h_flex() .id(setting_item.title) .min_w_0() @@ -1056,40 +1054,60 @@ fn render_settings_item( ) .child(control) .when(sub_page_stack().is_empty(), |this| { - // Intentionally using the description to make the icon button - // unique because some items share the same title (e.g., "Font Size") - let icon_button_id = - SharedString::new(format!("copy-link-btn-{}", setting_item.description)); + this.child(render_settings_item_link( + setting_item.description, + setting_item.field.json_path(), + sub_field, + cx, + )) + }) +} - this.child( - div() - .absolute() - .top(rems_from_px(18.)) - .map(|this| { - if sub_field { - this.visible_on_hover("setting-sub-item") - .left(rems_from_px(-8.5)) - } else { - this.visible_on_hover("setting-item") - .left(rems_from_px(-22.)) - } - }) - .child({ - IconButton::new(icon_button_id, link_icon) - .icon_color(link_icon_color) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Link")) - .when_some(setting_item.field.json_path(), |this, path| { - this.on_click(cx.listener(move |_, _, _, cx| { - let link = format!("zed://settings/{}", path); - cx.write_to_clipboard(ClipboardItem::new_string(link)); - cx.notify(); - })) - }) - }), - ) +fn render_settings_item_link( + id: impl Into, + json_path: Option<&'static str>, + sub_field: bool, + cx: &mut Context<'_, SettingsWindow>, +) -> impl IntoElement { + let clipboard_has_link = cx + .read_from_clipboard() + .and_then(|entry| entry.text()) + .map_or(false, |maybe_url| { + json_path.is_some() && maybe_url.strip_prefix("zed://settings/") == json_path + }); + + let (link_icon, link_icon_color) = if clipboard_has_link { + (IconName::Check, Color::Success) + } else { + (IconName::Link, Color::Muted) + }; + + div() + .absolute() + .top(rems_from_px(18.)) + .map(|this| { + if sub_field { + this.visible_on_hover("setting-sub-item") + .left(rems_from_px(-8.5)) + } else { + this.visible_on_hover("setting-item") + .left(rems_from_px(-22.)) + } }) + .child( + IconButton::new((id.into(), "copy-link-btn"), link_icon) + .icon_color(link_icon_color) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .tooltip(Tooltip::text("Copy Link")) + .when_some(json_path, |this, path| { + this.on_click(cx.listener(move |_, _, _, cx| { + let link = format!("zed://settings/{}", path); + cx.write_to_clipboard(ClipboardItem::new_string(link)); + cx.notify(); + })) + }), + ) } struct SettingItem { @@ -1175,6 +1193,12 @@ impl PartialEq for SettingItem { #[derive(Clone)] struct SubPageLink { title: SharedString, + description: Option, + /// See [`SettingField.json_path`] + json_path: Option<&'static str>, + /// Whether or not the settings in this sub page are configurable in settings.json + /// Removes the "Edit in settings.json" button from the page. + in_json: bool, files: FileMask, render: Arc< dyn Fn(&mut SettingsWindow, &mut Window, &mut Context) -> AnyElement @@ -1835,6 +1859,7 @@ impl SettingsWindow { header_str = *header; } SettingsPageItem::SubPageLink(sub_page_link) => { + json_path = sub_page_link.json_path; documents.push(bm25::Document { id: key_index, contents: [page.title, header_str, sub_page_link.title.as_ref()] @@ -2758,19 +2783,49 @@ impl SettingsWindow { page_content } - fn render_sub_page_items<'a, Items: Iterator>( + fn render_sub_page_items<'a, Items>( &self, items: Items, page_index: Option, window: &mut Window, cx: &mut Context, - ) -> impl IntoElement { - let mut page_content = v_flex() + ) -> impl IntoElement + where + Items: Iterator, + { + let page_content = v_flex() .id("settings-ui-page") .size_full() .overflow_y_scroll() .track_scroll(&self.sub_page_scroll_handle); + self.render_sub_page_items_in(page_content, items, page_index, window, cx) + } + fn render_sub_page_items_section<'a, Items>( + &self, + items: Items, + page_index: Option, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + where + Items: Iterator, + { + let page_content = v_flex().id("settings-ui-sub-page-section").size_full(); + self.render_sub_page_items_in(page_content, items, page_index, window, cx) + } + + fn render_sub_page_items_in<'a, Items>( + &self, + mut page_content: Stateful
, + items: Items, + page_index: Option, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + where + Items: Iterator, + { let items: Vec<_> = items.collect(); let items_len = items.len(); let mut section_header = None; @@ -2871,18 +2926,25 @@ impl SettingsWindow { ) .child(self.render_sub_page_breadcrumbs()), ) - .child( - Button::new("open-in-settings-file", "Edit in settings.json") - .tab_index(0_isize) - .style(ButtonStyle::OutlinedGhost) - .tooltip(Tooltip::for_action_title_in( - "Edit in settings.json", - &OpenCurrentFile, - &self.focus_handle, - )) - .on_click(cx.listener(|this, _, window, cx| { - this.open_current_settings_file(window, cx); - })), + .when( + sub_page_stack() + .last() + .is_none_or(|sub_page| sub_page.link.in_json), + |this| { + this.child( + Button::new("open-in-settings-file", "Edit in settings.json") + .tab_index(0_isize) + .style(ButtonStyle::OutlinedGhost) + .tooltip(Tooltip::for_action_title_in( + "Edit in settings.json", + &OpenCurrentFile, + &self.focus_handle, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.open_current_settings_file(window, cx); + })), + ) + }, ) .into_any_element(); diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index b6318f18c973ca5ca7eefa1ba39517ef65cad6df..c9cb943277c6c6a5e6bc1b472040c31d9caac45c 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -1,3 +1,4 @@ +mod ai; mod avatar; mod banner; mod button; @@ -16,6 +17,7 @@ mod icon; mod image; mod indent_guides; mod indicator; +mod inline_code; mod keybinding; mod keybinding_hint; mod label; @@ -43,6 +45,7 @@ mod tree_view_item; #[cfg(feature = "stories")] mod stories; +pub use ai::*; pub use avatar::*; pub use banner::*; pub use button::*; @@ -61,6 +64,7 @@ pub use icon::*; pub use image::*; pub use indent_guides::*; pub use indicator::*; +pub use inline_code::*; pub use keybinding::*; pub use keybinding_hint::*; pub use label::*; diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs new file mode 100644 index 0000000000000000000000000000000000000000..e36361b7b06559c1442b86acf26b6694bb950d82 --- /dev/null +++ b/crates/ui/src/components/ai.rs @@ -0,0 +1,3 @@ +mod configured_api_card; + +pub use configured_api_card::*; diff --git a/crates/language_models/src/ui/configured_api_card.rs b/crates/ui/src/components/ai/configured_api_card.rs similarity index 84% rename from crates/language_models/src/ui/configured_api_card.rs rename to crates/ui/src/components/ai/configured_api_card.rs index 063ac1717f3aa5de1a448e26c94df7530fec588f..37f9ac7602d676906565a911f1bbca6d2b40f755 100644 --- a/crates/language_models/src/ui/configured_api_card.rs +++ b/crates/ui/src/components/ai/configured_api_card.rs @@ -1,10 +1,11 @@ +use crate::{Tooltip, prelude::*}; use gpui::{ClickEvent, IntoElement, ParentElement, SharedString}; -use ui::{Tooltip, prelude::*}; #[derive(IntoElement)] pub struct ConfiguredApiCard { label: SharedString, button_label: Option, + button_tab_index: Option, tooltip_label: Option, disabled: bool, on_click: Option>, @@ -15,6 +16,7 @@ impl ConfiguredApiCard { Self { label: label.into(), button_label: None, + button_tab_index: None, tooltip_label: None, disabled: false, on_click: None, @@ -43,6 +45,11 @@ impl ConfiguredApiCard { self.disabled = disabled; self } + + pub fn button_tab_index(mut self, tab_index: isize) -> Self { + self.button_tab_index = Some(tab_index); + self + } } impl RenderOnce for ConfiguredApiCard { @@ -51,23 +58,27 @@ impl RenderOnce for ConfiguredApiCard { let button_id = SharedString::new(format!("id-{}", button_label)); h_flex() + .min_w_0() .mt_0p5() .p_1() .justify_between() .rounded_md() + .flex_wrap() .border_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().background) .child( h_flex() - .flex_1() .min_w_0() .gap_1() .child(Icon::new(IconName::Check).color(Color::Success)) - .child(Label::new(self.label).truncate()), + .child(Label::new(self.label)), ) .child( Button::new(button_id, button_label) + .when_some(self.button_tab_index, |elem, tab_index| { + elem.tab_index(tab_index) + }) .label_size(LabelSize::Small) .icon(IconName::Undo) .icon_size(IconSize::Small) diff --git a/crates/ui/src/components/ai/copilot_configuration_callout.rs b/crates/ui/src/components/ai/copilot_configuration_callout.rs new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/crates/ui/src/components/button.rs b/crates/ui/src/components/button.rs index 23e7702f6241b6ca0d4074936ee20da26531fbed..d56a9c09d3b57ba607b6837b16af31d240e58663 100644 --- a/crates/ui/src/components/button.rs +++ b/crates/ui/src/components/button.rs @@ -1,12 +1,14 @@ mod button; mod button_icon; mod button_like; +mod button_link; mod icon_button; mod split_button; mod toggle_button; pub use button::*; pub use button_like::*; +pub use button_link::*; pub use icon_button::*; pub use split_button::*; pub use toggle_button::*; diff --git a/crates/ui/src/components/button/button_link.rs b/crates/ui/src/components/button/button_link.rs new file mode 100644 index 0000000000000000000000000000000000000000..caffe2772bce394be6899b1f9b3b686c3927a530 --- /dev/null +++ b/crates/ui/src/components/button/button_link.rs @@ -0,0 +1,102 @@ +use gpui::{IntoElement, Window, prelude::*}; + +use crate::{ButtonLike, prelude::*}; + +/// A button that takes an underline to look like a regular web link. +/// It also contains an arrow icon to communicate the link takes you out of Zed. +/// +/// # Usage Example +/// +/// ``` +/// use ui::ButtonLink; +/// +/// let button_link = ButtonLink::new("Click me", "https://example.com"); +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct ButtonLink { + label: SharedString, + label_size: LabelSize, + label_color: Color, + link: String, + no_icon: bool, +} + +impl ButtonLink { + pub fn new(label: impl Into, link: impl Into) -> Self { + Self { + link: link.into(), + label: label.into(), + label_size: LabelSize::Default, + label_color: Color::Default, + no_icon: false, + } + } + + pub fn no_icon(mut self, no_icon: bool) -> Self { + self.no_icon = no_icon; + self + } + + pub fn label_size(mut self, label_size: LabelSize) -> Self { + self.label_size = label_size; + self + } + + pub fn label_color(mut self, label_color: Color) -> Self { + self.label_color = label_color; + self + } +} + +impl RenderOnce for ButtonLink { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let id = format!("{}-{}", self.label, self.link); + + ButtonLike::new(id) + .size(ButtonSize::None) + .child( + h_flex() + .gap_0p5() + .child( + Label::new(self.label) + .size(self.label_size) + .color(self.label_color) + .underline(), + ) + .when(!self.no_icon, |this| { + this.child( + Icon::new(IconName::ArrowUpRight) + .size(IconSize::Small) + .color(Color::Muted), + ) + }), + ) + .on_click(move |_, _, cx| cx.open_url(&self.link)) + .into_any_element() + } +} + +impl Component for ButtonLink { + fn scope() -> ComponentScope { + ComponentScope::Navigation + } + + fn description() -> Option<&'static str> { + Some("A button that opens a URL.") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/divider.rs b/crates/ui/src/components/divider.rs index d6101f23203072a27febd0f8b8391af75b41d7f3..cc7ad19875d2817d98076812bb7b9ea101341107 100644 --- a/crates/ui/src/components/divider.rs +++ b/crates/ui/src/components/divider.rs @@ -144,12 +144,18 @@ impl Divider { impl RenderOnce for Divider { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let base = match self.direction { - DividerDirection::Horizontal => { - div().h_px().w_full().when(self.inset, |this| this.mx_1p5()) - } - DividerDirection::Vertical => { - div().w_px().h_full().when(self.inset, |this| this.my_1p5()) - } + DividerDirection::Horizontal => div() + .min_w_0() + .flex_none() + .h_px() + .w_full() + .when(self.inset, |this| this.mx_1p5()), + DividerDirection::Vertical => div() + .min_w_0() + .flex_none() + .w_px() + .h_full() + .when(self.inset, |this| this.my_1p5()), }; match self.style { diff --git a/crates/ui/src/components/inline_code.rs b/crates/ui/src/components/inline_code.rs new file mode 100644 index 0000000000000000000000000000000000000000..43507127fef478e5a38cfad2d84446673af15f2e --- /dev/null +++ b/crates/ui/src/components/inline_code.rs @@ -0,0 +1,64 @@ +use crate::prelude::*; +use gpui::{AnyElement, IntoElement, ParentElement, Styled}; + +/// InlineCode mimics the way inline code is rendered when wrapped in backticks in Markdown. +/// +/// # Usage Example +/// +/// ``` +/// use ui::InlineCode; +/// +/// let InlineCode = InlineCode::new("
hey
"); +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct InlineCode { + label: SharedString, + label_size: LabelSize, +} + +impl InlineCode { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + label_size: LabelSize::Default, + } + } + + /// Sets the size of the label. + pub fn label_size(mut self, size: LabelSize) -> Self { + self.label_size = size; + self + } +} + +impl RenderOnce for InlineCode { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .min_w_0() + .px_0p5() + .overflow_hidden() + .bg(cx.theme().colors().text.opacity(0.05)) + .child(Label::new(self.label).size(self.label_size).buffer_font(cx)) + } +} + +impl Component for InlineCode { + fn scope() -> ComponentScope { + ComponentScope::DataDisplay + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .child( + example_group(vec![single_example( + "Simple", + InlineCode::new("zed.dev").into_any_element(), + )]) + .vertical(), + ) + .into_any_element(), + ) + } +} diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 1fa6b14c83d8359df234f33ecb9318c88e3a2714..e51d65c3b6c8ecb38ba26a1926c3bfdbb988a1f8 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -227,7 +227,7 @@ impl RenderOnce for LabelLike { .get_or_insert_with(Default::default) .underline = Some(UnderlineStyle { thickness: px(1.), - color: None, + color: Some(cx.theme().colors().text_muted.opacity(0.4)), wavy: false, }); this diff --git a/crates/ui/src/components/list/list_bullet_item.rs b/crates/ui/src/components/list/list_bullet_item.rs index 17731488f7139522bf19aeaab18fb395d1eb68b0..934f0853dbe18b8231e15073766b6c84c1896546 100644 --- a/crates/ui/src/components/list/list_bullet_item.rs +++ b/crates/ui/src/components/list/list_bullet_item.rs @@ -1,18 +1,33 @@ -use crate::{ListItem, prelude::*}; -use component::{Component, ComponentScope, example_group_with_title, single_example}; +use crate::{ButtonLink, ListItem, prelude::*}; +use component::{Component, ComponentScope, example_group, single_example}; use gpui::{IntoElement, ParentElement, SharedString}; #[derive(IntoElement, RegisterComponent)] pub struct ListBulletItem { label: SharedString, + label_color: Option, + children: Vec, } impl ListBulletItem { pub fn new(label: impl Into) -> Self { Self { label: label.into(), + label_color: None, + children: Vec::new(), } } + + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = Some(color); + self + } +} + +impl ParentElement for ListBulletItem { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } } impl RenderOnce for ListBulletItem { @@ -34,7 +49,18 @@ impl RenderOnce for ListBulletItem { .color(Color::Hidden), ), ) - .child(div().w_full().min_w_0().child(Label::new(self.label))), + .map(|this| { + if !self.children.is_empty() { + this.child(h_flex().gap_0p5().flex_wrap().children(self.children)) + } else { + this.child( + div().w_full().min_w_0().child( + Label::new(self.label) + .color(self.label_color.unwrap_or(Color::Default)), + ), + ) + } + }), ) .into_any_element() } @@ -46,37 +72,43 @@ impl Component for ListBulletItem { } fn description() -> Option<&'static str> { - Some("A list item with a bullet point indicator for unordered lists.") + Some("A list item with a dash indicator for unordered lists.") } fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let basic_examples = vec![ + single_example( + "Simple", + ListBulletItem::new("First bullet item").into_any_element(), + ), + single_example( + "Multiple Lines", + v_flex() + .child(ListBulletItem::new("First item")) + .child(ListBulletItem::new("Second item")) + .child(ListBulletItem::new("Third item")) + .into_any_element(), + ), + single_example( + "Long Text", + ListBulletItem::new( + "A longer bullet item that demonstrates text wrapping behavior", + ) + .into_any_element(), + ), + single_example( + "With Link", + ListBulletItem::new("") + .child(Label::new("Create a Zed account by")) + .child(ButtonLink::new("visiting the website", "https://zed.dev")) + .into_any_element(), + ), + ]; + Some( v_flex() .gap_6() - .child(example_group_with_title( - "Bullet Items", - vec![ - single_example( - "Simple", - ListBulletItem::new("First bullet item").into_any_element(), - ), - single_example( - "Multiple Lines", - v_flex() - .child(ListBulletItem::new("First item")) - .child(ListBulletItem::new("Second item")) - .child(ListBulletItem::new("Third item")) - .into_any_element(), - ), - single_example( - "Long Text", - ListBulletItem::new( - "A longer bullet item that demonstrates text wrapping behavior", - ) - .into_any_element(), - ), - ], - )) + .child(example_group(basic_examples).vertical()) .into_any_element(), ) } diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index cfdc730b4db5be8e2f4a317dcf7e12072af40a88..6d37ea4d2a50637ae7c2e0287ae8f371e3b47aba 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -41,7 +41,7 @@ pub enum NotificationId { impl NotificationId { /// Returns a unique [`NotificationId`] for the given type. - pub fn unique() -> Self { + pub const fn unique() -> Self { Self::Unique(TypeId::of::()) } diff --git a/crates/zed_env_vars/src/zed_env_vars.rs b/crates/zed_env_vars/src/zed_env_vars.rs index 53b9c22bb207e81831d1d9ae6087d1a297331d3f..e601cc9536602ac943bd76bf1bfd8b8ac8979dd9 100644 --- a/crates/zed_env_vars/src/zed_env_vars.rs +++ b/crates/zed_env_vars/src/zed_env_vars.rs @@ -5,6 +5,7 @@ use std::sync::LazyLock; /// When true, Zed will use in-memory databases instead of persistent storage. pub static ZED_STATELESS: LazyLock = bool_env_var!("ZED_STATELESS"); +#[derive(Clone)] pub struct EnvVar { pub name: SharedString, /// Value of the environment variable. Also `None` when set to an empty string. @@ -30,7 +31,7 @@ impl EnvVar { #[macro_export] macro_rules! env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into())) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into())) }; } @@ -39,6 +40,6 @@ macro_rules! env_var { #[macro_export] macro_rules! bool_env_var { ($name:expr) => { - LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) + ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some()) }; } From dad6481e0241a252d59f296c57e757bb230280fb Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sat, 13 Dec 2025 21:51:58 -0500 Subject: [PATCH 10/67] Disambiguate branch name in title bar (#44793) Add the repository name when: - there's more than one repository, and - the name of the active repository doesn't match the name of the project (to avoid stuttering with the adjacent project switcher button) Release Notes: - The branch name in the title bar now includes the name of the current repository when needed to disambiguate. --- crates/title_bar/src/title_bar.rs | 60 ++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 680c455e73ab135f418f199f06415fff79100ea5..bd606e4a021eaad30b95322d785e23d694734c06 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -167,7 +167,7 @@ impl Render for TitleBar { .child(self.render_project_name(cx)) }) .when(title_bar_settings.show_branch_name, |title_bar| { - title_bar.children(self.render_project_branch(cx)) + title_bar.children(self.render_project_repo(cx)) }) }) }) @@ -319,6 +319,27 @@ impl TitleBar { } } + fn project_name(&self, cx: &Context) -> Option { + self.project + .read(cx) + .visible_worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + let settings_location = SettingsLocation { + worktree_id: worktree.id(), + path: RelPath::empty(), + }; + + let settings = WorktreeSettings::get(Some(settings_location), cx); + let name = match &settings.project_name { + Some(name) => name.as_str(), + None => worktree.root_name_str(), + }; + SharedString::new(name) + }) + .next() + } + fn render_remote_project_connection(&self, cx: &mut Context) -> Option { let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.display_name().into(); @@ -451,27 +472,10 @@ impl TitleBar { } pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { - let name = self - .project - .read(cx) - .visible_worktrees(cx) - .map(|worktree| { - let worktree = worktree.read(cx); - let settings_location = SettingsLocation { - worktree_id: worktree.id(), - path: RelPath::empty(), - }; - - let settings = WorktreeSettings::get(Some(settings_location), cx); - match &settings.project_name { - Some(name) => name.as_str(), - None => worktree.root_name_str(), - } - }) - .next(); + let name = self.project_name(cx); let is_project_selected = name.is_some(); let name = if let Some(name) = name { - util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH) + util::truncate_and_trailoff(&name, MAX_PROJECT_NAME_LENGTH) } else { "Open recent project".to_string() }; @@ -500,9 +504,10 @@ impl TitleBar { })) } - pub fn render_project_branch(&self, cx: &mut Context) -> Option { + pub fn render_project_repo(&self, cx: &mut Context) -> Option { let settings = TitleBarSettings::get_global(cx); let repository = self.project.read(cx).active_repository(cx)?; + let repository_count = self.project.read(cx).repositories(cx).len(); let workspace = self.workspace.upgrade()?; let repo = repository.read(cx); let branch_name = repo @@ -519,6 +524,19 @@ impl TitleBar { .collect::() }) })?; + let project_name = self.project_name(cx); + let repo_name = repo + .work_directory_abs_path + .file_name() + .and_then(|name| name.to_str()) + .map(SharedString::new); + let show_repo_name = + repository_count > 1 && repo.branch.is_some() && repo_name != project_name; + let branch_name = if let Some(repo_name) = repo_name.filter(|_| show_repo_name) { + format!("{repo_name}/{branch_name}") + } else { + branch_name + }; Some( Button::new("project_branch_trigger", branch_name) From 488fa0254772b72709875e37802cef0955f67e26 Mon Sep 17 00:00:00 2001 From: Michael Benfield Date: Sat, 13 Dec 2025 19:22:20 -0800 Subject: [PATCH 11/67] Streaming tool use for inline assistant (#44751) Depends on: https://github.com/zed-industries/zed/pull/44753 Release Notes: - N/A --------- Co-authored-by: Mikayla Maki --- assets/prompts/content_prompt_v2.hbs | 3 +- assets/settings/default.json | 2 + crates/agent_settings/src/agent_settings.rs | 4 + crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/buffer_codegen.rs | 304 +++++++++++++----- crates/agent_ui/src/inline_assistant.rs | 52 --- crates/anthropic/src/anthropic.rs | 14 + crates/feature_flags/src/flags.rs | 6 +- crates/language_model/src/language_model.rs | 20 ++ .../language_models/src/provider/anthropic.rs | 4 + crates/language_models/src/provider/cloud.rs | 4 + crates/prompt_store/src/prompts.rs | 2 +- crates/settings/src/settings_content/agent.rs | 11 +- 13 files changed, 282 insertions(+), 145 deletions(-) diff --git a/assets/prompts/content_prompt_v2.hbs b/assets/prompts/content_prompt_v2.hbs index e1b6ddc6f023e9e97c9bb851473ac02e989c8feb..87376f49f12f0e27cc61e9f9747d9de6bfde43cb 100644 --- a/assets/prompts/content_prompt_v2.hbs +++ b/assets/prompts/content_prompt_v2.hbs @@ -39,6 +39,5 @@ Only make changes that are necessary to fulfill the prompt, leave everything els Start at the indentation level in the original file in the rewritten {{content_type}}. -You must use one of the provided tools to make the rewrite or to provide an explanation as to why the user's request cannot be fulfilled. It is an error if -you simply send back unstructured text. If you need to make a statement or ask a question you must use one of the tools to do so. +IMPORTANT: You MUST use one of the provided tools to make the rewrite or to provide an explanation as to why the user's request cannot be fulfilled. You MUST NOT send back unstructured text. If you need to make a statement or ask a question you MUST use one of the tools to do so. It is an error if you try to make a change that cannot be made simply by editing the rewrite_section. diff --git a/assets/settings/default.json b/assets/settings/default.json index 58564138227f361e5432d377358b18734f250d72..a5180c9e2eaca9be49fa832e32e001d15d65df8f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -896,6 +896,8 @@ "default_width": 380, }, "agent": { + // Whether the inline assistant should use streaming tools, when available + "inline_assistant_use_streaming_tools": true, // Whether the agent is enabled. "enabled": true, // What completion mode to start new threads in, if available. Can be 'normal' or 'burn'. diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 084ac7c3e7a1be4920126f857145e64b65a255dd..5dab085a255fe399d5f529791614d51f8b4cc78b 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -28,6 +28,7 @@ pub struct AgentSettings { pub default_height: Pixels, pub default_model: Option, pub inline_assistant_model: Option, + pub inline_assistant_use_streaming_tools: bool, pub commit_message_model: Option, pub thread_summary_model: Option, pub inline_alternatives: Vec, @@ -155,6 +156,9 @@ impl Settings for AgentSettings { default_height: px(agent.default_height.unwrap()), default_model: Some(agent.default_model.unwrap()), inline_assistant_model: agent.inline_assistant_model, + inline_assistant_use_streaming_tools: agent + .inline_assistant_use_streaming_tools + .unwrap_or(true), commit_message_model: agent.commit_message_model, thread_summary_model: agent.thread_summary_model, inline_alternatives: agent.inline_alternatives.unwrap_or_default(), diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index b6f7517ed934cf6cac8eefc262233b845169de9f..eb7785fad59894012251c84319af7fca306f2882 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -445,6 +445,7 @@ mod tests { default_height: px(600.), default_model: None, inline_assistant_model: None, + inline_assistant_use_streaming_tools: false, commit_message_model: None, thread_summary_model: None, inline_alternatives: vec![], diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1cd7bec7b5b2c24cfbcf01a20091e8a07608e73a..e2c67a04167d7080a6f94b9ee2a8fae516d487d7 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,23 +1,26 @@ use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; + use client::telemetry::Telemetry; use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; -use feature_flags::{FeatureFlagAppExt as _, InlineAssistantV2FeatureFlag}; +use feature_flags::{FeatureFlagAppExt as _, InlineAssistantUseToolFeatureFlag}; use futures::{ SinkExt, Stream, StreamExt, TryStreamExt as _, channel::mpsc, future::{LocalBoxFuture, Shared}, join, + stream::BoxStream, }; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; use language::{Buffer, IndentKind, Point, TransactionId, line_diff}; use language_model::{ - LanguageModel, LanguageModelCompletionError, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelTextStream, Role, - report_assistant_event, + LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolChoice, + LanguageModelToolUse, Role, TokenUsage, report_assistant_event, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; @@ -25,6 +28,7 @@ use prompt_store::PromptBuilder; use rope::Rope; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::Settings as _; use smol::future::FutureExt; use std::{ cmp, @@ -46,6 +50,7 @@ pub struct FailureMessageInput { /// A brief message to the user explaining why you're unable to fulfill the request or to ask a question about the request. /// /// The message may use markdown formatting if you wish. + #[serde(default)] pub message: String, } @@ -56,9 +61,11 @@ pub struct RewriteSectionInput { /// /// The description may use markdown formatting if you wish. /// This is optional - if the edit is simple or obvious, you should leave it empty. + #[serde(default)] pub description: String, /// The text to replace the section with. + #[serde(default)] pub replacement_text: String, } @@ -379,6 +386,12 @@ impl CodegenAlternative { &self.last_equal_ranges } + fn use_streaming_tools(model: &dyn LanguageModel, cx: &App) -> bool { + model.supports_streaming_tools() + && cx.has_flag::() + && AgentSettings::get_global(cx).inline_assistant_use_streaming_tools + } + pub fn start( &mut self, user_prompt: String, @@ -398,11 +411,17 @@ impl CodegenAlternative { let telemetry_id = model.telemetry_id(); let provider_id = model.provider_id(); - if cx.has_flag::() { + if Self::use_streaming_tools(model.as_ref(), cx) { let request = self.build_request(&model, user_prompt, context_task, cx)?; - let tool_use = - cx.spawn(async move |_, cx| model.stream_completion_tool(request.await, cx).await); - self.handle_tool_use(telemetry_id, provider_id.to_string(), api_key, tool_use, cx); + let completion_events = + cx.spawn(async move |_, cx| model.stream_completion(request.await, cx).await); + self.generation = self.handle_completion( + telemetry_id, + provider_id.to_string(), + api_key, + completion_events, + cx, + ); } else { let stream: LocalBoxFuture> = if user_prompt.trim().to_lowercase() == "delete" { @@ -414,13 +433,14 @@ impl CodegenAlternative { }) .boxed_local() }; - self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx); + self.generation = + self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx); } Ok(()) } - fn build_request_v2( + fn build_request_tools( &self, model: &Arc, user_prompt: String, @@ -456,7 +476,7 @@ impl CodegenAlternative { let system_prompt = self .builder - .generate_inline_transformation_prompt_v2( + .generate_inline_transformation_prompt_tools( language_name, buffer, range.start.0..range.end.0, @@ -466,6 +486,9 @@ impl CodegenAlternative { let temperature = AgentSettings::temperature_for_model(model, cx); let tool_input_format = model.tool_input_format(); + let tool_choice = model + .supports_tool_choice(LanguageModelToolChoice::Any) + .then_some(LanguageModelToolChoice::Any); Ok(cx.spawn(async move |_cx| { let mut messages = vec![LanguageModelRequestMessage { @@ -508,7 +531,7 @@ impl CodegenAlternative { intent: Some(CompletionIntent::InlineAssist), mode: None, tools, - tool_choice: None, + tool_choice, stop: Vec::new(), temperature, messages, @@ -524,8 +547,8 @@ impl CodegenAlternative { context_task: Shared>>, cx: &mut App, ) -> Result> { - if cx.has_flag::() { - return self.build_request_v2(model, user_prompt, context_task, cx); + if Self::use_streaming_tools(model.as_ref(), cx) { + return self.build_request_tools(model, user_prompt, context_task, cx); } let buffer = self.buffer.read(cx).snapshot(cx); @@ -603,7 +626,7 @@ impl CodegenAlternative { model_api_key: Option, stream: impl 'static + Future>, cx: &mut Context, - ) { + ) -> Task<()> { let start_time = Instant::now(); // Make a new snapshot and re-resolve anchor in case the document was modified. @@ -659,7 +682,8 @@ impl CodegenAlternative { let completion = Arc::new(Mutex::new(String::new())); let completion_clone = completion.clone(); - self.generation = cx.spawn(async move |codegen, cx| { + cx.notify(); + cx.spawn(async move |codegen, cx| { let stream = stream.await; let token_usage = stream @@ -685,6 +709,7 @@ impl CodegenAlternative { stream?.stream.map_err(|error| error.into()), ); futures::pin_mut!(chunks); + let mut diff = StreamingDiff::new(selected_text.to_string()); let mut line_diff = LineDiff::default(); @@ -876,8 +901,7 @@ impl CodegenAlternative { cx.notify(); }) .ok(); - }); - cx.notify(); + }) } pub fn current_completion(&self) -> Option { @@ -1060,21 +1084,29 @@ impl CodegenAlternative { }) } - fn handle_tool_use( + fn handle_completion( &mut self, - _telemetry_id: String, - _provider_id: String, - _api_key: Option, - tool_use: impl 'static - + Future< - Output = Result, + telemetry_id: String, + provider_id: String, + api_key: Option, + completion_stream: Task< + Result< + BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, >, cx: &mut Context, - ) { + ) -> Task<()> { self.diff = Diff::default(); self.status = CodegenStatus::Pending; - self.generation = cx.spawn(async move |codegen, cx| { + cx.notify(); + // Leaving this in generation so that STOP equivalent events are respected even + // while we're still pre-processing the completion event + cx.spawn(async move |codegen, cx| { let finish_with_status = |status: CodegenStatus, cx: &mut AsyncApp| { let _ = codegen.update(cx, |this, cx| { this.status = status; @@ -1083,76 +1115,176 @@ impl CodegenAlternative { }); }; - let tool_use = tool_use.await; - - match tool_use { - Ok(tool_use) if tool_use.name.as_ref() == "rewrite_section" => { - // Parse the input JSON into RewriteSectionInput - match serde_json::from_value::(tool_use.input) { - Ok(input) => { - // Store the description if non-empty - let description = if !input.description.trim().is_empty() { - Some(input.description.clone()) - } else { - None + let mut completion_events = match completion_stream.await { + Ok(events) => events, + Err(err) => { + finish_with_status(CodegenStatus::Error(err.into()), cx); + return; + } + }; + + let chars_read_so_far = Arc::new(Mutex::new(0usize)); + let tool_to_text_and_message = + move |tool_use: LanguageModelToolUse| -> (Option, Option) { + let mut chars_read_so_far = chars_read_so_far.lock(); + match tool_use.name.as_ref() { + "rewrite_section" => { + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return (None, None); }; + let value = input.replacement_text[*chars_read_so_far..].to_string(); + *chars_read_so_far = input.replacement_text.len(); + (Some(value), Some(std::mem::take(&mut input.description))) + } + "failure_message" => { + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return (None, None); + }; + (None, Some(std::mem::take(&mut input.message))) + } + _ => (None, None), + } + }; - // Apply the replacement text to the buffer and compute diff - let batch_diff_task = codegen - .update(cx, |this, cx| { - this.model_explanation = description.map(Into::into); - let range = this.range.clone(); - this.apply_edits( - std::iter::once((range, input.replacement_text)), - cx, - ); - this.reapply_batch_diff(cx) - }) - .ok(); - - // Wait for the diff computation to complete - if let Some(diff_task) = batch_diff_task { - diff_task.await; - } + let mut message_id = None; + let mut first_text = None; + let last_token_usage = Arc::new(Mutex::new(TokenUsage::default())); + let total_text = Arc::new(Mutex::new(String::new())); - finish_with_status(CodegenStatus::Done, cx); - return; + loop { + if let Some(first_event) = completion_events.next().await { + match first_event { + Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => { + message_id = Some(id); } - Err(e) => { - finish_with_status(CodegenStatus::Error(e.into()), cx); - return; + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) + if matches!( + tool_use.name.as_ref(), + "rewrite_section" | "failure_message" + ) => + { + let is_complete = tool_use.is_input_complete; + let (text, message) = tool_to_text_and_message(tool_use); + // Only update the model explanation if the tool use is complete. + // Otherwise the UI element bounces around as it's updated. + if is_complete { + let _ = codegen.update(cx, |this, _cx| { + this.model_explanation = message.map(Into::into); + }); + } + first_text = text; + if first_text.is_some() { + break; + } } - } - } - Ok(tool_use) if tool_use.name.as_ref() == "failure_message" => { - // Handle failure message tool use - match serde_json::from_value::(tool_use.input) { - Ok(input) => { - let _ = codegen.update(cx, |this, _cx| { - // Store the failure message as the tool description - this.model_explanation = Some(input.message.into()); - }); - finish_with_status(CodegenStatus::Done, cx); - return; + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + } + Ok(e) => { + log::warn!("Unexpected event: {:?}", e); + break; } Err(e) => { finish_with_status(CodegenStatus::Error(e.into()), cx); - return; + break; } } } - Ok(_tool_use) => { - // Unexpected tool. - finish_with_status(CodegenStatus::Done, cx); - return; - } - Err(e) => { - finish_with_status(CodegenStatus::Error(e.into()), cx); - return; - } } - }); - cx.notify(); + + let Some(first_text) = first_text else { + finish_with_status(CodegenStatus::Done, cx); + return; + }; + + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded(); + + cx.spawn({ + let codegen = codegen.clone(); + async move |cx| { + while let Some(message) = message_rx.next().await { + let _ = codegen.update(cx, |this, _cx| { + this.model_explanation = message; + }); + } + } + }) + .detach(); + + let move_last_token_usage = last_token_usage.clone(); + + let text_stream = Box::pin(futures::stream::once(async { Ok(first_text) }).chain( + completion_events.filter_map(move |e| { + let tool_to_text_and_message = tool_to_text_and_message.clone(); + let last_token_usage = move_last_token_usage.clone(); + let total_text = total_text.clone(); + let mut message_tx = message_tx.clone(); + async move { + match e { + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) + if matches!( + tool_use.name.as_ref(), + "rewrite_section" | "failure_message" + ) => + { + let is_complete = tool_use.is_input_complete; + let (text, message) = tool_to_text_and_message(tool_use); + if is_complete { + // Again only send the message when complete to not get a bouncing UI element. + let _ = message_tx.send(message.map(Into::into)).await; + } + text.map(Ok) + } + Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { + *last_token_usage.lock() = token_usage; + None + } + Ok(LanguageModelCompletionEvent::Text(text)) => { + let mut lock = total_text.lock(); + lock.push_str(&text); + None + } + Ok(LanguageModelCompletionEvent::Stop(_reason)) => None, + e => { + log::error!("UNEXPECTED EVENT {:?}", e); + None + } + } + } + }), + )); + + let language_model_text_stream = LanguageModelTextStream { + message_id: message_id, + stream: text_stream, + last_token_usage, + }; + + let Some(task) = codegen + .update(cx, move |codegen, cx| { + codegen.handle_stream( + telemetry_id, + provider_id, + api_key, + async { Ok(language_model_text_stream) }, + cx, + ) + }) + .ok() + else { + return; + }; + + task.await; + }) } } @@ -1679,7 +1811,7 @@ mod tests { ) -> mpsc::UnboundedSender { let (chunks_tx, chunks_rx) = mpsc::unbounded(); codegen.update(cx, |codegen, cx| { - codegen.handle_stream( + codegen.generation = codegen.handle_stream( String::new(), String::new(), None, diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 48da85d38554da8227d76d3cbe290e29ef4fc531..ad0f58c162ca720e619e83ca9a3eb65a4be9fe2b 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1455,60 +1455,8 @@ impl InlineAssistant { let old_snapshot = codegen.snapshot(cx); let old_buffer = codegen.old_buffer(cx); let deleted_row_ranges = codegen.diff(cx).deleted_row_ranges.clone(); - // let model_explanation = codegen.model_explanation(cx); editor.update(cx, |editor, cx| { - // Update tool description block - // if let Some(description) = model_explanation { - // if let Some(block_id) = decorations.model_explanation { - // editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - // let new_block_id = editor.insert_blocks( - // [BlockProperties { - // style: BlockStyle::Flex, - // placement: BlockPlacement::Below(assist.range.end), - // height: Some(1), - // render: Arc::new({ - // let description = description.clone(); - // move |cx| { - // div() - // .w_full() - // .py_1() - // .px_2() - // .bg(cx.theme().colors().editor_background) - // .border_y_1() - // .border_color(cx.theme().status().info_border) - // .child( - // Label::new(description.clone()) - // .color(Color::Muted) - // .size(LabelSize::Small), - // ) - // .into_any_element() - // } - // }), - // priority: 0, - // }], - // None, - // cx, - // ); - // decorations.model_explanation = new_block_id.into_iter().next(); - // } - // } else if let Some(block_id) = decorations.model_explanation { - // // Hide the block if there's no description - // editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - // let new_block_id = editor.insert_blocks( - // [BlockProperties { - // style: BlockStyle::Flex, - // placement: BlockPlacement::Below(assist.range.end), - // height: Some(0), - // render: Arc::new(|_cx| div().into_any_element()), - // priority: 0, - // }], - // None, - // cx, - // ); - // decorations.model_explanation = new_block_id.into_iter().next(); - // } - let old_blocks = mem::take(&mut decorations.removed_line_block_ids); editor.remove_blocks(old_blocks, None, cx); diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 09b293b122624274b7484026f35d1bcc8e265ece..e976b7f5dc36905d2a32b4cdc04869f3267705fe 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -429,10 +429,24 @@ impl Model { let mut headers = vec![]; match self { + Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_5 + | Self::ClaudeSonnet4 + | Self::ClaudeSonnet4_5 + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking + | Self::ClaudeOpus4_5Thinking + | Self::ClaudeSonnet4Thinking + | Self::ClaudeSonnet4_5Thinking => { + // Fine-grained tool streaming for newer models + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); + } Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => { // Try beta token-efficient tool use (supported in Claude 3.7 Sonnet only) // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use headers.push("token-efficient-tools-2025-02-19".to_string()); + headers.push("fine-grained-tool-streaming-2025-05-14".to_string()); } Self::Custom { extra_beta_headers, .. diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 61d9a34e38de546c79a2dbb5f889e2fddad38480..566d5604149567702e8739d2f3ac9fdc6f5f0de8 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -12,10 +12,10 @@ impl FeatureFlag for PanicFeatureFlag { const NAME: &'static str = "panic"; } -pub struct InlineAssistantV2FeatureFlag; +pub struct InlineAssistantUseToolFeatureFlag; -impl FeatureFlag for InlineAssistantV2FeatureFlag { - const NAME: &'static str = "inline-assistant-v2"; +impl FeatureFlag for InlineAssistantUseToolFeatureFlag { + const NAME: &'static str = "inline-assistant-use-tool"; fn enabled_for_staff() -> bool { false diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index e158bb256be42291549c2379ae7ec19402166543..09d44b5b408324936af00a2a5e4f1deb4f351434 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -612,6 +612,11 @@ pub trait LanguageModel: Send + Sync { false } + /// Returns whether this model or provider supports streaming tool calls; + fn supports_streaming_tools(&self) -> bool { + false + } + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { LanguageModelToolSchemaFormat::JsonSchema } @@ -766,6 +771,21 @@ pub trait LanguageModelExt: LanguageModel { } impl LanguageModelExt for dyn LanguageModel {} +impl std::fmt::Debug for dyn LanguageModel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("") + .field("id", &self.id()) + .field("name", &self.name()) + .field("provider_id", &self.provider_id()) + .field("provider_name", &self.provider_name()) + .field("upstream_provider_name", &self.upstream_provider_name()) + .field("upstream_provider_id", &self.upstream_provider_id()) + .field("upstream_provider_id", &self.upstream_provider_id()) + .field("supports_streaming_tools", &self.supports_streaming_tools()) + .finish() + } +} + /// An error that occurred when trying to authenticate the language model provider. #[derive(Debug, Error)] pub enum AuthenticateError { diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index f9e1e60cf648d3a67cec425ebd1f09ad7b564665..25ba7615dc23e2561648e173588be6d93c28e295 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -350,6 +350,10 @@ impl LanguageModel for AnthropicModel { true } + fn supports_streaming_tools(&self) -> bool { + true + } + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { LanguageModelToolChoice::Auto diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index a19a427dbacb32883b1877888ec04899a2b8d427..508a77d38abcf2143170382e945ab6ce31f3a623 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -602,6 +602,10 @@ impl LanguageModel for CloudLanguageModel { self.model.supports_images } + fn supports_streaming_tools(&self) -> bool { + self.model.supports_streaming_tools + } + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { match choice { LanguageModelToolChoice::Auto diff --git a/crates/prompt_store/src/prompts.rs b/crates/prompt_store/src/prompts.rs index d6a172218a8eb3d4538363e6202a7e721d2b7bc1..847e45742db17fe194d002c26a67380390b68f06 100644 --- a/crates/prompt_store/src/prompts.rs +++ b/crates/prompt_store/src/prompts.rs @@ -286,7 +286,7 @@ impl PromptBuilder { Ok(()) } - pub fn generate_inline_transformation_prompt_v2( + pub fn generate_inline_transformation_prompt_tools( &self, language_name: Option<&LanguageName>, buffer: BufferSnapshot, diff --git a/crates/settings/src/settings_content/agent.rs b/crates/settings/src/settings_content/agent.rs index 2ea9f0cd5788f3312061ec8ffef2a728403463ac..fccc3e09fceb8e05ad3494101a4d23d95257358e 100644 --- a/crates/settings/src/settings_content/agent.rs +++ b/crates/settings/src/settings_content/agent.rs @@ -36,7 +36,13 @@ pub struct AgentSettingsContent { pub default_model: Option, /// Model to use for the inline assistant. Defaults to default_model when not specified. pub inline_assistant_model: Option, - /// Model to use for generating git commit messages. Defaults to default_model when not specified. + /// Model to use for the inline assistant when streaming tools are enabled. + /// + /// Default: true + pub inline_assistant_use_streaming_tools: Option, + /// Model to use for generating git commit messages. + /// + /// Default: true pub commit_message_model: Option, /// Model to use for generating thread summaries. Defaults to default_model when not specified. pub thread_summary_model: Option, @@ -129,6 +135,9 @@ impl AgentSettingsContent { model, }); } + pub fn set_inline_assistant_use_streaming_tools(&mut self, use_tools: bool) { + self.inline_assistant_use_streaming_tools = Some(use_tools); + } pub fn set_commit_message_model(&mut self, provider: String, model: String) { self.commit_message_model = Some(LanguageModelSelection { From f2cc24c5faa8b104334ba7a42d0db92175b0b51e Mon Sep 17 00:00:00 2001 From: Will Garrison Date: Sun, 14 Dec 2025 07:20:33 +0000 Subject: [PATCH 12/67] docs: Add clarifying note about Vim subword motion (#44535) Clarify the docs regarding how operators are affected when subword motion in Vim is activated. Ref: https://github.com/zed-industries/zed/issues/23344#issuecomment-3186025873. Release Notes: - N/A --------- Co-authored-by: Kunall Banerjee --- docs/src/vim.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/src/vim.md b/docs/src/vim.md index 9ba1b059223f147d73398a1ec91e6d818ff92c8a..09baa9b54f7e1aeb5f16777f4292131315d18928 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -471,7 +471,7 @@ But you cannot use the same shortcuts to move between all the editor docks (the } ``` -Subword motion, which allows you to navigate and select individual words in camelCase or snake_case, is not enabled by default. To enable it, add these bindings to your keymap. +Subword motion, which allows you to navigate and select individual words in `camelCase` or `snake_case`, is not enabled by default. To enable it, add these bindings to your keymap. ```json [settings] { @@ -485,6 +485,9 @@ Subword motion, which allows you to navigate and select individual words in came } ``` +> Note: Operations like `dw` remain unaffected. If you would like operations to +> also use subword motion, remove `vim_mode != operator` from the `context`. + Vim mode comes with shortcuts to surround the selection in normal mode (`ys`), but it doesn't have a shortcut to add surrounds in visual mode. By default, `shift-s` substitutes the selection (erases the text and enters insert mode). To use `shift-s` to add surrounds in visual mode, you can add the following object to your keymap. ```json [settings] From 6cc947f654f27c80bd1f8b2f68a94abd2892fec7 Mon Sep 17 00:00:00 2001 From: John Tur Date: Sun, 14 Dec 2025 02:45:54 -0500 Subject: [PATCH 13/67] Update `cc` and `cmake` crates (#44797) This fixes the build when Visual Studio 2026 is installed. Release Notes: - N/A --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc7f8b0a85fd21dd7cae57e1ffc5348d70defbed..834a072a92ff7334338b018eaecbdf7d71c48cdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2770,9 +2770,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "jobserver", @@ -3113,9 +3113,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" dependencies = [ "cc", ] @@ -6091,9 +6091,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixedbitset" From 00169e0ae21dbc4f6626f3aa03fdf5991a1ac4dc Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Sun, 14 Dec 2025 07:55:19 -0500 Subject: [PATCH 14/67] git: Fix create remote branch (#44805) Fix a bug where the branch picker would be dismissed before completing the add remote flow, thus making Zed unable to add remote repositories through the branch picker. This bug was caused by the picker always being dismissed on the confirm action, so the fix was stopping the branch modal from being dismissed too early. I also cleaned up the UI a bit and code. 1. Removed the loading field from the Branch delegate because it was never used and the activity indicator will show remote add command if it takes a while. 2. I replaced some async task spawning with the use of `cx.defer`. 3. Added a `add remote name` fake entry when the picker is in the name remote state. I did this so the UI would be consistent with the other states. 4. Added two regression tests. 4.1 One to prevent this bug from occurring again: https://github.com/zed-industries/zed/pull/44742 4.2 Another to prevent the early dismissal bug from occurring 5. Made `init_branch_list_test` param order consistent with Zed's code base ###### Updated UI image Release Notes: - N/A --- Cargo.lock | 1 + crates/git_ui/Cargo.toml | 1 + crates/git_ui/src/branch_picker.rs | 379 +++++++++++++++++------------ 3 files changed, 232 insertions(+), 149 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 834a072a92ff7334338b018eaecbdf7d71c48cdc..e465ff483bcb6cc9528403bbb8f3bd883a6af871 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7045,6 +7045,7 @@ dependencies = [ "picker", "pretty_assertions", "project", + "rand 0.9.2", "recent_projects", "remote", "schemars", diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index beaf192b0ef538fb524ff4986710255040b89f27..6747daa09d2801ad8c05c17fb04cb3ab235cdbff 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -74,6 +74,7 @@ gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } +rand.workspace = true settings = { workspace = true, features = ["test-support"] } unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 8a08736d8bace6a77963c4325406d340903f1b73..79cd89d1485f6d99349b43d92c17261cf8a644e2 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -6,7 +6,7 @@ use collections::HashSet; use git::repository::Branch; use gpui::http_client::Url; use gpui::{ - Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; @@ -17,8 +17,8 @@ use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; use ui::{ - CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, - ListItemSpacing, Tooltip, prelude::*, + Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem, ListItemSpacing, Tooltip, + prelude::*, }; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; @@ -232,21 +232,12 @@ impl BranchList { window: &mut Window, cx: &mut Context, ) { - self.picker.update(cx, |this, cx| { - this.delegate.display_remotes = !this.delegate.display_remotes; - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |picker, window, cx| { - let last_query = picker.delegate.last_query.clone(); - picker.delegate.update_matches(last_query, window, cx) - })? - .await; - - Result::Ok::<_, anyhow::Error>(()) - }) - .detach_and_log_err(cx); + self.picker.update(cx, |picker, cx| { + picker.delegate.branch_filter = picker.delegate.branch_filter.invert(); + picker.update_matches(picker.query(cx), window, cx); + picker.refresh_placeholder(window, cx); + cx.notify(); }); - - cx.notify(); } } impl ModalView for BranchList {} @@ -289,6 +280,10 @@ enum Entry { NewBranch { name: String, }, + NewRemoteName { + name: String, + url: SharedString, + }, } impl Entry { @@ -304,6 +299,7 @@ impl Entry { Entry::Branch { branch, .. } => branch.name(), Entry::NewUrl { url, .. } => url.as_str(), Entry::NewBranch { name, .. } => name.as_str(), + Entry::NewRemoteName { name, .. } => name.as_str(), } } @@ -318,6 +314,23 @@ impl Entry { } } +#[derive(Clone, Copy, PartialEq)] +enum BranchFilter { + /// Only show local branches + Local, + /// Only show remote branches + Remote, +} + +impl BranchFilter { + fn invert(&self) -> Self { + match self { + BranchFilter::Local => BranchFilter::Remote, + BranchFilter::Remote => BranchFilter::Local, + } + } +} + pub struct BranchListDelegate { workspace: Option>, matches: Vec, @@ -328,9 +341,8 @@ pub struct BranchListDelegate { selected_index: usize, last_query: String, modifiers: Modifiers, - display_remotes: bool, + branch_filter: BranchFilter, state: PickerState, - loading: bool, focus_handle: FocusHandle, } @@ -363,9 +375,8 @@ impl BranchListDelegate { selected_index: 0, last_query: Default::default(), modifiers: Default::default(), - display_remotes: false, + branch_filter: BranchFilter::Local, state: PickerState::List, - loading: false, focus_handle: cx.focus_handle(), } } @@ -406,37 +417,13 @@ impl BranchListDelegate { let Some(repo) = self.repo.clone() else { return; }; - cx.spawn(async move |this, cx| { - this.update(cx, |picker, cx| { - picker.delegate.loading = true; - cx.notify(); - }) - .log_err(); - let stop_loader = |this: &WeakEntity>, cx: &mut AsyncApp| { - this.update(cx, |picker, cx| { - picker.delegate.loading = false; - cx.notify(); - }) - .log_err(); - }; - repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)) - .inspect_err(|_err| { - stop_loader(&this, cx); - })? - .await - .inspect_err(|_err| { - stop_loader(&this, cx); - })? - .inspect_err(|_err| { - stop_loader(&this, cx); - })?; - stop_loader(&this, cx); - Ok(()) - }) - .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { - Some(e.to_string()) - }); + let receiver = repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)); + + cx.background_spawn(async move { receiver.await? }) + .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { + Some(e.to_string()) + }); cx.emit(DismissEvent); } @@ -528,29 +515,33 @@ impl PickerDelegate for BranchListDelegate { type ListItem = ListItem; fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - "Select branch…".into() + match self.state { + PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { + match self.branch_filter { + BranchFilter::Local => "Select branch…", + BranchFilter::Remote => "Select remote…", + } + } + PickerState::CreateRemote(_) => "Enter a name for this remote…", + } + .into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + match self.state { + PickerState::CreateRemote(_) => { + Some(SharedString::new_static("Remote name can't be empty")) + } + _ => None, + } } fn render_editor( &self, editor: &Entity, - window: &mut Window, - cx: &mut Context>, + _window: &mut Window, + _cx: &mut Context>, ) -> Div { - cx.update_entity(editor, move |editor, cx| { - let placeholder = match self.state { - PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { - if self.display_remotes { - "Select remote…" - } else { - "Select branch…" - } - } - PickerState::CreateRemote(_) => "Choose a name…", - }; - editor.set_placeholder_text(placeholder, window, cx); - }); - let focus_handle = self.focus_handle.clone(); v_flex() @@ -568,16 +559,14 @@ impl PickerDelegate for BranchListDelegate { .when( self.editor_position() == PickerEditorPosition::End, |this| { - let tooltip_label = if self.display_remotes { - "Turn Off Remote Filter" - } else { - "Filter Remote Branches" + let tooltip_label = match self.branch_filter { + BranchFilter::Local => "Turn Off Remote Filter", + BranchFilter::Remote => "Filter Remote Branches", }; this.gap_1().justify_between().child({ IconButton::new("filter-remotes", IconName::Filter) - .disabled(self.loading) - .toggle_state(self.display_remotes) + .toggle_state(self.branch_filter == BranchFilter::Remote) .tooltip(move |_, cx| { Tooltip::for_action_in( tooltip_label, @@ -636,13 +625,13 @@ impl PickerDelegate for BranchListDelegate { return Task::ready(()); }; - let display_remotes = self.display_remotes; + let display_remotes = self.branch_filter; cx.spawn_in(window, async move |picker, cx| { let mut matches: Vec = if query.is_empty() { all_branches .into_iter() .filter(|branch| { - if display_remotes { + if display_remotes == BranchFilter::Remote { branch.is_remote() } else { !branch.is_remote() @@ -657,7 +646,7 @@ impl PickerDelegate for BranchListDelegate { let branches = all_branches .iter() .filter(|branch| { - if display_remotes { + if display_remotes == BranchFilter::Remote { branch.is_remote() } else { !branch.is_remote() @@ -688,11 +677,19 @@ impl PickerDelegate for BranchListDelegate { }; picker .update(cx, |picker, _| { - if matches!(picker.delegate.state, PickerState::CreateRemote(_)) { + if let PickerState::CreateRemote(url) = &picker.delegate.state { + let query = query.replace(' ', "-"); + if !query.is_empty() { + picker.delegate.matches = vec![Entry::NewRemoteName { + name: query.clone(), + url: url.clone(), + }]; + picker.delegate.selected_index = 0; + } else { + picker.delegate.matches = Vec::new(); + picker.delegate.selected_index = 0; + } picker.delegate.last_query = query; - picker.delegate.matches = Vec::new(); - picker.delegate.selected_index = 0; - return; } @@ -736,13 +733,6 @@ impl PickerDelegate for BranchListDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - if let PickerState::CreateRemote(remote_url) = &self.state { - self.create_remote(self.last_query.clone(), remote_url.to_string(), window, cx); - self.state = PickerState::List; - cx.notify(); - return; - } - let Some(entry) = self.matches.get(self.selected_index()) else { return; }; @@ -785,13 +775,19 @@ impl PickerDelegate for BranchListDelegate { self.state = PickerState::CreateRemote(url.clone().into()); self.matches = Vec::new(); self.selected_index = 0; - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |picker, window, cx| { - picker.set_query("", window, cx); - }) - }) - .detach_and_log_err(cx); - cx.notify(); + + cx.defer_in(window, |picker, window, cx| { + picker.refresh_placeholder(window, cx); + picker.set_query("", window, cx); + cx.notify(); + }); + + // returning early to prevent dismissing the modal, so a user can enter + // a remote name first. + return; + } + Entry::NewRemoteName { name, url } => { + self.create_remote(name.clone(), url.to_string(), window, cx); } Entry::NewBranch { name } => { let from_branch = if secondary { @@ -842,17 +838,13 @@ impl PickerDelegate for BranchListDelegate { .unwrap_or_else(|| (None, None, None)); let entry_icon = match entry { - Entry::NewUrl { .. } | Entry::NewBranch { .. } => { + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } => { Icon::new(IconName::Plus).color(Color::Muted) } - - Entry::Branch { .. } => { - if self.display_remotes { - Icon::new(IconName::Screen).color(Color::Muted) - } else { - Icon::new(IconName::GitBranchAlt).color(Color::Muted) - } - } + Entry::Branch { .. } => match self.branch_filter { + BranchFilter::Local => Icon::new(IconName::GitBranchAlt).color(Color::Muted), + BranchFilter::Remote => Icon::new(IconName::Screen).color(Color::Muted), + }, }; let entry_title = match entry { @@ -864,6 +856,10 @@ impl PickerDelegate for BranchListDelegate { .single_line() .truncate() .into_any_element(), + Entry::NewRemoteName { name, .. } => Label::new(format!("Create Remote: \"{name}\"")) + .single_line() + .truncate() + .into_any_element(), Entry::Branch { branch, positions } => { HighlightedLabel::new(branch.name().to_string(), positions.clone()) .single_line() @@ -873,7 +869,10 @@ impl PickerDelegate for BranchListDelegate { }; let focus_handle = self.focus_handle.clone(); - let is_new_items = matches!(entry, Entry::NewUrl { .. } | Entry::NewBranch { .. }); + let is_new_items = matches!( + entry, + Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } + ); let delete_branch_button = IconButton::new("delete", IconName::Trash) .tooltip(move |_, cx| { @@ -935,6 +934,9 @@ impl PickerDelegate for BranchListDelegate { Entry::NewUrl { url } => { format!("Based off {url}") } + Entry::NewRemoteName { url, .. } => { + format!("Based off {url}") + } Entry::NewBranch { .. } => { if let Some(current_branch) = self.repo.as_ref().and_then(|repo| { @@ -1033,10 +1035,9 @@ impl PickerDelegate for BranchListDelegate { _cx: &mut Context>, ) -> Option { matches!(self.state, PickerState::List).then(|| { - let label = if self.display_remotes { - "Remote" - } else { - "Local" + let label = match self.branch_filter { + BranchFilter::Local => "Local", + BranchFilter::Remote => "Remote", }; ListHeader::new(label).inset(true).into_any_element() @@ -1047,11 +1048,7 @@ impl PickerDelegate for BranchListDelegate { if self.editor_position() == PickerEditorPosition::End { return None; } - let focus_handle = self.focus_handle.clone(); - let loading_icon = Icon::new(IconName::LoadCircle) - .size(IconSize::Small) - .with_rotate_animation(3); let footer_container = || { h_flex() @@ -1090,7 +1087,6 @@ impl PickerDelegate for BranchListDelegate { .gap_1() .child( Button::new("delete-branch", "Delete") - .disabled(self.loading) .key_binding( KeyBinding::for_action_in( &branch_picker::DeleteBranch, @@ -1138,17 +1134,15 @@ impl PickerDelegate for BranchListDelegate { ) }, ) - } else if self.loading { - this.justify_between() - .child(loading_icon) - .child(delete_and_select_btns) } else { this.justify_between() .child({ let focus_handle = focus_handle.clone(); Button::new("filter-remotes", "Filter Remotes") - .disabled(self.loading) - .toggle_state(self.display_remotes) + .toggle_state(matches!( + self.branch_filter, + BranchFilter::Remote + )) .key_binding( KeyBinding::for_action_in( &branch_picker::FilterRemotes, @@ -1213,14 +1207,15 @@ impl PickerDelegate for BranchListDelegate { footer_container() .justify_end() .child( - Label::new("Choose a name for this remote repository") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new("Save") - .size(LabelSize::Small) - .color(Color::Muted), + Button::new("branch-from-default", "Confirm") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.delegate.confirm(false, window, cx); + })) + .disabled(self.last_query.is_empty()), ) .into_any_element(), ), @@ -1237,6 +1232,7 @@ mod tests { use git::repository::{CommitSummary, Remote}; use gpui::{TestAppContext, VisualTestContext}; use project::{FakeFs, Project}; + use rand::{Rng, rngs::StdRng}; use serde_json::json; use settings::SettingsStore; use util::path; @@ -1284,10 +1280,10 @@ mod tests { } fn init_branch_list_test( - cx: &mut TestAppContext, repository: Option>, branches: Vec, - ) -> (VisualTestContext, Entity) { + cx: &mut TestAppContext, + ) -> (Entity, VisualTestContext) { let window = cx.add_window(|window, cx| { let mut delegate = BranchListDelegate::new(None, repository, BranchListStyle::Modal, cx); @@ -1313,7 +1309,7 @@ mod tests { let branch_list = window.root(cx).unwrap(); let cx = VisualTestContext::from_window(*window, cx); - (cx, branch_list) + (branch_list, cx) } async fn init_fake_repository(cx: &mut TestAppContext) -> Entity { @@ -1347,7 +1343,7 @@ mod tests { init_test(cx); let branches = create_test_branches(); - let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); let cx = &mut ctx; branch_list @@ -1423,7 +1419,7 @@ mod tests { .await; cx.run_until_parked(); - let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; @@ -1488,12 +1484,12 @@ mod tests { .await; cx.run_until_parked(); - let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); let cx = &mut ctx; // Enable remote filter branch_list.update(cx, |branch_list, cx| { branch_list.picker.update(cx, |picker, _cx| { - picker.delegate.display_remotes = true; + picker.delegate.branch_filter = BranchFilter::Remote; }); }); update_branch_list_matches_with_empty_query(&branch_list, cx).await; @@ -1546,7 +1542,7 @@ mod tests { create_test_branch("develop", false, None, Some(700)), ]; - let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); let cx = &mut ctx; update_branch_list_matches_with_empty_query(&branch_list, cx).await; @@ -1573,7 +1569,7 @@ mod tests { let last_match = picker.delegate.matches.last().unwrap(); assert!(!last_match.is_new_branch()); assert!(!last_match.is_new_url()); - picker.delegate.display_remotes = true; + picker.delegate.branch_filter = BranchFilter::Remote; picker.delegate.update_matches(String::new(), window, cx) }) }) @@ -1600,7 +1596,7 @@ mod tests { // Verify the last entry is NOT the "create new branch" option let last_match = picker.delegate.matches.last().unwrap(); assert!(!last_match.is_new_url()); - picker.delegate.display_remotes = true; + picker.delegate.branch_filter = BranchFilter::Remote; picker .delegate .update_matches(String::from("fork"), window, cx) @@ -1629,22 +1625,27 @@ mod tests { #[gpui::test] async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) { + const MAIN_BRANCH: &str = "main"; + const FEATURE_BRANCH: &str = "feature"; + const NEW_BRANCH: &str = "new-feature-branch"; + init_test(test_cx); let repository = init_fake_repository(test_cx).await; let branches = vec![ - create_test_branch("main", true, None, Some(1000)), - create_test_branch("feature", false, None, Some(900)), + create_test_branch(MAIN_BRANCH, true, None, Some(1000)), + create_test_branch(FEATURE_BRANCH, false, None, Some(900)), ]; - let (mut ctx, branch_list) = init_branch_list_test(test_cx, repository.into(), branches); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, test_cx); let cx = &mut ctx; branch_list .update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { - let query = "new-feature-branch".to_string(); - picker.delegate.update_matches(query, window, cx) + picker + .delegate + .update_matches(NEW_BRANCH.to_string(), window, cx) }) }) .await; @@ -1655,7 +1656,7 @@ mod tests { branch_list.picker.update(cx, |picker, cx| { let last_match = picker.delegate.matches.last().unwrap(); assert!(last_match.is_new_branch()); - assert_eq!(last_match.name(), "new-feature-branch"); + assert_eq!(last_match.name(), NEW_BRANCH); // State is NewBranch because no existing branches fuzzy-match the query assert!(matches!(picker.delegate.state, PickerState::NewBranch)); picker.delegate.confirm(false, window, cx); @@ -1680,11 +1681,11 @@ mod tests { let new_branch = branches .into_iter() - .find(|branch| branch.name() == "new-feature-branch") + .find(|branch| branch.name() == NEW_BRANCH) .expect("new-feature-branch should exist"); assert_eq!( new_branch.ref_name.as_ref(), - "refs/heads/new-feature-branch", + &format!("refs/heads/{NEW_BRANCH}"), "branch ref_name should not have duplicate refs/heads/ prefix" ); } @@ -1695,7 +1696,7 @@ mod tests { let repository = init_fake_repository(cx).await; let branches = vec![create_test_branch("main", true, None, Some(1000))]; - let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx); let cx = &mut ctx; branch_list @@ -1734,8 +1735,13 @@ mod tests { branch_list.update_in(cx, |branch_list, window, cx| { branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 1); + assert!(matches!( + picker.delegate.matches.first(), + Some(Entry::NewRemoteName { name, url }) + if name == "my_new_remote" && url.as_ref() == "https://github.com/user/repo.git" + )); picker.delegate.confirm(false, window, cx); - assert_eq!(picker.delegate.matches.len(), 0); }) }); cx.run_until_parked(); @@ -1768,7 +1774,7 @@ mod tests { init_test(cx); let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; - let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); let cx = &mut ctx; branch_list @@ -1823,4 +1829,79 @@ mod tests { }) }); } + + #[gpui::test] + async fn test_confirm_remote_url_does_not_dismiss(cx: &mut TestAppContext) { + const REMOTE_URL: &str = "https://github.com/user/repo.git"; + + init_test(cx); + let branches = vec![create_test_branch("main", true, None, Some(1000))]; + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let cx = &mut ctx; + + let subscription = cx.update(|_, cx| { + cx.subscribe(&branch_list, |_, _: &DismissEvent, _| { + panic!("DismissEvent should not be emitted when confirming a remote URL"); + }) + }); + + branch_list + .update_in(cx, |branch_list, window, cx| { + window.focus(&branch_list.picker_focus_handle); + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches(REMOTE_URL.to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_url()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + + picker.delegate.confirm(false, window, cx); + + assert!( + matches!(picker.delegate.state, PickerState::CreateRemote(ref url) if url.as_ref() == REMOTE_URL), + "State should transition to CreateRemote with the URL" + ); + }); + + assert!( + branch_list.picker_focus_handle.is_focused(window), + "Branch list picker should still be focused after confirming remote URL" + ); + }); + + cx.run_until_parked(); + + drop(subscription); + } + + #[gpui::test(iterations = 10)] + async fn test_empty_query_displays_all_branches(mut rng: StdRng, cx: &mut TestAppContext) { + init_test(cx); + let branch_count = rng.random_range(13..540); + + let branches: Vec = (0..branch_count) + .map(|i| create_test_branch(&format!("branch-{:02}", i), i == 0, None, Some(i * 100))) + .collect(); + + let (branch_list, mut ctx) = init_branch_list_test(None, branches, cx); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), branch_count as usize); + }) + }); + } } From e9073eceeba8e1a71e966582566179517591ec6b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sun, 14 Dec 2025 10:48:23 -0300 Subject: [PATCH 15/67] agent_ui: Fix fallback icon used for external agents (#44777) When an external agent doesn't provide an icon, we were using different fallback icons in all the places we display icons (settings view, thread new menu, and the thread view toolbar itself). Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 3 ++- crates/agent_ui/src/agent_panel.rs | 21 +++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 8619b085c00268d6d157dee37411ff36ba4d5680..24f019c605d1b167e62a6e68dfc1f3ed07c73f1c 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -975,7 +975,7 @@ impl AgentConfiguration { let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) { AgentIcon::Path(icon_path) } else { - AgentIcon::Name(IconName::Ai) + AgentIcon::Name(IconName::Sparkle) }; let display_name = agent_server_store .agent_display_name(&name) @@ -1137,6 +1137,7 @@ impl AgentConfiguration { ) -> impl IntoElement { let id = id.into(); let display_name = display_name.into(); + let icon = match icon { AgentIcon::Name(icon_name) => Icon::new(icon_name) .size(IconSize::Small) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 2f6a722b471a189eafbc7aadbddb927476e4b3b9..97c7aecb8e34563db0adfa6bdbeda31140fd6cdd 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -259,7 +259,7 @@ impl AgentType { Self::Gemini => Some(IconName::AiGemini), Self::ClaudeCode => Some(IconName::AiClaude), Self::Codex => Some(IconName::AiOpenAi), - Self::Custom { .. } => Some(IconName::Terminal), + Self::Custom { .. } => Some(IconName::Sparkle), } } } @@ -1851,14 +1851,17 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); - // Get custom icon path for selected agent before building menu (to avoid borrow issues) - let selected_agent_custom_icon = + let (selected_agent_custom_icon, selected_agent_label) = if let AgentType::Custom { name, .. } = &self.selected_agent { - agent_server_store - .read(cx) - .agent_icon(&ExternalAgentServerName(name.clone())) + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&ExternalAgentServerName(name.clone())); + + let label = store + .agent_display_name(&ExternalAgentServerName(name.clone())) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) } else { - None + (None, self.selected_agent.label()) }; let active_thread = match &self.active_view { @@ -2090,7 +2093,7 @@ impl AgentPanel { if let Some(icon_path) = icon_path { entry = entry.custom_icon_svg(icon_path); } else { - entry = entry.icon(IconName::Terminal); + entry = entry.icon(IconName::Sparkle); } entry = entry .when( @@ -2154,8 +2157,6 @@ impl AgentPanel { } }); - let selected_agent_label = self.selected_agent.label(); - let is_thread_loading = self .active_thread_view() .map(|thread| thread.read(cx).is_loading()) From 13594bd97ec1250d49d99c3587575607837bad97 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Sun, 14 Dec 2025 19:01:22 +0100 Subject: [PATCH 16/67] keymap: More default keymap fixes for windows/linux (#44821) Release Notes: - N/A *or* Added/Fixed/Improved ... --- assets/keymaps/default-linux.json | 2 +- assets/keymaps/default-windows.json | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0bcbb455b502642237347cf9fc36b91eab83f20b..872544cdff0bc03bbafd6b711fa7adb2f5e2d008 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -63,7 +63,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -501,6 +500,7 @@ "ctrl-k ctrl-i": "editor::Hover", "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 51943ab35587e633a25eb9420c45dff21048330a..ae051f233e344cc6b961612c690ae1b5107fb2c0 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -63,7 +63,6 @@ "delete": "editor::Delete", "tab": "editor::Tab", "shift-tab": "editor::Backtab", - "ctrl-k": "editor::CutToEndOfLine", "ctrl-k ctrl-q": "editor::Rewrap", "ctrl-k q": "editor::Rewrap", "ctrl-backspace": ["editor::DeleteToPreviousWordStart", { "ignore_newlines": false, "ignore_brackets": false }], @@ -465,8 +464,10 @@ "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", "back": "pane::GoBack", "alt--": "pane::GoBack", + "alt-left": "pane::GoBack", "forward": "pane::GoForward", "alt-=": "pane::GoForward", + "alt-right": "pane::GoForward", "f3": "search::SelectNextMatch", "shift-f3": "search::SelectPreviousMatch", "ctrl-shift-f": "project_search::ToggleFocus", @@ -508,6 +509,7 @@ "ctrl-k ctrl-b": "editor::BlameHover", "ctrl-k ctrl-f": "editor::FormatSelections", "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "ctrl-k ctrl-c": ["editor::ToggleComments", { "advance_downwards": false }], "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], "f2": "editor::Rename", From f80ef9a3c52a0d07bc3db536f853b6e0083dfdd3 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Sun, 14 Dec 2025 19:21:50 +0100 Subject: [PATCH 17/67] editor: Fix inlay hovers blinking in sync with cursors (#44822) This change matches how normal hovers are handled (which early return with `None` in this branch) Release Notes: - Fixed hover boxes for inlays blinking in and out without movement when cursor blinking was enabled --- crates/editor/src/hover_popover.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index edf10671b9e4c63e2918f6e144ba1b553e44daca..7c3e41e8c2edf721fbcae729069eecb640e2246c 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -151,7 +151,7 @@ pub fn hover_at_inlay( false }) { - hide_hover(editor, cx); + return; } let hover_popover_delay = EditorSettings::get_global(cx).hover_popover_delay.0; From 26b261a33645f20993fd8f819109b37cabcdc67c Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 14 Dec 2025 11:47:15 -0700 Subject: [PATCH 18/67] Implement Sum trait for Pixels (#44809) This adds implementations of `std::iter::Sum` for `Pixels`, allowing the use of `.sum()` on iterators of `Pixels` values. ### Changes - Implement `Sum` for `Pixels` (owned values) - Implement `Sum<&Pixels>` for `Pixels` (references) This enables ergonomic patterns like: ```rust let total: Pixels = pixel_values.iter().sum(); ``` --- crates/gpui/src/geometry.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index f466624dfb91af9b4a33421ea15827ebe2559665..fc735ba5e0e7e719ed12b6b1b168ec3ee49e22bb 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -2648,6 +2648,18 @@ impl Debug for Pixels { } } +impl std::iter::Sum for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + b) + } +} + +impl<'a> std::iter::Sum<&'a Pixels> for Pixels { + fn sum>(iter: I) -> Self { + iter.fold(Self::ZERO, |a, b| a + *b) + } +} + impl TryFrom<&'_ str> for Pixels { type Error = anyhow::Error; From a51585d2daaa975409a835f43574c8bb5bcc9d5b Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Sun, 14 Dec 2025 12:58:26 -0700 Subject: [PATCH 19/67] Fix race condition in test_collaborating_with_completion (#44806) The test `test_collaborating_with_completion` has a latent race condition that hasn't manifested on CI yet but could cause hangs with certain task orderings. ## The Bug Commit `fd1494c31a` set up LSP request handlers AFTER typing the trigger character: ```rust // Type trigger first - spawns async tasks to send completion request editor_b.update_in(cx_b, |editor, window, cx| { editor.handle_input(".", window, cx); }); // THEN set up handlers (race condition!) fake_language_server .set_request_handler::(...) .next().await.unwrap(); // Waits for handler to receive a request ``` Whether this works depends on task scheduling order, which varies by seed. If the completion request is processed before the handler is registered, the request goes to `on_unhandled_notification` which claims to handle it but sends no response, causing a hang. ## Changes - Move handler setup BEFORE typing the trigger character - Make `TestDispatcher::spawn_realtime` panic to prevent future non-determinism from real OS threads - Add `execution_hash()` and `execution_count()` to TestDispatcher for debugging - Add `DEBUG_SCHEDULER=1` logging for task execution tracing - Document the investigation in `situation.md` cc @localcc @SomeoneToIgnore (authors of related commits) Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- crates/collab/src/tests/editor_tests.rs | 109 ++++++++++++------------ 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index ba92e868126c7f27fb5051021fce44fe43c8d5e7..4e6cdb0e79aba494bd01137cc262a097a084217e 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -312,6 +312,49 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu "Rust", FakeLspAdapter { capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri, + lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + ); + assert_eq!( + params.text_document_position.position, + lsp::Position::new(0, 14), + ); + + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "first_method(…)".into(), + detail: Some("fn(&mut self, B) -> C".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "first_method($1)".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + lsp::CompletionItem { + label: "second_method(…)".into(), + detail: Some("fn(&mut self, C) -> D".into()), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + new_text: "second_method()".to_string(), + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 14), + ), + })), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + ..Default::default() + }, + ]))) + }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -320,6 +363,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu FakeLspAdapter { name: "fake-analyzer", capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_server| { + fake_server.set_request_handler::( + |_, _| async move { Ok(None) }, + ); + })), ..FakeLspAdapter::default() }, ), @@ -373,6 +421,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu let fake_language_server = fake_language_servers[0].next().await.unwrap(); let second_fake_language_server = fake_language_servers[1].next().await.unwrap(); cx_a.background_executor.run_until_parked(); + cx_b.background_executor.run_until_parked(); buffer_b.read_with(cx_b, |buffer, _| { assert!(!buffer.completion_triggers().is_empty()) @@ -387,58 +436,9 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu }); cx_b.focus(&editor_b); - // Receive a completion request as the host's language server. - // Return some completions from the host's language server. - cx_a.executor().start_waiting(); - fake_language_server - .set_request_handler::(|params, _| async move { - assert_eq!( - params.text_document_position.text_document.uri, - lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), - ); - assert_eq!( - params.text_document_position.position, - lsp::Position::new(0, 14), - ); - - Ok(Some(lsp::CompletionResponse::Array(vec![ - lsp::CompletionItem { - label: "first_method(…)".into(), - detail: Some("fn(&mut self, B) -> C".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "first_method($1)".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - lsp::CompletionItem { - label: "second_method(…)".into(), - detail: Some("fn(&mut self, C) -> D".into()), - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - new_text: "second_method()".to_string(), - range: lsp::Range::new( - lsp::Position::new(0, 14), - lsp::Position::new(0, 14), - ), - })), - insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() - }, - ]))) - }) - .next() - .await - .unwrap(); - second_fake_language_server - .set_request_handler::(|_, _| async move { Ok(None) }) - .next() - .await - .unwrap(); - cx_a.executor().finish_waiting(); + // Allow the completion request to propagate from guest to host to LSP. + cx_b.background_executor.run_until_parked(); + cx_a.background_executor.run_until_parked(); // Open the buffer on the host. let buffer_a = project_a @@ -484,6 +484,7 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu // The additional edit is applied. cx_a.executor().run_until_parked(); + cx_b.executor().run_until_parked(); buffer_a.read_with(cx_a, |buffer, _| { assert_eq!( @@ -641,13 +642,11 @@ async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mu ), })), insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), - ..Default::default() + ..lsp::CompletionItem::default() }, ]))) }); - cx_b.executor().run_until_parked(); - // Await both language server responses first_lsp_completion.next().await.unwrap(); second_lsp_completion.next().await.unwrap(); From 86aa9abc9036fa94cf2a98aefbefb1d7c71ad699 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sun, 14 Dec 2025 21:48:15 -0500 Subject: [PATCH 20/67] git: Avoid removing project excerpts for dirty buffers (#44312) Imitating the approach of #41829. Prevents e.g. reverting a hunk and having that excerpt yanked out from under the cursor. Release Notes: - git: Improved stability of excerpts when editing in the project diff. --- crates/acp_thread/src/diff.rs | 2 +- crates/agent_ui/src/agent_diff.rs | 7 +- crates/editor/src/editor.rs | 40 +--------- crates/editor/src/items.rs | 1 - crates/git_ui/src/project_diff.rs | 114 +++++++++++++++++++++------- crates/multi_buffer/src/path_key.rs | 15 ++-- 6 files changed, 104 insertions(+), 75 deletions(-) diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index f17e9d0fce404483ae99efc95bf666586c1f644b..cae1aad90810c217324659d29c065af443494933 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -166,7 +166,7 @@ impl Diff { } pub fn has_revealed_range(&self, cx: &App) -> bool { - self.multibuffer().read(cx).excerpt_paths().next().is_some() + self.multibuffer().read(cx).paths().next().is_some() } pub fn needs_update(&self, old_text: &str, new_text: &str, cx: &App) -> bool { diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 11acd649ef9df500edf99926e754228e4c41e7bc..06fce64819d3ce66b9e39f2b83cbebefb6ba9698 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -130,7 +130,12 @@ impl AgentDiffPane { .action_log() .read(cx) .changed_buffers(cx); - let mut paths_to_delete = self.multibuffer.read(cx).paths().collect::>(); + let mut paths_to_delete = self + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); for (buffer, diff_handle) in changed_buffers { if buffer.read(cx).file().is_none() { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cddb20d83e0b9066fcfd882aa5325624cbadf92e..923b5dc1540d93bd849f5a50a8d51052f79f93a0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -22956,10 +22956,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) { - let workspace = self.workspace(); - let project = self.project(); - let save_tasks = self.buffer().update(cx, |multi_buffer, cx| { - let mut tasks = Vec::new(); + self.buffer().update(cx, |multi_buffer, cx| { for (buffer_id, changes) in revert_changes { if let Some(buffer) = multi_buffer.buffer(buffer_id) { buffer.update(cx, |buffer, cx| { @@ -22971,44 +22968,9 @@ impl Editor { cx, ); }); - - if let Some(project) = - project.filter(|_| multi_buffer.all_diff_hunks_expanded()) - { - project.update(cx, |project, cx| { - tasks.push((buffer.clone(), project.save_buffer(buffer, cx))); - }) - } } } - tasks }); - cx.spawn_in(window, async move |_, cx| { - for (buffer, task) in save_tasks { - let result = task.await; - if result.is_err() { - let Some(path) = buffer - .read_with(cx, |buffer, cx| buffer.project_path(cx)) - .ok() - else { - continue; - }; - if let Some((workspace, path)) = workspace.as_ref().zip(path) { - let Some(task) = cx - .update_window_entity(workspace, |workspace, window, cx| { - workspace - .open_path_preview(path, None, false, false, false, window, cx) - }) - .ok() - else { - continue; - }; - task.await.log_err(); - } - } - } - }) - .detach(); self.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { selections.refresh() }); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 3b9c17f80f10116f2302bab203966922cbf0bcb2..cfbb7c975c844f08d76a5568f1e02dfe3d7d74f1 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -842,7 +842,6 @@ impl Item for Editor { .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone())) .collect::>(); - // let mut buffers_to_save = let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave { buffers } else { diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index f40d70da6494cf8491c1d3d7909a288e5f99023c..3f689567327e280f7e9911699e10159340ddb8d5 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -74,6 +74,13 @@ pub struct ProjectDiff { _subscription: Subscription, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefreshReason { + DiffChanged, + StatusesChanged, + EditorSaved, +} + const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; @@ -278,7 +285,7 @@ impl ProjectDiff { BranchDiffEvent::FileListChanged => { this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }) } }, @@ -297,7 +304,7 @@ impl ProjectDiff { this._task = { window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }) } } @@ -308,7 +315,7 @@ impl ProjectDiff { let task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::StatusesChanged, cx).await }); Self { @@ -448,19 +455,27 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - if let EditorEvent::SelectionsChanged { local: true } = event { - let Some(project_path) = self.active_path(cx) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - if let Some(git_panel) = workspace.panel::(cx) { - git_panel.update(cx, |git_panel, cx| { - git_panel.select_entry_by_path(project_path, window, cx) - }) - } - }) - .ok(); + match event { + EditorEvent::SelectionsChanged { local: true } => { + let Some(project_path) = self.active_path(cx) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + if let Some(git_panel) = workspace.panel::(cx) { + git_panel.update(cx, |git_panel, cx| { + git_panel.select_entry_by_path(project_path, window, cx) + }) + } + }) + .ok(); + } + EditorEvent::Saved => { + self._task = cx.spawn_in(window, async move |this, cx| { + Self::refresh(this, RefreshReason::EditorSaved, cx).await + }); + } + _ => {} } if editor.focus_handle(cx).contains_focused(window, cx) && self.multibuffer.read(cx).is_empty() @@ -482,7 +497,7 @@ impl ProjectDiff { let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| { this._task = window.spawn(cx, { let this = cx.weak_entity(); - async |cx| Self::refresh(this, cx).await + async |cx| Self::refresh(this, RefreshReason::DiffChanged, cx).await }) }); self.buffer_diff_subscriptions @@ -581,14 +596,23 @@ impl ProjectDiff { } } - pub async fn refresh(this: WeakEntity, cx: &mut AsyncWindowContext) -> Result<()> { + pub async fn refresh( + this: WeakEntity, + reason: RefreshReason, + cx: &mut AsyncWindowContext, + ) -> Result<()> { let mut path_keys = Vec::new(); let buffers_to_load = this.update(cx, |this, cx| { let (repo, buffers_to_load) = this.branch_diff.update(cx, |branch_diff, cx| { let load_buffers = branch_diff.load_buffers(cx); (branch_diff.repo().cloned(), load_buffers) }); - let mut previous_paths = this.multibuffer.read(cx).paths().collect::>(); + let mut previous_paths = this + .multibuffer + .read(cx) + .paths() + .cloned() + .collect::>(); if let Some(repo) = repo { let repo = repo.read(cx); @@ -605,8 +629,20 @@ impl ProjectDiff { this.multibuffer.update(cx, |multibuffer, cx| { for path in previous_paths { + if let Some(buffer) = multibuffer.buffer_for_path(&path, cx) { + let skip = match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if skip { + continue; + } + } + this.buffer_diff_subscriptions.remove(&path.path); - multibuffer.remove_excerpts_for_path(path, cx); + multibuffer.remove_excerpts_for_path(path.clone(), cx); } }); buffers_to_load @@ -619,7 +655,27 @@ impl ProjectDiff { yield_now().await; cx.update(|window, cx| { this.update(cx, |this, cx| { - this.register_buffer(path_key, entry.file_status, buffer, diff, window, cx) + let multibuffer = this.multibuffer.read(cx); + let skip = multibuffer.buffer(buffer.read(cx).remote_id()).is_some() + && multibuffer + .diff_for(buffer.read(cx).remote_id()) + .is_some_and(|prev_diff| prev_diff.entity_id() == diff.entity_id()) + && match reason { + RefreshReason::DiffChanged | RefreshReason::EditorSaved => { + buffer.read(cx).is_dirty() + } + RefreshReason::StatusesChanged => false, + }; + if !skip { + this.register_buffer( + path_key, + entry.file_status, + buffer, + diff, + window, + cx, + ) + } }) .ok(); })?; @@ -637,7 +693,7 @@ impl ProjectDiff { pub fn excerpt_paths(&self, cx: &App) -> Vec> { self.multibuffer .read(cx) - .excerpt_paths() + .paths() .map(|key| key.path.clone()) .collect() } @@ -1650,9 +1706,13 @@ mod tests { .unindent(), ); - editor.update_in(cx, |editor, window, cx| { - editor.git_restore(&Default::default(), window, cx); - }); + editor + .update_in(cx, |editor, window, cx| { + editor.git_restore(&Default::default(), window, cx); + editor.save(SaveOptions::default(), project.clone(), window, cx) + }) + .await + .unwrap(); cx.run_until_parked(); assert_state_with_diff(&editor, cx, &"ˇ".unindent()); @@ -1841,8 +1901,8 @@ mod tests { cx, &" - original - + ˇdifferent - " + + different + ˇ" .unindent(), ); } diff --git a/crates/multi_buffer/src/path_key.rs b/crates/multi_buffer/src/path_key.rs index 119194d088c946941b13ffab3f6f2b3ea126cd09..10d4088fd4bc28449c8a4ee74095ad31a45fbcf3 100644 --- a/crates/multi_buffer/src/path_key.rs +++ b/crates/multi_buffer/src/path_key.rs @@ -43,8 +43,8 @@ impl PathKey { } impl MultiBuffer { - pub fn paths(&self) -> impl Iterator + '_ { - self.excerpts_by_path.keys().cloned() + pub fn paths(&self) -> impl Iterator + '_ { + self.excerpts_by_path.keys() } pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context) { @@ -58,15 +58,18 @@ impl MultiBuffer { } } - pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option { + pub fn buffer_for_path(&self, path: &PathKey, cx: &App) -> Option> { let excerpt_id = self.excerpts_by_path.get(path)?.first()?; let snapshot = self.read(cx); let excerpt = snapshot.excerpt(*excerpt_id)?; - Some(Anchor::in_buffer(excerpt.id, excerpt.range.context.start)) + self.buffer(excerpt.buffer_id) } - pub fn excerpt_paths(&self) -> impl Iterator { - self.excerpts_by_path.keys() + pub fn location_for_path(&self, path: &PathKey, cx: &App) -> Option { + let excerpt_id = self.excerpts_by_path.get(path)?.first()?; + let snapshot = self.read(cx); + let excerpt = snapshot.excerpt(*excerpt_id)?; + Some(Anchor::in_buffer(excerpt.id, excerpt.range.context.start)) } /// Sets excerpts, returns `true` if at least one new excerpt was added. From d7da5d3efdd2283cb70035e2e6c0a40d8cf02a0b Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Sun, 14 Dec 2025 20:07:44 -0800 Subject: [PATCH 21/67] Finish inline telemetry changes (#44842) Closes #ISSUE Release Notes: - N/A --- Cargo.lock | 4 +- crates/agent_ui/Cargo.toml | 1 - crates/agent_ui/src/agent_ui.rs | 11 +- crates/agent_ui/src/buffer_codegen.rs | 148 +++++----- crates/agent_ui/src/inline_assistant.rs | 125 ++++---- crates/agent_ui/src/inline_prompt_editor.rs | 276 ++++++++++-------- crates/agent_ui/src/terminal_codegen.rs | 66 +++-- .../agent_ui/src/terminal_inline_assistant.rs | 83 +++--- crates/agent_ui/src/text_thread_editor.rs | 1 - crates/assistant_text_thread/Cargo.toml | 2 +- .../src/assistant_text_thread_tests.rs | 9 - .../assistant_text_thread/src/text_thread.rs | 52 ++-- .../src/text_thread_store.rs | 14 +- crates/language_model/Cargo.toml | 1 - crates/language_model/src/telemetry.rs | 124 +++++--- crates/settings_ui/src/settings_ui.rs | 7 - 16 files changed, 499 insertions(+), 425 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e465ff483bcb6cc9528403bbb8f3bd883a6af871..436da4aef8c0849a61336a9645639c17da731029 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -388,7 +388,6 @@ dependencies = [ "streaming_diff", "task", "telemetry", - "telemetry_events", "terminal", "terminal_view", "text", @@ -894,7 +893,7 @@ dependencies = [ "settings", "smallvec", "smol", - "telemetry_events", + "telemetry", "text", "ui", "unindent", @@ -8817,7 +8816,6 @@ dependencies = [ "serde_json", "settings", "smol", - "telemetry_events", "thiserror 2.0.17", "util", "zed_env_vars", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 2af0ce6fbd2b636d19d9cb8e544851514800313c..b235799635ce81b02fd6fcd5d4d7a53a6957eb77 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -84,7 +84,6 @@ smol.workspace = true streaming_diff.workspace = true task.workspace = true telemetry.workspace = true -telemetry_events.workspace = true terminal.workspace = true terminal_view.workspace = true text.workspace = true diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index eb7785fad59894012251c84319af7fca306f2882..cd6113bfa6c611c8d2a6b9d43294e77737b7a9ae 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -216,7 +216,7 @@ pub fn init( is_eval: bool, cx: &mut App, ) { - assistant_text_thread::init(client.clone(), cx); + assistant_text_thread::init(client, cx); rules_library::init(cx); if !is_eval { // Initializing the language model from the user settings messes with the eval, so we only initialize them when @@ -229,13 +229,8 @@ pub fn init( TextThreadEditor::init(cx); register_slash_commands(cx); - inline_assistant::init( - fs.clone(), - prompt_builder.clone(), - client.telemetry().clone(), - cx, - ); - terminal_inline_assistant::init(fs.clone(), prompt_builder, client.telemetry().clone(), cx); + inline_assistant::init(fs.clone(), prompt_builder.clone(), cx); + terminal_inline_assistant::init(fs.clone(), prompt_builder, cx); cx.observe_new(move |workspace, window, cx| { ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index e2c67a04167d7080a6f94b9ee2a8fae516d487d7..bb05d5e04deb06f82dfc8e5dae0d871648f1d11e 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -1,8 +1,8 @@ use crate::{context::LoadedContext, inline_prompt_editor::CodegenStatus}; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; +use uuid::Uuid; -use client::telemetry::Telemetry; use cloud_llm_client::CompletionIntent; use collections::HashSet; use editor::{Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset as _, ToPoint}; @@ -15,12 +15,12 @@ use futures::{ stream::BoxStream, }; use gpui::{App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task}; -use language::{Buffer, IndentKind, Point, TransactionId, line_diff}; +use language::{Buffer, IndentKind, LanguageName, Point, TransactionId, line_diff}; use language_model::{ LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelTextStream, LanguageModelToolChoice, - LanguageModelToolUse, Role, TokenUsage, report_assistant_event, + LanguageModelToolUse, Role, TokenUsage, }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; @@ -41,7 +41,6 @@ use std::{ time::Instant, }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use ui::SharedString; /// Use this tool to provide a message to the user when you're unable to complete a task. @@ -77,9 +76,9 @@ pub struct BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, builder: Arc, pub is_insertion: bool, + session_id: Uuid, } impl BufferCodegen { @@ -87,7 +86,7 @@ impl BufferCodegen { buffer: Entity, range: Range, initial_transaction_id: Option, - telemetry: Arc, + session_id: Uuid, builder: Arc, cx: &mut Context, ) -> Self { @@ -96,8 +95,8 @@ impl BufferCodegen { buffer.clone(), range.clone(), false, - Some(telemetry.clone()), builder.clone(), + session_id, cx, ) }); @@ -110,8 +109,8 @@ impl BufferCodegen { buffer, range, initial_transaction_id, - telemetry, builder, + session_id, }; this.activate(0, cx); this @@ -134,6 +133,10 @@ impl BufferCodegen { &self.alternatives[self.active_alternative] } + pub fn language_name(&self, cx: &App) -> Option { + self.active_alternative().read(cx).language_name(cx) + } + pub fn status<'a>(&self, cx: &'a App) -> &'a CodegenStatus { &self.active_alternative().read(cx).status } @@ -192,8 +195,8 @@ impl BufferCodegen { self.buffer.clone(), self.range.clone(), false, - Some(self.telemetry.clone()), self.builder.clone(), + self.session_id, cx, ) })); @@ -256,6 +259,10 @@ impl BufferCodegen { pub fn selected_text<'a>(&self, cx: &'a App) -> Option<&'a str> { self.active_alternative().read(cx).selected_text() } + + pub fn session_id(&self) -> Uuid { + self.session_id + } } impl EventEmitter for BufferCodegen {} @@ -271,7 +278,6 @@ pub struct CodegenAlternative { status: CodegenStatus, generation: Task<()>, diff: Diff, - telemetry: Option>, _subscription: gpui::Subscription, builder: Arc, active: bool, @@ -282,6 +288,7 @@ pub struct CodegenAlternative { selected_text: Option, pub message_id: Option, pub model_explanation: Option, + session_id: Uuid, } impl EventEmitter for CodegenAlternative {} @@ -291,8 +298,8 @@ impl CodegenAlternative { buffer: Entity, range: Range, active: bool, - telemetry: Option>, builder: Arc, + session_id: Uuid, cx: &mut Context, ) -> Self { let snapshot = buffer.read(cx).snapshot(cx); @@ -331,7 +338,6 @@ impl CodegenAlternative { status: CodegenStatus::Idle, generation: Task::ready(()), diff: Diff::default(), - telemetry, builder, active: active, edits: Vec::new(), @@ -341,10 +347,18 @@ impl CodegenAlternative { completion: None, selected_text: None, model_explanation: None, + session_id, _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } } + pub fn language_name(&self, cx: &App) -> Option { + self.old_buffer + .read(cx) + .language() + .map(|language| language.name()) + } + pub fn set_active(&mut self, active: bool, cx: &mut Context) { if active != self.active { self.active = active; @@ -407,34 +421,28 @@ impl CodegenAlternative { self.edit_position = Some(self.range.start.bias_right(&self.snapshot)); - let api_key = model.api_key(cx); - let telemetry_id = model.telemetry_id(); - let provider_id = model.provider_id(); - if Self::use_streaming_tools(model.as_ref(), cx) { let request = self.build_request(&model, user_prompt, context_task, cx)?; - let completion_events = - cx.spawn(async move |_, cx| model.stream_completion(request.await, cx).await); - self.generation = self.handle_completion( - telemetry_id, - provider_id.to_string(), - api_key, - completion_events, - cx, - ); + let completion_events = cx.spawn({ + let model = model.clone(); + async move |_, cx| model.stream_completion(request.await, cx).await + }); + self.generation = self.handle_completion(model, completion_events, cx); } else { let stream: LocalBoxFuture> = if user_prompt.trim().to_lowercase() == "delete" { async { Ok(LanguageModelTextStream::default()) }.boxed_local() } else { let request = self.build_request(&model, user_prompt, context_task, cx)?; - cx.spawn(async move |_, cx| { - Ok(model.stream_completion_text(request.await, cx).await?) + cx.spawn({ + let model = model.clone(); + async move |_, cx| { + Ok(model.stream_completion_text(request.await, cx).await?) + } }) .boxed_local() }; - self.generation = - self.handle_stream(telemetry_id, provider_id.to_string(), api_key, stream, cx); + self.generation = self.handle_stream(model, stream, cx); } Ok(()) @@ -621,12 +629,14 @@ impl CodegenAlternative { pub fn handle_stream( &mut self, - model_telemetry_id: String, - model_provider_id: String, - model_api_key: Option, + model: Arc, stream: impl 'static + Future>, cx: &mut Context, ) -> Task<()> { + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); let start_time = Instant::now(); // Make a new snapshot and re-resolve anchor in case the document was modified. @@ -664,8 +674,6 @@ impl CodegenAlternative { } } - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); let language_name = { let multibuffer = self.buffer.read(cx); let snapshot = multibuffer.snapshot(cx); @@ -698,10 +706,11 @@ impl CodegenAlternative { let model_telemetry_id = model_telemetry_id.clone(); let model_provider_id = model_provider_id.clone(); let (mut diff_tx, mut diff_rx) = mpsc::channel(1); - let executor = cx.background_executor().clone(); let message_id = message_id.clone(); - let line_based_stream_diff: Task> = - cx.background_spawn(async move { + let line_based_stream_diff: Task> = cx.background_spawn({ + let anthropic_reporter = anthropic_reporter.clone(); + let language_name = language_name.clone(); + async move { let mut response_latency = None; let request_start = Instant::now(); let diff = async { @@ -798,27 +807,30 @@ impl CodegenAlternative { let result = diff.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - message_id, - kind: AssistantKind::Inline, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id, - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - telemetry, - http_client, - model_api_key, - &executor, + telemetry::event!( + "Assistant Responded", + kind = "inline", + phase = "response", + session_id = session_id.to_string(), + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name.as_ref().map(|n| n.to_string()), + message_id = message_id.as_deref(), + response_latency = response_latency, + error_message = error_message.as_deref(), ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Response, + language_name: language_name.map(|n| n.to_string()), + message_id, + }); + result?; Ok(()) - }); + } + }); while let Some((char_ops, line_ops)) = diff_rx.next().await { codegen.update(cx, |codegen, cx| { @@ -1086,9 +1098,7 @@ impl CodegenAlternative { fn handle_completion( &mut self, - telemetry_id: String, - provider_id: String, - api_key: Option, + model: Arc, completion_stream: Task< Result< BoxStream< @@ -1270,13 +1280,7 @@ impl CodegenAlternative { let Some(task) = codegen .update(cx, move |codegen, cx| { - codegen.handle_stream( - telemetry_id, - provider_id, - api_key, - async { Ok(language_model_text_stream) }, - cx, - ) + codegen.handle_stream(model, async { Ok(language_model_text_stream) }, cx) }) .ok() else { @@ -1448,6 +1452,7 @@ mod tests { use gpui::TestAppContext; use indoc::indoc; use language::{Buffer, Point}; + use language_model::fake_provider::FakeLanguageModel; use language_model::{LanguageModelRegistry, TokenUsage}; use languages::rust_lang; use rand::prelude::*; @@ -1478,8 +1483,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1540,8 +1545,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1604,8 +1609,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1668,8 +1673,8 @@ mod tests { buffer.clone(), range.clone(), true, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1720,8 +1725,8 @@ mod tests { buffer.clone(), range.clone(), false, - None, prompt_builder, + Uuid::new_v4(), cx, ) }); @@ -1810,11 +1815,10 @@ mod tests { cx: &mut TestAppContext, ) -> mpsc::UnboundedSender { let (chunks_tx, chunks_rx) = mpsc::unbounded(); + let model = Arc::new(FakeLanguageModel::default()); codegen.update(cx, |codegen, cx| { codegen.generation = codegen.handle_stream( - String::new(), - String::new(), - None, + model, future::ready(Ok(LanguageModelTextStream { message_id: None, stream: chunks_rx.map(Ok).boxed(), diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index ad0f58c162ca720e619e83ca9a3eb65a4be9fe2b..0eb96b3712623cc08632ede6c7836ed09499c02d 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -1,8 +1,11 @@ +use language_model::AnthropicEventData; +use language_model::report_anthropic_event; use std::cmp; use std::mem; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; +use uuid::Uuid; use crate::context::load_context; use crate::mention_set::MentionSet; @@ -15,7 +18,6 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; use collections::{HashMap, HashSet, VecDeque, hash_map}; use editor::EditorSnapshot; use editor::MultiBufferOffset; @@ -38,15 +40,13 @@ use gpui::{ WeakEntity, Window, point, }; use language::{Buffer, Point, Selection, TransactionId}; -use language_model::{ - ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event, -}; +use language_model::{ConfigurationError, ConfiguredModel, LanguageModelRegistry}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use project::{CodeAction, DisableAiSettings, LspAction, Project, ProjectTransaction}; use prompt_store::{PromptBuilder, PromptStore}; use settings::{Settings, SettingsStore}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use text::{OffsetRangeExt, ToPoint as _}; use ui::prelude::*; @@ -54,13 +54,8 @@ use util::{RangeExt, ResultExt, maybe}; use workspace::{ItemHandle, Toast, Workspace, dock::Panel, notifications::NotificationId}; use zed_actions::agent::OpenSettings; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(InlineAssistant::new(fs, prompt_builder)); cx.observe_global::(|cx| { if DisableAiSettings::get_global(cx).disable_ai { @@ -100,7 +95,6 @@ pub struct InlineAssistant { confirmed_assists: HashMap>, prompt_history: VecDeque, prompt_builder: Arc, - telemetry: Arc, fs: Arc, _inline_assistant_completions: Option>>, } @@ -108,11 +102,7 @@ pub struct InlineAssistant { impl Global for InlineAssistant {} impl InlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: InlineAssistId::default(), next_assist_group_id: InlineAssistGroupId::default(), @@ -122,7 +112,6 @@ impl InlineAssistant { confirmed_assists: HashMap::default(), prompt_history: VecDeque::default(), prompt_builder, - telemetry, fs, _inline_assistant_completions: None, } @@ -457,17 +446,25 @@ impl InlineAssistant { codegen_ranges.push(anchor_range); if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { - self.telemetry.report_assistant_event(AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, - phase: AssistantPhase::Invoked, - message_id: None, - model: model.model.telemetry_id(), - model_provider: model.provider.id().to_string(), - response_latency: None, - error_message: None, - language_name: buffer.language().map(|language| language.name().to_proto()), - }); + telemetry::event!( + "Assistant Invoked", + kind = "inline", + phase = "invoked", + model = model.model.telemetry_id(), + model_provider = model.provider.id().to_string(), + language_name = buffer.language().map(|language| language.name().to_proto()) + ); + + report_anthropic_event( + &model.model, + AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: language_model::AnthropicEventType::Invoked, + language_name: buffer.language().map(|language| language.name().to_proto()), + message_id: None, + }, + cx, + ); } } @@ -491,6 +488,7 @@ impl InlineAssistant { let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let assist_group_id = self.next_assist_group_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), @@ -508,7 +506,7 @@ impl InlineAssistant { editor.read(cx).buffer().clone(), range.clone(), initial_transaction_id, - self.telemetry.clone(), + session_id, self.prompt_builder.clone(), cx, ) @@ -522,6 +520,7 @@ impl InlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen.clone(), + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -1069,8 +1068,6 @@ impl InlineAssistant { } let active_alternative = assist.codegen.read(cx).active_alternative().clone(); - let message_id = active_alternative.read(cx).message_id.clone(); - if let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() { let language_name = assist.editor.upgrade().and_then(|editor| { let multibuffer = editor.read(cx).buffer().read(cx); @@ -1079,28 +1076,49 @@ impl InlineAssistant { ranges .first() .and_then(|(buffer, _, _)| buffer.language()) - .map(|language| language.name()) + .map(|language| language.name().0.to_string()) }); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::Inline, + + let codegen = assist.codegen.read(cx); + let session_id = codegen.session_id(); + let message_id = active_alternative.read(cx).message_id.clone(); + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + telemetry::event!( + event_type, + phase, + session_id = session_id.to_string(), + kind = "inline", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + message_id = message_id.as_deref(), + ); + + report_anthropic_event( + &model.model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Editor, + event: anthropic_event_type, + language_name, message_id, - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.model.telemetry_id(), - model_provider: model.model.provider_id().to_string(), - response_latency: None, - error_message: None, - language_name: language_name.map(|name| name.to_proto()), }, - Some(self.telemetry.clone()), - cx.http_client(), - model.model.api_key(cx), - cx.background_executor(), + cx, ); } @@ -2036,8 +2054,7 @@ pub mod test { cx.set_http_client(http); Client::production(cx) }); - let mut inline_assistant = - InlineAssistant::new(fs.clone(), prompt_builder, client.telemetry().clone()); + let mut inline_assistant = InlineAssistant::new(fs.clone(), prompt_builder); let (tx, mut completion_rx) = mpsc::unbounded(); inline_assistant.set_completion_receiver(tx); diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 4856d4024c94856e8dee91c048fe6ce72e79a7b8..e262cda87899b0314c9fd8909f5718b4fd7dbfda 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -8,7 +8,7 @@ use editor::{ ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode, EditorStyle, MultiBuffer, actions::{MoveDown, MoveUp}, }; -use feature_flags::{FeatureFlag, FeatureFlagAppExt}; +use feature_flags::{FeatureFlagAppExt, InlineAssistantUseToolFeatureFlag}; use fs::Fs; use gpui::{ AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, @@ -20,10 +20,10 @@ use parking_lot::Mutex; use project::Project; use prompt_store::PromptStore; use settings::Settings; +use std::cmp; use std::ops::Range; use std::rc::Rc; use std::sync::Arc; -use std::{cmp, mem}; use theme::ThemeSettings; use ui::utils::WithRemSize; use ui::{IconButtonShape, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*}; @@ -44,54 +44,15 @@ use crate::{CycleNextInlineAssist, CyclePreviousInlineAssist, ModelUsageContext} actions!(inline_assistant, [ThumbsUpResult, ThumbsDownResult]); -pub struct InlineAssistRatingFeatureFlag; - -impl FeatureFlag for InlineAssistRatingFeatureFlag { - const NAME: &'static str = "inline-assist-rating"; - - fn enabled_for_staff() -> bool { - false - } -} - -enum RatingState { +enum CompletionState { Pending, - GeneratedCompletion(Option), - Rated(Uuid), + Generated { completion_text: Option }, + Rated, } -impl RatingState { - fn is_pending(&self) -> bool { - matches!(self, RatingState::Pending) - } - - fn rating_id(&self) -> Option { - match self { - RatingState::Pending => None, - RatingState::GeneratedCompletion(_) => None, - RatingState::Rated(id) => Some(*id), - } - } - - fn rate(&mut self) -> (Uuid, Option) { - let id = Uuid::new_v4(); - let old_state = mem::replace(self, RatingState::Rated(id)); - let completion = match old_state { - RatingState::Pending => None, - RatingState::GeneratedCompletion(completion) => completion, - RatingState::Rated(_) => None, - }; - - (id, completion) - } - - fn reset(&mut self) { - *self = RatingState::Pending; - } - - fn generated_completion(&mut self, generated_completion: Option) { - *self = RatingState::GeneratedCompletion(generated_completion); - } +struct SessionState { + session_id: Uuid, + completion: CompletionState, } pub struct PromptEditor { @@ -109,7 +70,7 @@ pub struct PromptEditor { _codegen_subscription: Subscription, editor_subscriptions: Vec, show_rate_limit_notice: bool, - rated: RatingState, + session_state: SessionState, _phantom: std::marker::PhantomData, } @@ -487,7 +448,7 @@ impl PromptEditor { } self.edited_since_done = true; - self.rated.reset(); + self.session_state.completion = CompletionState::Pending; cx.notify(); } EditorEvent::Blurred => { @@ -559,109 +520,165 @@ impl PromptEditor { fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { match self.codegen_status(cx) { CodegenStatus::Idle => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } CodegenStatus::Pending => {} CodegenStatus::Done => { if self.edited_since_done { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } else { cx.emit(PromptEditorEvent::ConfirmRequested { execute: false }); } } CodegenStatus::Error(_) => { + self.fire_started_telemetry(cx); cx.emit(PromptEditorEvent::StartRequested); } } } - fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context) { - if self.rated.is_pending() { - self.toast("Still generating...", None, cx); + fn fire_started_telemetry(&self, cx: &Context) { + let Some(model) = LanguageModelRegistry::read_global(cx).inline_assistant_model() else { return; - } - - if let Some(rating_id) = self.rated.rating_id() { - self.toast("Already rated this completion", Some(rating_id), cx); - return; - } + }; - let (rating_id, completion) = self.rated.rate(); + let model_telemetry_id = model.model.telemetry_id(); + let model_provider_id = model.provider.id().to_string(); - let selected_text = match &self.mode { + let (kind, language_name) = match &self.mode { PromptEditorMode::Buffer { codegen, .. } => { - codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + let codegen = codegen.read(cx); + ( + "inline", + codegen.language_name(cx).map(|name| name.to_string()), + ) } - PromptEditorMode::Terminal { .. } => None, + PromptEditorMode::Terminal { .. } => ("inline_terminal", None), }; - let model_info = self.model_selector.read(cx).active_model(cx); - let model_id = { - let Some(configured_model) = model_info else { - self.toast("No configured model", None, cx); - return; - }; + telemetry::event!( + "Assistant Started", + session_id = self.session_state.session_id.to_string(), + kind = kind, + phase = "started", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = language_name, + ); + } - configured_model.model.telemetry_id() - }; + fn thumbs_up(&mut self, _: &ThumbsUpResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); + return; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let model_id = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + configured_model.model.telemetry_id() + }; - let prompt = self.editor.read(cx).text(cx); + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; - telemetry::event!( - "Inline Assistant Rated", - rating = "positive", - model = model_id, - prompt = prompt, - completion = completion, - selected_text = selected_text, - rating_id = rating_id.to_string() - ); + let prompt = self.editor.read(cx).text(cx); - cx.notify(); - } + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; - fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context) { - if self.rated.is_pending() { - self.toast("Still generating...", None, cx); - return; - } - if let Some(rating_id) = self.rated.rating_id() { - self.toast("Already rated this completion", Some(rating_id), cx); - return; - } + telemetry::event!( + "Inline Assistant Rated", + rating = "positive", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + ); - let (rating_id, completion) = self.rated.rate(); + self.session_state.completion = CompletionState::Rated; - let selected_text = match &self.mode { - PromptEditorMode::Buffer { codegen, .. } => { - codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + cx.notify(); } - PromptEditorMode::Terminal { .. } => None, - }; + } + } - let model_info = self.model_selector.read(cx).active_model(cx); - let model_telemetry_id = { - let Some(configured_model) = model_info else { - self.toast("No configured model", None, cx); + fn thumbs_down(&mut self, _: &ThumbsDownResult, _window: &mut Window, cx: &mut Context) { + match &self.session_state.completion { + CompletionState::Pending => { + self.toast("Can't rate, still generating...", None, cx); return; - }; + } + CompletionState::Rated => { + self.toast( + "Already rated this completion", + Some(self.session_state.session_id), + cx, + ); + return; + } + CompletionState::Generated { completion_text } => { + let model_info = self.model_selector.read(cx).active_model(cx); + let model_telemetry_id = { + let Some(configured_model) = model_info else { + self.toast("No configured model", None, cx); + return; + }; + configured_model.model.telemetry_id() + }; - configured_model.model.telemetry_id() - }; + let selected_text = match &self.mode { + PromptEditorMode::Buffer { codegen, .. } => { + codegen.read(cx).selected_text(cx).map(|s| s.to_string()) + } + PromptEditorMode::Terminal { .. } => None, + }; - let prompt = self.editor.read(cx).text(cx); + let prompt = self.editor.read(cx).text(cx); - telemetry::event!( - "Inline Assistant Rated", - rating = "negative", - model = model_telemetry_id, - prompt = prompt, - completion = completion, - selected_text = selected_text, - rating_id = rating_id.to_string() - ); + let kind = match &self.mode { + PromptEditorMode::Buffer { .. } => "inline", + PromptEditorMode::Terminal { .. } => "inline_terminal", + }; + + telemetry::event!( + "Inline Assistant Rated", + rating = "negative", + session_id = self.session_state.session_id.to_string(), + kind = kind, + model = model_telemetry_id, + prompt = prompt, + completion = completion_text, + selected_text = selected_text, + ); + + self.session_state.completion = CompletionState::Rated; - cx.notify(); + cx.notify(); + } + } } fn toast(&mut self, msg: &str, uuid: Option, cx: &mut Context<'_, PromptEditor>) { @@ -795,8 +812,8 @@ impl PromptEditor { .into_any_element(), ] } else { - let show_rating_buttons = cx.has_flag::(); - let rated = self.rated.rating_id().is_some(); + let show_rating_buttons = cx.has_flag::(); + let rated = matches!(self.session_state.completion, CompletionState::Rated); let accept = IconButton::new("accept", IconName::Check) .icon_color(Color::Info) @@ -1120,6 +1137,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -1190,7 +1208,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), show_rate_limit_notice: false, mode, - rated: RatingState::Pending, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; @@ -1210,13 +1231,15 @@ impl PromptEditor { .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { - self.rated.reset(); + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done => { let completion = codegen.read(cx).active_completion(cx); - self.rated.generated_completion(completion); + self.session_state.completion = CompletionState::Generated { + completion_text: completion, + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); @@ -1272,6 +1295,7 @@ impl PromptEditor { prompt_history: VecDeque, prompt_buffer: Entity, codegen: Entity, + session_id: Uuid, fs: Arc, history_store: Entity, prompt_store: Option>, @@ -1337,7 +1361,10 @@ impl PromptEditor { editor_subscriptions: Vec::new(), mode, show_rate_limit_notice: false, - rated: RatingState::Pending, + session_state: SessionState { + session_id, + completion: CompletionState::Pending, + }, _phantom: Default::default(), }; this.count_lines(cx); @@ -1377,13 +1404,14 @@ impl PromptEditor { .update(cx, |editor, _| editor.set_read_only(false)); } CodegenStatus::Pending => { - self.rated = RatingState::Pending; + self.session_state.completion = CompletionState::Pending; self.editor .update(cx, |editor, _| editor.set_read_only(true)); } CodegenStatus::Done | CodegenStatus::Error(_) => { - self.rated - .generated_completion(codegen.read(cx).completion()); + self.session_state.completion = CompletionState::Generated { + completion_text: codegen.read(cx).completion(), + }; self.edited_since_done = false; self.editor .update(cx, |editor, _| editor.set_read_only(false)); diff --git a/crates/agent_ui/src/terminal_codegen.rs b/crates/agent_ui/src/terminal_codegen.rs index cc99471f7f3037cb94ff23979036bd6c2026e2f0..e93d3d3991378ddb4156b264be1f0a5ab4d4faac 100644 --- a/crates/agent_ui/src/terminal_codegen.rs +++ b/crates/agent_ui/src/terminal_codegen.rs @@ -1,37 +1,38 @@ use crate::inline_prompt_editor::CodegenStatus; -use client::telemetry::Telemetry; use futures::{SinkExt, StreamExt, channel::mpsc}; use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Task}; -use language_model::{ - ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, report_assistant_event, -}; -use std::{sync::Arc, time::Instant}; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; +use language_model::{ConfiguredModel, LanguageModelRegistry, LanguageModelRequest}; +use std::time::Instant; use terminal::Terminal; +use uuid::Uuid; pub struct TerminalCodegen { pub status: CodegenStatus, - pub telemetry: Option>, terminal: Entity, generation: Task<()>, pub message_id: Option, transaction: Option, + session_id: Uuid, } impl EventEmitter for TerminalCodegen {} impl TerminalCodegen { - pub fn new(terminal: Entity, telemetry: Option>) -> Self { + pub fn new(terminal: Entity, session_id: Uuid) -> Self { Self { terminal, - telemetry, status: CodegenStatus::Idle, generation: Task::ready(()), message_id: None, transaction: None, + session_id, } } + pub fn session_id(&self) -> Uuid { + self.session_id + } + pub fn start(&mut self, prompt_task: Task, cx: &mut Context) { let Some(ConfiguredModel { model, .. }) = LanguageModelRegistry::read_global(cx).inline_assistant_model() @@ -39,15 +40,15 @@ impl TerminalCodegen { return; }; - let model_api_key = model.api_key(cx); - let http_client = cx.http_client(); - let telemetry = self.telemetry.clone(); + let anthropic_reporter = language_model::AnthropicEventReporter::new(&model, cx); + let session_id = self.session_id; + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + self.status = CodegenStatus::Pending; self.transaction = Some(TerminalTransaction::start(self.terminal.clone())); self.generation = cx.spawn(async move |this, cx| { let prompt = prompt_task.await; - let model_telemetry_id = model.telemetry_id(); - let model_provider_id = model.provider_id(); let response = model.stream_completion_text(prompt, cx).await; let generate = async { let message_id = response @@ -59,7 +60,7 @@ impl TerminalCodegen { let task = cx.background_spawn({ let message_id = message_id.clone(); - let executor = cx.background_executor().clone(); + let anthropic_reporter = anthropic_reporter.clone(); async move { let mut response_latency = None; let request_start = Instant::now(); @@ -79,24 +80,27 @@ impl TerminalCodegen { let result = task.await; let error_message = result.as_ref().err().map(|error| error.to_string()); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id, - phase: AssistantPhase::Response, - model: model_telemetry_id, - model_provider: model_provider_id.to_string(), - response_latency, - error_message, - language_name: None, - }, - telemetry, - http_client, - model_api_key, - &executor, + + telemetry::event!( + "Assistant Responded", + session_id = session_id.to_string(), + kind = "inline_terminal", + phase = "response", + model = model_telemetry_id, + model_provider = model_provider_id, + language_name = Option::<&str>::None, + message_id = message_id, + response_latency = response_latency, + error_message = error_message, ); + anthropic_reporter.report(language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: language_model::AnthropicEventType::Response, + language_name: None, + message_id, + }); + result?; anyhow::Ok(()) } diff --git a/crates/agent_ui/src/terminal_inline_assistant.rs b/crates/agent_ui/src/terminal_inline_assistant.rs index 43ea697bece318699f350259a0e2e38d1a4f4d8d..84a74242b80d0b2f8479b3c6dbca1c7d0bb2cb6d 100644 --- a/crates/agent_ui/src/terminal_inline_assistant.rs +++ b/crates/agent_ui/src/terminal_inline_assistant.rs @@ -8,7 +8,7 @@ use crate::{ use agent::HistoryStore; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::telemetry::Telemetry; + use cloud_llm_client::CompletionIntent; use collections::{HashMap, VecDeque}; use editor::{MultiBuffer, actions::SelectAll}; @@ -17,24 +17,19 @@ use gpui::{App, Entity, Focusable, Global, Subscription, Task, UpdateGlobal, Wea use language::Buffer; use language_model::{ ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - Role, report_assistant_event, + Role, report_anthropic_event, }; use project::Project; use prompt_store::{PromptBuilder, PromptStore}; use std::sync::Arc; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use terminal_view::TerminalView; use ui::prelude::*; use util::ResultExt; +use uuid::Uuid; use workspace::{Toast, Workspace, notifications::NotificationId}; -pub fn init( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - cx: &mut App, -) { - cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder, telemetry)); +pub fn init(fs: Arc, prompt_builder: Arc, cx: &mut App) { + cx.set_global(TerminalInlineAssistant::new(fs, prompt_builder)); } const DEFAULT_CONTEXT_LINES: usize = 50; @@ -44,7 +39,6 @@ pub struct TerminalInlineAssistant { next_assist_id: TerminalInlineAssistId, assists: HashMap, prompt_history: VecDeque, - telemetry: Option>, fs: Arc, prompt_builder: Arc, } @@ -52,16 +46,11 @@ pub struct TerminalInlineAssistant { impl Global for TerminalInlineAssistant {} impl TerminalInlineAssistant { - pub fn new( - fs: Arc, - prompt_builder: Arc, - telemetry: Arc, - ) -> Self { + pub fn new(fs: Arc, prompt_builder: Arc) -> Self { Self { next_assist_id: TerminalInlineAssistId::default(), assists: HashMap::default(), prompt_history: VecDeque::default(), - telemetry: Some(telemetry), fs, prompt_builder, } @@ -80,13 +69,14 @@ impl TerminalInlineAssistant { ) { let terminal = terminal_view.read(cx).terminal().clone(); let assist_id = self.next_assist_id.post_inc(); + let session_id = Uuid::new_v4(); let prompt_buffer = cx.new(|cx| { MultiBuffer::singleton( cx.new(|cx| Buffer::local(initial_prompt.unwrap_or_default(), cx)), cx, ) }); - let codegen = cx.new(|_| TerminalCodegen::new(terminal, self.telemetry.clone())); + let codegen = cx.new(|_| TerminalCodegen::new(terminal, session_id)); let prompt_editor = cx.new(|cx| { PromptEditor::new_terminal( @@ -94,6 +84,7 @@ impl TerminalInlineAssistant { self.prompt_history.clone(), prompt_buffer.clone(), codegen, + session_id, self.fs.clone(), thread_store.clone(), prompt_store.clone(), @@ -309,27 +300,45 @@ impl TerminalInlineAssistant { LanguageModelRegistry::read_global(cx).inline_assistant_model() { let codegen = assist.codegen.read(cx); - let executor = cx.background_executor().clone(); - report_assistant_event( - AssistantEventData { - conversation_id: None, - kind: AssistantKind::InlineTerminal, - message_id: codegen.message_id.clone(), - phase: if undo { - AssistantPhase::Rejected - } else { - AssistantPhase::Accepted - }, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency: None, - error_message: None, + let session_id = codegen.session_id(); + let message_id = codegen.message_id.clone(); + let model_telemetry_id = model.telemetry_id(); + let model_provider_id = model.provider_id().to_string(); + + let (phase, event_type, anthropic_event_type) = if undo { + ( + "rejected", + "Assistant Response Rejected", + language_model::AnthropicEventType::Reject, + ) + } else { + ( + "accepted", + "Assistant Response Accepted", + language_model::AnthropicEventType::Accept, + ) + }; + + // Fire Zed telemetry + telemetry::event!( + event_type, + kind = "inline_terminal", + phase = phase, + model = model_telemetry_id, + model_provider = model_provider_id, + message_id = message_id, + session_id = session_id, + ); + + report_anthropic_event( + &model, + language_model::AnthropicEventData { + completion_type: language_model::AnthropicCompletionType::Terminal, + event: anthropic_event_type, language_name: None, + message_id, }, - codegen.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - &executor, + cx, ); } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index fb9ee5e49e22fe7b70c02537a3e9a60394ddcc6f..5e3f348c17de3cd0dae9f5fe41a2477211d6ddd8 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -3324,7 +3324,6 @@ mod tests { let mut text_thread = TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/assistant_text_thread/Cargo.toml b/crates/assistant_text_thread/Cargo.toml index 7c8fcca3bfa81f6f2de570fa68ecc795cb81b257..5ad429758ea1785ecb4fcecb2f3ad83a71afda0d 100644 --- a/crates/assistant_text_thread/Cargo.toml +++ b/crates/assistant_text_thread/Cargo.toml @@ -46,7 +46,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true smol.workspace = true -telemetry_events.workspace = true +telemetry.workspace = true text.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs index 0743641bf5ce33850f28987d834b2e79771cff6f..7232a03c212a9dfc4bfe9bcce4a78667d9210ad8 100644 --- a/crates/assistant_text_thread/src/assistant_text_thread_tests.rs +++ b/crates/assistant_text_thread/src/assistant_text_thread_tests.rs @@ -50,7 +50,6 @@ fn test_inserting_and_removing_messages(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -189,7 +188,6 @@ fn test_message_splitting(cx: &mut App) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -294,7 +292,6 @@ fn test_messages_for_offsets(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -405,7 +402,6 @@ async fn test_slash_commands(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -677,7 +673,6 @@ async fn test_serialization(cx: &mut TestAppContext) { TextThread::local( registry.clone(), None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -724,7 +719,6 @@ async fn test_serialization(cx: &mut TestAppContext) { prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -780,7 +774,6 @@ async fn test_random_context_collaboration(cx: &mut TestAppContext, mut rng: Std prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), None, - None, cx, ) }); @@ -1041,7 +1034,6 @@ fn test_mark_cache_anchors(cx: &mut App) { TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, @@ -1368,7 +1360,6 @@ fn setup_context_editor_with_fake_model( TextThread::local( registry, None, - None, prompt_builder.clone(), Arc::new(SlashCommandWorkingSet::default()), cx, diff --git a/crates/assistant_text_thread/src/text_thread.rs b/crates/assistant_text_thread/src/text_thread.rs index b808d9fb0019ccad25366d9ae60cc1f765126c74..5ec72eb0814f9ac09aba36f52d6f011af5b47249 100644 --- a/crates/assistant_text_thread/src/text_thread.rs +++ b/crates/assistant_text_thread/src/text_thread.rs @@ -5,7 +5,7 @@ use assistant_slash_command::{ SlashCommandResult, SlashCommandWorkingSet, }; use assistant_slash_commands::FileCommandMetadata; -use client::{self, ModelRequestUsage, RequestUsage, proto, telemetry::Telemetry}; +use client::{self, ModelRequestUsage, RequestUsage, proto}; use clock::ReplicaId; use cloud_llm_client::{CompletionIntent, UsageLimit}; use collections::{HashMap, HashSet}; @@ -19,10 +19,11 @@ use gpui::{ use itertools::Itertools as _; use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset}; use language_model::{ - LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, - LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, + AnthropicCompletionType, AnthropicEventData, AnthropicEventType, LanguageModel, + LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolUseId, MessageContent, PaymentRequiredError, Role, StopReason, - report_assistant_event, + report_anthropic_event, }; use open_ai::Model as OpenAiModel; use paths::text_threads_dir; @@ -40,7 +41,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; + use text::{BufferSnapshot, ToPoint}; use ui::IconName; use util::{ResultExt, TryFutureExt, post_inc}; @@ -686,7 +687,6 @@ pub struct TextThread { pending_cache_warming_task: Task>, path: Option>, _subscriptions: Vec, - telemetry: Option>, language_registry: Arc, project: Option>, prompt_builder: Arc, @@ -709,7 +709,6 @@ impl TextThread { pub fn local( language_registry: Arc, project: Option>, - telemetry: Option>, prompt_builder: Arc, slash_commands: Arc, cx: &mut Context, @@ -722,7 +721,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ) } @@ -743,7 +741,6 @@ impl TextThread { prompt_builder: Arc, slash_commands: Arc, project: Option>, - telemetry: Option>, cx: &mut Context, ) -> Self { let buffer = cx.new(|_cx| { @@ -784,7 +781,6 @@ impl TextThread { completion_mode: AgentSettings::get_global(cx).preferred_completion_mode, path: None, buffer, - telemetry, project, language_registry, slash_commands, @@ -874,7 +870,6 @@ impl TextThread { prompt_builder: Arc, slash_commands: Arc, project: Option>, - telemetry: Option>, cx: &mut Context, ) -> Self { let id = saved_context.id.clone().unwrap_or_else(TextThreadId::new); @@ -886,7 +881,6 @@ impl TextThread { prompt_builder, slash_commands, project, - telemetry, cx, ); this.path = Some(path); @@ -2212,24 +2206,26 @@ impl TextThread { .read(cx) .language() .map(|language| language.name()); - report_assistant_event( - AssistantEventData { - conversation_id: Some(this.id.0.clone()), - kind: AssistantKind::Panel, - phase: AssistantPhase::Response, - message_id: None, - model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), - response_latency, - error_message, - language_name: language_name.map(|name| name.to_proto()), - }, - this.telemetry.clone(), - cx.http_client(), - model.api_key(cx), - cx.background_executor(), + + telemetry::event!( + "Assistant Responded", + conversation_id = this.id.0.clone(), + kind = "panel", + phase = "response", + model = model.telemetry_id(), + model_provider = model.provider_id().to_string(), + response_latency, + error_message, + language_name = language_name.as_ref().map(|name| name.to_proto()), ); + report_anthropic_event(&model, AnthropicEventData { + completion_type: AnthropicCompletionType::Panel, + event: AnthropicEventType::Response, + language_name: language_name.map(|name| name.to_proto()), + message_id: None, + }, cx); + if let Ok(stop_reason) = result { match stop_reason { StopReason::ToolUse => {} diff --git a/crates/assistant_text_thread/src/text_thread_store.rs b/crates/assistant_text_thread/src/text_thread_store.rs index 71fabed503e8c04a8865bed72c28ae5b30e75574..483baa73134334162ea30d269a1f955dd8fe023a 100644 --- a/crates/assistant_text_thread/src/text_thread_store.rs +++ b/crates/assistant_text_thread/src/text_thread_store.rs @@ -4,7 +4,7 @@ use crate::{ }; use anyhow::{Context as _, Result}; use assistant_slash_command::{SlashCommandId, SlashCommandWorkingSet}; -use client::{Client, TypedEnvelope, proto, telemetry::Telemetry}; +use client::{Client, TypedEnvelope, proto}; use clock::ReplicaId; use collections::HashMap; use context_server::ContextServerId; @@ -48,7 +48,6 @@ pub struct TextThreadStore { fs: Arc, languages: Arc, slash_commands: Arc, - telemetry: Arc, _watch_updates: Task>, client: Arc, project: WeakEntity, @@ -88,7 +87,6 @@ impl TextThreadStore { ) -> Task>> { let fs = project.read(cx).fs().clone(); let languages = project.read(cx).languages().clone(); - let telemetry = project.read(cx).client().telemetry().clone(); cx.spawn(async move |cx| { const CONTEXT_WATCH_DURATION: Duration = Duration::from_millis(100); let (mut events, _) = fs.watch(text_threads_dir(), CONTEXT_WATCH_DURATION).await; @@ -102,7 +100,6 @@ impl TextThreadStore { fs, languages, slash_commands, - telemetry, _watch_updates: cx.spawn(async move |this, cx| { async move { while events.next().await.is_some() { @@ -143,7 +140,6 @@ impl TextThreadStore { fs: project.read(cx).fs().clone(), languages: project.read(cx).languages().clone(), slash_commands: Arc::default(), - telemetry: project.read(cx).client().telemetry().clone(), _watch_updates: Task::ready(None), client: project.read(cx).client(), project: project.downgrade(), @@ -379,7 +375,6 @@ impl TextThreadStore { TextThread::local( self.languages.clone(), Some(self.project.clone()), - Some(self.telemetry.clone()), self.prompt_builder.clone(), self.slash_commands.clone(), cx, @@ -402,7 +397,7 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); + let prompt_builder = self.prompt_builder.clone(); let slash_commands = self.slash_commands.clone(); let request = self.client.request(proto::CreateContext { project_id }); @@ -419,7 +414,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -457,7 +451,6 @@ impl TextThreadStore { let fs = self.fs.clone(); let languages = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let load = cx.background_spawn({ let path = path.clone(); async move { @@ -478,7 +471,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; @@ -568,7 +560,6 @@ impl TextThreadStore { let capability = project.capability(); let language_registry = self.languages.clone(); let project = self.project.clone(); - let telemetry = self.telemetry.clone(); let request = self.client.request(proto::OpenContext { project_id, context_id: text_thread_id.to_proto(), @@ -587,7 +578,6 @@ impl TextThreadStore { prompt_builder, slash_commands, Some(project), - Some(telemetry), cx, ) })?; diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index 0a6d440a6bbc4cb1f45663d78eecb57bec43f1f5..e472521074109216bd243f5875dcc325cc9b3fed 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -39,7 +39,6 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true -telemetry_events.workspace = true thiserror.workspace = true util.workspace = true zed_env_vars.workspace = true diff --git a/crates/language_model/src/telemetry.rs b/crates/language_model/src/telemetry.rs index ccdcb0ad0cdf0d830d0163f39afad478377fe01d..6d7f4df7f644115cae7b2148f4d78fde19674344 100644 --- a/crates/language_model/src/telemetry.rs +++ b/crates/language_model/src/telemetry.rs @@ -1,41 +1,101 @@ use crate::ANTHROPIC_PROVIDER_ID; use anthropic::ANTHROPIC_API_URL; use anyhow::{Context as _, anyhow}; -use client::telemetry::Telemetry; use gpui::BackgroundExecutor; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use std::env; use std::sync::Arc; -use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; use util::ResultExt; -pub fn report_assistant_event( - event: AssistantEventData, - telemetry: Option>, - client: Arc, - model_api_key: Option, - executor: &BackgroundExecutor, +#[derive(Clone, Debug)] +pub struct AnthropicEventData { + pub completion_type: AnthropicCompletionType, + pub event: AnthropicEventType, + pub language_name: Option, + pub message_id: Option, +} + +#[derive(Clone, Debug)] +pub enum AnthropicCompletionType { + Editor, + Terminal, + Panel, +} + +#[derive(Clone, Debug)] +pub enum AnthropicEventType { + Invoked, + Response, + Accept, + Reject, +} + +impl AnthropicCompletionType { + fn as_str(&self) -> &'static str { + match self { + Self::Editor => "natural_language_completion_in_editor", + Self::Terminal => "natural_language_completion_in_terminal", + Self::Panel => "conversation_message", + } + } +} + +impl AnthropicEventType { + fn as_str(&self) -> &'static str { + match self { + Self::Invoked => "invoke", + Self::Response => "response", + Self::Accept => "accept", + Self::Reject => "reject", + } + } +} + +pub fn report_anthropic_event( + model: &Arc, + event: AnthropicEventData, + cx: &gpui::App, ) { - if let Some(telemetry) = telemetry.as_ref() { - telemetry.report_assistant_event(event.clone()); - if telemetry.metrics_enabled() && event.model_provider == ANTHROPIC_PROVIDER_ID.0 { - if let Some(api_key) = model_api_key { - executor - .spawn(async move { - report_anthropic_event(event, client, api_key) - .await - .log_err(); - }) - .detach(); - } else { - log::error!("Cannot send Anthropic telemetry because API key is missing"); - } + let reporter = AnthropicEventReporter::new(model, cx); + reporter.report(event); +} + +#[derive(Clone)] +pub struct AnthropicEventReporter { + http_client: Arc, + executor: BackgroundExecutor, + api_key: Option, + is_anthropic: bool, +} + +impl AnthropicEventReporter { + pub fn new(model: &Arc, cx: &gpui::App) -> Self { + Self { + http_client: cx.http_client(), + executor: cx.background_executor().clone(), + api_key: model.api_key(cx), + is_anthropic: model.provider_id() == ANTHROPIC_PROVIDER_ID, } } + + pub fn report(&self, event: AnthropicEventData) { + if !self.is_anthropic { + return; + } + let Some(api_key) = self.api_key.clone() else { + return; + }; + let client = self.http_client.clone(); + self.executor + .spawn(async move { + send_anthropic_event(event, client, api_key).await.log_err(); + }) + .detach(); + } } -async fn report_anthropic_event( - event: AssistantEventData, +async fn send_anthropic_event( + event: AnthropicEventData, client: Arc, api_key: String, ) -> anyhow::Result<()> { @@ -45,18 +105,10 @@ async fn report_anthropic_event( .uri(uri) .header("X-Api-Key", api_key) .header("Content-Type", "application/json"); - let serialized_event: serde_json::Value = serde_json::json!({ - "completion_type": match event.kind { - AssistantKind::Inline => "natural_language_completion_in_editor", - AssistantKind::InlineTerminal => "natural_language_completion_in_terminal", - AssistantKind::Panel => "conversation_message", - }, - "event": match event.phase { - AssistantPhase::Response => "response", - AssistantPhase::Invoked => "invoke", - AssistantPhase::Accepted => "accept", - AssistantPhase::Rejected => "reject", - }, + + let serialized_event = serde_json::json!({ + "completion_type": event.completion_type.as_str(), + "event": event.event.as_str(), "metadata": { "language_name": event.language_name, "message_id": event.message_id, diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 2c5585af5668a4b224d406413ab700bd8b2e349c..bfc60cc1ea21525effa5347431d90ee219064d24 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -4,7 +4,6 @@ mod pages; use anyhow::Result; use editor::{Editor, EditorEvent}; -use feature_flags::FeatureFlag; use fuzzy::StringMatchCandidate; use gpui::{ Action, App, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle, @@ -370,12 +369,6 @@ struct SettingsFieldMetadata { should_do_titlecase: Option, } -pub struct SettingsUiFeatureFlag; - -impl FeatureFlag for SettingsUiFeatureFlag { - const NAME: &'static str = "settings-ui"; -} - pub fn init(cx: &mut App) { init_renderers(cx); From b8e40e6fdb61fc108f2db7372b3a38655b101875 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Sun, 14 Dec 2025 20:50:48 -0800 Subject: [PATCH 22/67] Add an action for capturing your last edit as an edit prediction example (#44841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a staff-only button to the edit prediction menu for capturing your current editing session as edit prediction example file. When you click that button, it opens a markdown tab with the example. By default, the most recent change that you've made is used as the expected patch, and all of the previous events are used as the editing history. Screenshot 2025-12-14 at 6 58 33 PM Release Notes: - N/A --- Cargo.lock | 5 +- crates/edit_prediction/Cargo.toml | 1 + crates/edit_prediction/src/edit_prediction.rs | 146 +++++++++--- .../src/edit_prediction_tests.rs | 97 +++++++- crates/edit_prediction/src/example_spec.rs | 212 ++++++++++++++++++ crates/edit_prediction_cli/Cargo.toml | 1 - crates/edit_prediction_cli/src/distill.rs | 2 +- crates/edit_prediction_cli/src/example.rs | 169 ++------------ .../edit_prediction_cli/src/format_prompt.rs | 19 +- .../edit_prediction_cli/src/load_project.rs | 42 ++-- crates/edit_prediction_cli/src/main.rs | 8 +- crates/edit_prediction_cli/src/predict.rs | 6 +- .../src/retrieve_context.rs | 2 +- crates/edit_prediction_cli/src/score.rs | 6 +- crates/edit_prediction_ui/Cargo.toml | 3 + .../src/edit_prediction_button.rs | 12 +- .../src/edit_prediction_ui.rs | 208 ++++++++++++++++- 17 files changed, 711 insertions(+), 228 deletions(-) create mode 100644 crates/edit_prediction/src/example_spec.rs diff --git a/Cargo.lock b/Cargo.lock index 436da4aef8c0849a61336a9645639c17da731029..dd57996c7ef6dd711c1e67725d1bdfd86d277729 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5130,6 +5130,7 @@ dependencies = [ "postage", "pretty_assertions", "project", + "pulldown-cmark 0.12.2", "rand 0.9.2", "regex", "release_channel", @@ -5184,7 +5185,6 @@ dependencies = [ "pretty_assertions", "project", "prompt_store", - "pulldown-cmark 0.12.2", "release_channel", "reqwest_client", "serde", @@ -5256,9 +5256,11 @@ dependencies = [ "feature_flags", "fs", "futures 0.3.31", + "git", "gpui", "indoc", "language", + "log", "lsp", "markdown", "menu", @@ -5272,6 +5274,7 @@ dependencies = [ "telemetry", "text", "theme", + "time", "ui", "util", "workspace", diff --git a/crates/edit_prediction/Cargo.toml b/crates/edit_prediction/Cargo.toml index 5f1799e2dc4bb5460a900664472ad33e3035d4f1..2d5fb36a581f7bd17bb76f79791c276c86c9c631 100644 --- a/crates/edit_prediction/Cargo.toml +++ b/crates/edit_prediction/Cargo.toml @@ -41,6 +41,7 @@ open_ai.workspace = true postage.workspace = true pretty_assertions.workspace = true project.workspace = true +pulldown-cmark.workspace = true rand.workspace = true regex.workspace = true release_channel.workspace = true diff --git a/crates/edit_prediction/src/edit_prediction.rs b/crates/edit_prediction/src/edit_prediction.rs index 8b96466667bbac8fba92549487821f0d450670ac..ff15d04cc1c0f8e7bbeb7f2a29b520a8ec32097a 100644 --- a/crates/edit_prediction/src/edit_prediction.rs +++ b/crates/edit_prediction/src/edit_prediction.rs @@ -25,7 +25,7 @@ use gpui::{ prelude::*, }; use language::language_settings::all_language_settings; -use language::{Anchor, Buffer, File, Point, ToPoint}; +use language::{Anchor, Buffer, File, Point, TextBufferSnapshot, ToPoint}; use language::{BufferSnapshot, OffsetRangeExt}; use language_model::{LlmApiToken, RefreshLlmTokenListener}; use project::{Project, ProjectPath, WorktreeId}; @@ -47,7 +47,8 @@ use thiserror::Error; use util::{RangeExt as _, ResultExt as _}; use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification}; -mod cursor_excerpt; +pub mod cursor_excerpt; +pub mod example_spec; mod license_detection; pub mod mercury; mod onboarding_modal; @@ -89,6 +90,7 @@ actions!( /// Maximum number of events to track. const EVENT_COUNT_MAX: usize = 6; const CHANGE_GROUPING_LINE_SPAN: u32 = 8; +const LAST_CHANGE_GROUPING_TIME: Duration = Duration::from_secs(1); const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); @@ -265,6 +267,19 @@ impl ProjectState { .collect() } + pub fn events_split_by_pause(&self, cx: &App) -> Vec> { + self.events + .iter() + .cloned() + .chain(self.last_event.as_ref().iter().flat_map(|event| { + let (one, two) = event.split_by_pause(); + let one = one.finalize(&self.license_detection_watchers, cx); + let two = two.and_then(|two| two.finalize(&self.license_detection_watchers, cx)); + one.into_iter().chain(two) + })) + .collect() + } + fn cancel_pending_prediction( &mut self, pending_prediction: PendingPrediction, @@ -385,15 +400,21 @@ impl std::ops::Deref for BufferEditPrediction<'_> { } struct RegisteredBuffer { - snapshot: BufferSnapshot, + file: Option>, + snapshot: TextBufferSnapshot, last_position: Option, _subscriptions: [gpui::Subscription; 2], } +#[derive(Clone)] struct LastEvent { - old_snapshot: BufferSnapshot, - new_snapshot: BufferSnapshot, + old_snapshot: TextBufferSnapshot, + new_snapshot: TextBufferSnapshot, + old_file: Option>, + new_file: Option>, end_edit_anchor: Option, + snapshot_after_last_editing_pause: Option, + last_edit_time: Option, } impl LastEvent { @@ -402,19 +423,19 @@ impl LastEvent { license_detection_watchers: &HashMap>, cx: &App, ) -> Option> { - let path = buffer_path_with_id_fallback(&self.new_snapshot, cx); - let old_path = buffer_path_with_id_fallback(&self.old_snapshot, cx); - - let file = self.new_snapshot.file(); - let old_file = self.old_snapshot.file(); - - let in_open_source_repo = [file, old_file].iter().all(|file| { - file.is_some_and(|file| { - license_detection_watchers - .get(&file.worktree_id(cx)) - .is_some_and(|watcher| watcher.is_project_open_source()) - }) - }); + let path = buffer_path_with_id_fallback(self.new_file.as_ref(), &self.new_snapshot, cx); + let old_path = buffer_path_with_id_fallback(self.old_file.as_ref(), &self.old_snapshot, cx); + + let in_open_source_repo = + [self.new_file.as_ref(), self.old_file.as_ref()] + .iter() + .all(|file| { + file.is_some_and(|file| { + license_detection_watchers + .get(&file.worktree_id(cx)) + .is_some_and(|watcher| watcher.is_project_open_source()) + }) + }); let diff = language::unified_diff(&self.old_snapshot.text(), &self.new_snapshot.text()); @@ -431,10 +452,42 @@ impl LastEvent { })) } } + + pub fn split_by_pause(&self) -> (LastEvent, Option) { + let Some(boundary_snapshot) = self.snapshot_after_last_editing_pause.as_ref() else { + return (self.clone(), None); + }; + + let before = LastEvent { + old_snapshot: self.old_snapshot.clone(), + new_snapshot: boundary_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + let after = LastEvent { + old_snapshot: boundary_snapshot.clone(), + new_snapshot: self.new_snapshot.clone(), + old_file: self.old_file.clone(), + new_file: self.new_file.clone(), + end_edit_anchor: self.end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: self.last_edit_time, + }; + + (before, Some(after)) + } } -fn buffer_path_with_id_fallback(snapshot: &BufferSnapshot, cx: &App) -> Arc { - if let Some(file) = snapshot.file() { +fn buffer_path_with_id_fallback( + file: Option<&Arc>, + snapshot: &TextBufferSnapshot, + cx: &App, +) -> Arc { + if let Some(file) = file { file.full_path(cx).into() } else { Path::new(&format!("untitled-{}", snapshot.remote_id())).into() @@ -585,6 +638,17 @@ impl EditPredictionStore { .unwrap_or_default() } + pub fn edit_history_for_project_with_pause_split_last_event( + &self, + project: &Entity, + cx: &App, + ) -> Vec> { + self.projects + .get(&project.entity_id()) + .map(|project_state| project_state.events_split_by_pause(cx)) + .unwrap_or_default() + } + pub fn context_for_project<'a>( &'a self, project: &Entity, @@ -802,10 +866,13 @@ impl EditPredictionStore { match project_state.registered_buffers.entry(buffer_id) { hash_map::Entry::Occupied(entry) => entry.into_mut(), hash_map::Entry::Vacant(entry) => { - let snapshot = buffer.read(cx).snapshot(); + let buf = buffer.read(cx); + let snapshot = buf.text_snapshot(); + let file = buf.file().cloned(); let project_entity_id = project.entity_id(); entry.insert(RegisteredBuffer { snapshot, + file, last_position: None, _subscriptions: [ cx.subscribe(buffer, { @@ -840,11 +907,14 @@ impl EditPredictionStore { let project_state = self.get_or_init_project(project, cx); let registered_buffer = Self::register_buffer_impl(project_state, buffer, project, cx); - let new_snapshot = buffer.read(cx).snapshot(); + let buf = buffer.read(cx); + let new_file = buf.file().cloned(); + let new_snapshot = buf.text_snapshot(); if new_snapshot.version == registered_buffer.snapshot.version { return; } + 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 end_edit_anchor = new_snapshot .anchored_edits_since::(&old_snapshot.version) @@ -852,20 +922,16 @@ impl EditPredictionStore { .map(|(_, range)| range.end); let events = &mut project_state.events; - if let Some(LastEvent { - new_snapshot: last_new_snapshot, - end_edit_anchor: last_end_edit_anchor, - .. - }) = project_state.last_event.as_mut() - { + let now = cx.background_executor().now(); + if let Some(last_event) = project_state.last_event.as_mut() { let is_next_snapshot_of_same_buffer = old_snapshot.remote_id() - == last_new_snapshot.remote_id() - && old_snapshot.version == last_new_snapshot.version; + == last_event.new_snapshot.remote_id() + && old_snapshot.version == last_event.new_snapshot.version; let should_coalesce = is_next_snapshot_of_same_buffer && end_edit_anchor .as_ref() - .zip(last_end_edit_anchor.as_ref()) + .zip(last_event.end_edit_anchor.as_ref()) .is_some_and(|(a, b)| { let a = a.to_point(&new_snapshot); let b = b.to_point(&new_snapshot); @@ -873,8 +939,18 @@ impl EditPredictionStore { }); if should_coalesce { - *last_end_edit_anchor = end_edit_anchor; - *last_new_snapshot = new_snapshot; + let pause_elapsed = last_event + .last_edit_time + .map(|t| now.duration_since(t) >= LAST_CHANGE_GROUPING_TIME) + .unwrap_or(false); + if pause_elapsed { + last_event.snapshot_after_last_editing_pause = + Some(last_event.new_snapshot.clone()); + } + + last_event.end_edit_anchor = end_edit_anchor; + last_event.new_snapshot = new_snapshot; + last_event.last_edit_time = Some(now); return; } } @@ -888,9 +964,13 @@ impl EditPredictionStore { } project_state.last_event = Some(LastEvent { + old_file, + new_file, old_snapshot, new_snapshot, end_edit_anchor, + snapshot_after_last_editing_pause: None, + last_edit_time: Some(now), }); } diff --git a/crates/edit_prediction/src/edit_prediction_tests.rs b/crates/edit_prediction/src/edit_prediction_tests.rs index 9e4baa78ef4564ce4348ef1b51085ba0a6abdffc..5067aa0050d7a0831ca7668d17188fa6d41637b9 100644 --- a/crates/edit_prediction/src/edit_prediction_tests.rs +++ b/crates/edit_prediction/src/edit_prediction_tests.rs @@ -304,11 +304,102 @@ async fn test_request_events(cx: &mut TestAppContext) { let prediction = prediction_task.await.unwrap().unwrap().prediction.unwrap(); assert_eq!(prediction.edits.len(), 1); + assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); +} + +#[gpui::test] +async fn test_edit_history_getter_pause_splits_last_event(cx: &mut TestAppContext) { + let (ep_store, _requests) = init_test_with_fake_client(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/root", + json!({ + "foo.md": "Hello!\n\nBye\n" + }), + ) + .await; + let project = Project::test(fs, vec![path!("/root").as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + let path = project.find_project_path(path!("root/foo.md"), cx).unwrap(); + project.open_buffer(path, cx) + }) + .await + .unwrap(); + + ep_store.update(cx, |ep_store, cx| { + ep_store.register_buffer(&buffer, &project, cx); + }); + + // First burst: insert "How" + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(7..7, "How")], None, cx); + }); + + // Simulate a pause longer than the grouping threshold (e.g. 500ms). + cx.executor().advance_clock(LAST_CHANGE_GROUPING_TIME * 2); + cx.run_until_parked(); + + // Second burst: append " are you?" immediately after "How" on the same line. + // + // Keeping both bursts on the same line ensures the existing line-span coalescing logic + // groups them into a single `LastEvent`, allowing the pause-split getter to return two diffs. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(10..10, " are you?")], None, cx); + }); + + // A second edit shortly after the first post-pause edit ensures the last edit timestamp is + // advanced after the pause boundary is recorded, making pause-splitting deterministic. + buffer.update(cx, |buffer, cx| { + buffer.edit(vec![(19..19, "!")], None, cx); + }); + + // Without time-based splitting, there is one event. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project(&project, cx) + }); + assert_eq!(events.len(), 1); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); assert_eq!( - prediction.edits[0].0.to_point(&snapshot).start, - language::Point::new(1, 3) + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How are you?! + Bye + "} + ); + + // With time-based splitting, there are two distinct events. + let events = ep_store.update(cx, |ep_store, cx| { + ep_store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + assert_eq!(events.len(), 2); + let zeta_prompt::Event::BufferChange { diff, .. } = events[0].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + - + +How + Bye + "} + ); + + let zeta_prompt::Event::BufferChange { diff, .. } = events[1].as_ref(); + assert_eq!( + diff.as_str(), + indoc! {" + @@ -1,3 +1,3 @@ + Hello! + -How + +How are you?! + Bye + "} ); - assert_eq!(prediction.edits[0].1.as_ref(), " are you?"); } #[gpui::test] diff --git a/crates/edit_prediction/src/example_spec.rs b/crates/edit_prediction/src/example_spec.rs new file mode 100644 index 0000000000000000000000000000000000000000..bf221b576b890f1200c4ee3c095f73edaea71462 --- /dev/null +++ b/crates/edit_prediction/src/example_spec.rs @@ -0,0 +1,212 @@ +use serde::{Deserialize, Serialize}; +use std::{fmt::Write as _, mem, path::Path, sync::Arc}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExampleSpec { + #[serde(default)] + pub name: String, + pub repository_url: String, + pub revision: String, + #[serde(default)] + pub uncommitted_diff: String, + pub cursor_path: Arc, + pub cursor_position: String, + pub edit_history: String, + pub expected_patch: String, +} + +const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; +const EDIT_HISTORY_HEADING: &str = "Edit History"; +const CURSOR_POSITION_HEADING: &str = "Cursor Position"; +const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; +const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; +const REPOSITORY_URL_FIELD: &str = "repository_url"; +const REVISION_FIELD: &str = "revision"; + +impl ExampleSpec { + /// Format this example spec as markdown. + pub fn to_markdown(&self) -> String { + let mut markdown = String::new(); + + _ = writeln!(markdown, "# {}", self.name); + markdown.push('\n'); + + _ = writeln!(markdown, "repository_url = {}", self.repository_url); + _ = writeln!(markdown, "revision = {}", self.revision); + markdown.push('\n'); + + if !self.uncommitted_diff.is_empty() { + _ = writeln!(markdown, "## {}", UNCOMMITTED_DIFF_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.uncommitted_diff); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", EDIT_HISTORY_HEADING); + _ = writeln!(markdown); + + if self.edit_history.is_empty() { + _ = writeln!(markdown, "(No edit history)"); + _ = writeln!(markdown); + } else { + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.edit_history); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + } + + _ = writeln!(markdown, "## {}", CURSOR_POSITION_HEADING); + _ = writeln!(markdown); + _ = writeln!(markdown, "```{}", self.cursor_path.to_string_lossy()); + markdown.push_str(&self.cursor_position); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + _ = writeln!(markdown, "## {}", EXPECTED_PATCH_HEADING); + markdown.push('\n'); + _ = writeln!(markdown, "```diff"); + markdown.push_str(&self.expected_patch); + if !markdown.ends_with('\n') { + markdown.push('\n'); + } + _ = writeln!(markdown, "```"); + markdown.push('\n'); + + markdown + } + + /// Parse an example spec from markdown. + pub fn from_markdown(name: String, input: &str) -> anyhow::Result { + use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; + + let parser = Parser::new(input); + + let mut spec = ExampleSpec { + name, + repository_url: String::new(), + revision: String::new(), + uncommitted_diff: String::new(), + cursor_path: Path::new("").into(), + cursor_position: String::new(), + edit_history: String::new(), + expected_patch: String::new(), + }; + + let mut text = String::new(); + let mut block_info: CowStr = "".into(); + + #[derive(PartialEq)] + enum Section { + Start, + UncommittedDiff, + EditHistory, + CursorPosition, + ExpectedExcerpts, + ExpectedPatch, + Other, + } + + let mut current_section = Section::Start; + + for event in parser { + match event { + Event::Text(line) => { + text.push_str(&line); + + if let Section::Start = current_section + && let Some((field, value)) = line.split_once('=') + { + match field.trim() { + REPOSITORY_URL_FIELD => { + spec.repository_url = value.trim().to_string(); + } + REVISION_FIELD => { + spec.revision = value.trim().to_string(); + } + _ => {} + } + } + } + Event::End(TagEnd::Heading(HeadingLevel::H2)) => { + let title = mem::take(&mut text); + current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { + Section::UncommittedDiff + } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { + Section::EditHistory + } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { + Section::CursorPosition + } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { + Section::ExpectedPatch + } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { + Section::ExpectedExcerpts + } else { + Section::Other + }; + } + Event::End(TagEnd::Heading(HeadingLevel::H3)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(HeadingLevel::H4)) => { + mem::take(&mut text); + } + Event::End(TagEnd::Heading(level)) => { + anyhow::bail!("Unexpected heading level: {level}"); + } + Event::Start(Tag::CodeBlock(kind)) => { + match kind { + CodeBlockKind::Fenced(info) => { + block_info = info; + } + CodeBlockKind::Indented => { + anyhow::bail!("Unexpected indented codeblock"); + } + }; + } + Event::Start(_) => { + text.clear(); + block_info = "".into(); + } + Event::End(TagEnd::CodeBlock) => { + let block_info = block_info.trim(); + match current_section { + Section::UncommittedDiff => { + spec.uncommitted_diff = mem::take(&mut text); + } + Section::EditHistory => { + spec.edit_history.push_str(&mem::take(&mut text)); + } + Section::CursorPosition => { + spec.cursor_path = Path::new(block_info).into(); + spec.cursor_position = mem::take(&mut text); + } + Section::ExpectedExcerpts => { + mem::take(&mut text); + } + Section::ExpectedPatch => { + spec.expected_patch = mem::take(&mut text); + } + Section::Start | Section::Other => {} + } + } + _ => {} + } + } + + if spec.cursor_path.as_ref() == Path::new("") || spec.cursor_position.is_empty() { + anyhow::bail!("Missing cursor position codeblock"); + } + + Ok(spec) + } +} diff --git a/crates/edit_prediction_cli/Cargo.toml b/crates/edit_prediction_cli/Cargo.toml index 811808c72304f4c11a9858e61395e46024b83f1e..b6bace2a2c080626126af96f9ef51e435d6ab8fa 100644 --- a/crates/edit_prediction_cli/Cargo.toml +++ b/crates/edit_prediction_cli/Cargo.toml @@ -40,7 +40,6 @@ node_runtime.workspace = true paths.workspace = true project.workspace = true prompt_store.workspace = true -pulldown-cmark.workspace = true release_channel.workspace = true reqwest_client.workspace = true serde.workspace = true diff --git a/crates/edit_prediction_cli/src/distill.rs b/crates/edit_prediction_cli/src/distill.rs index 085c5f744a1837cbb97f4c33b6f89b6031088e2b..abfe178ae61b6da522f43c93d40b6000800d0e4d 100644 --- a/crates/edit_prediction_cli/src/distill.rs +++ b/crates/edit_prediction_cli/src/distill.rs @@ -14,7 +14,7 @@ pub async fn run_distill(example: &mut Example) -> Result<()> { ) })?; - example.expected_patch = prediction.actual_patch; + example.spec.expected_patch = prediction.actual_patch; example.prompt = None; example.predictions = Vec::new(); example.score = Vec::new(); diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs index 9499aae0c1ebce7eeca3ef05fedbcf09c960e131..e37619bf224b3fa506516714856cfbc5024ece14 100644 --- a/crates/edit_prediction_cli/src/example.rs +++ b/crates/edit_prediction_cli/src/example.rs @@ -1,6 +1,7 @@ use crate::{PredictionProvider, PromptFormat, metrics::ClassificationMetrics}; use anyhow::{Context as _, Result}; use collections::HashMap; +use edit_prediction::example_spec::ExampleSpec; use edit_prediction::udiff::OpenedBuffers; use gpui::Entity; use http_client::Url; @@ -11,23 +12,14 @@ use std::sync::Arc; use std::{ borrow::Cow, io::{Read, Write}, - mem, path::{Path, PathBuf}, }; use zeta_prompt::RelatedFile; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Example { - #[serde(default)] - pub name: String, - pub repository_url: String, - pub revision: String, - #[serde(default)] - pub uncommitted_diff: String, - pub cursor_path: Arc, - pub cursor_position: String, - pub edit_history: String, - pub expected_patch: String, + #[serde(flatten)] + pub spec: ExampleSpec, /// The full content of the file where an edit is being predicted, and the /// actual cursor offset. @@ -101,8 +93,9 @@ pub struct ExampleScore { impl Example { pub fn repo_name(&self) -> Result<(Cow<'_, str>, Cow<'_, str>)> { // git@github.com:owner/repo.git - if self.repository_url.contains('@') { + if self.spec.repository_url.contains('@') { let (owner, repo) = self + .spec .repository_url .split_once(':') .context("expected : in git url")? @@ -115,7 +108,7 @@ impl Example { )) // http://github.com/owner/repo.git } else { - let url = Url::parse(&self.repository_url)?; + let url = Url::parse(&self.spec.repository_url)?; let mut segments = url.path_segments().context("empty http url")?; let owner = segments .next() @@ -171,8 +164,8 @@ pub fn read_examples(inputs: &[PathBuf]) -> Vec { serde_json::from_str::(&content).unwrap_or_else(|error| { panic!("Failed to parse example file: {}\n{error}", path.display()) }); - if example.name.is_empty() { - example.name = filename; + if example.spec.name.is_empty() { + example.spec.name = filename; } examples.push(example); } @@ -189,8 +182,8 @@ pub fn read_examples(inputs: &[PathBuf]) -> Vec { line_ix + 1 ) }); - if example.name.is_empty() { - example.name = format!("{filename}-{line_ix}") + if example.spec.name.is_empty() { + example.spec.name = format!("{filename}-{line_ix}") } example }) @@ -225,9 +218,10 @@ pub fn write_examples(examples: &[Example], output_path: Option<&PathBuf>) { pub fn sort_examples_by_repo_and_rev(examples: &mut [Example]) { examples.sort_by(|a, b| { - a.repository_url - .cmp(&b.repository_url) - .then(b.revision.cmp(&a.revision)) + a.spec + .repository_url + .cmp(&b.spec.repository_url) + .then(b.spec.revision.cmp(&a.spec.revision)) }); } @@ -235,145 +229,22 @@ pub fn group_examples_by_repo(examples: &mut [Example]) -> Vec let mut examples_by_repo = HashMap::default(); for example in examples.iter_mut() { examples_by_repo - .entry(example.repository_url.clone()) + .entry(example.spec.repository_url.clone()) .or_insert_with(Vec::new) .push(example); } examples_by_repo.into_values().collect() } -fn parse_markdown_example(id: String, input: &str) -> Result { - use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Parser, Tag, TagEnd}; - - const UNCOMMITTED_DIFF_HEADING: &str = "Uncommitted Diff"; - const EDIT_HISTORY_HEADING: &str = "Edit History"; - const CURSOR_POSITION_HEADING: &str = "Cursor Position"; - const EXPECTED_PATCH_HEADING: &str = "Expected Patch"; - const EXPECTED_CONTEXT_HEADING: &str = "Expected Context"; - const REPOSITORY_URL_FIELD: &str = "repository_url"; - const REVISION_FIELD: &str = "revision"; - - let parser = Parser::new(input); - - let mut example = Example { - name: id, - repository_url: String::new(), - revision: String::new(), - uncommitted_diff: String::new(), - cursor_path: PathBuf::new().into(), - cursor_position: String::new(), - edit_history: String::new(), - expected_patch: String::new(), +fn parse_markdown_example(name: String, input: &str) -> Result { + let spec = ExampleSpec::from_markdown(name, input)?; + Ok(Example { + spec, buffer: None, context: None, prompt: None, predictions: Vec::new(), score: Vec::new(), state: None, - }; - - let mut text = String::new(); - let mut block_info: CowStr = "".into(); - - #[derive(PartialEq)] - enum Section { - Start, - UncommittedDiff, - EditHistory, - CursorPosition, - ExpectedExcerpts, - ExpectedPatch, - Other, - } - - let mut current_section = Section::Start; - - for event in parser { - match event { - Event::Text(line) => { - text.push_str(&line); - - if let Section::Start = current_section - && let Some((field, value)) = line.split_once('=') - { - match field.trim() { - REPOSITORY_URL_FIELD => { - example.repository_url = value.trim().to_string(); - } - REVISION_FIELD => { - example.revision = value.trim().to_string(); - } - _ => {} - } - } - } - Event::End(TagEnd::Heading(HeadingLevel::H2)) => { - let title = mem::take(&mut text); - current_section = if title.eq_ignore_ascii_case(UNCOMMITTED_DIFF_HEADING) { - Section::UncommittedDiff - } else if title.eq_ignore_ascii_case(EDIT_HISTORY_HEADING) { - Section::EditHistory - } else if title.eq_ignore_ascii_case(CURSOR_POSITION_HEADING) { - Section::CursorPosition - } else if title.eq_ignore_ascii_case(EXPECTED_PATCH_HEADING) { - Section::ExpectedPatch - } else if title.eq_ignore_ascii_case(EXPECTED_CONTEXT_HEADING) { - Section::ExpectedExcerpts - } else { - Section::Other - }; - } - Event::End(TagEnd::Heading(HeadingLevel::H3)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(HeadingLevel::H4)) => { - mem::take(&mut text); - } - Event::End(TagEnd::Heading(level)) => { - anyhow::bail!("Unexpected heading level: {level}"); - } - Event::Start(Tag::CodeBlock(kind)) => { - match kind { - CodeBlockKind::Fenced(info) => { - block_info = info; - } - CodeBlockKind::Indented => { - anyhow::bail!("Unexpected indented codeblock"); - } - }; - } - Event::Start(_) => { - text.clear(); - block_info = "".into(); - } - Event::End(TagEnd::CodeBlock) => { - let block_info = block_info.trim(); - match current_section { - Section::UncommittedDiff => { - example.uncommitted_diff = mem::take(&mut text); - } - Section::EditHistory => { - example.edit_history.push_str(&mem::take(&mut text)); - } - Section::CursorPosition => { - example.cursor_path = Path::new(block_info).into(); - example.cursor_position = mem::take(&mut text); - } - Section::ExpectedExcerpts => { - mem::take(&mut text); - } - Section::ExpectedPatch => { - example.expected_patch = mem::take(&mut text); - } - Section::Start | Section::Other => {} - } - } - _ => {} - } - } - if example.cursor_path.as_ref() == Path::new("") || example.cursor_position.is_empty() { - anyhow::bail!("Missing cursor position codeblock"); - } - - Ok(example) + }) } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index c778b708b701492b0cc85a0030a1e9d090ce0724..f543d0799b379403f0caa980df76954649e1aceb 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -23,14 +23,14 @@ pub async fn run_format_prompt( ) -> Result<()> { run_context_retrieval(example, app_state.clone(), cx.clone()).await?; - let _step_progress = Progress::global().start(Step::FormatPrompt, &example.name); + let _step_progress = Progress::global().start(Step::FormatPrompt, &example.spec.name); match prompt_format { PromptFormat::Teacher => { let prompt = TeacherPrompt::format_prompt(example); example.prompt = Some(ExamplePrompt { input: prompt, - expected_output: example.expected_patch.clone(), // TODO + expected_output: example.spec.expected_patch.clone(), // TODO format: prompt_format, }); } @@ -54,7 +54,7 @@ pub async fn run_format_prompt( .files .clone(), ep_store.edit_history_for_project(&project, cx), - example.cursor_path.clone(), + example.spec.cursor_path.clone(), example .buffer .as_ref() @@ -63,7 +63,8 @@ pub async fn run_format_prompt( )) })??; let prompt = format_zeta_prompt(&input); - let expected_output = zeta2_output_for_patch(&input, &example.expected_patch.clone())?; + let expected_output = + zeta2_output_for_patch(&input, &example.spec.expected_patch.clone())?; example.prompt = Some(ExamplePrompt { input: prompt, expected_output, @@ -85,7 +86,7 @@ impl TeacherPrompt { const MAX_HISTORY_LINES: usize = 128; pub fn format_prompt(example: &Example) -> String { - let edit_history = Self::format_edit_history(&example.edit_history); + let edit_history = Self::format_edit_history(&example.spec.edit_history); let context = Self::format_context(example); let editable_region = Self::format_editable_region(example); @@ -131,7 +132,7 @@ impl TeacherPrompt { --- a/{path} +++ b/{path} {diff}", - path = example.cursor_path.to_string_lossy(), + path = example.spec.cursor_path.to_string_lossy(), diff = diff, }; @@ -170,13 +171,13 @@ impl TeacherPrompt { fn format_editable_region(example: &Example) -> String { let mut result = String::new(); - let path_str = example.cursor_path.to_string_lossy(); + let path_str = example.spec.cursor_path.to_string_lossy(); result.push_str(&format!("`````path=\"{path_str}\"\n")); result.push_str(Self::EDITABLE_REGION_START); // TODO: control number of lines around cursor - result.push_str(&example.cursor_position); - if !example.cursor_position.ends_with('\n') { + result.push_str(&example.spec.cursor_position); + if !example.spec.cursor_position.ends_with('\n') { result.push('\n'); } diff --git a/crates/edit_prediction_cli/src/load_project.rs b/crates/edit_prediction_cli/src/load_project.rs index 4517e6ccbebca76a7ba8ce73322d6467000fc189..38f114d726d3626fac89982b7f3a98c55e92ac07 100644 --- a/crates/edit_prediction_cli/src/load_project.rs +++ b/crates/edit_prediction_cli/src/load_project.rs @@ -34,7 +34,7 @@ pub async fn run_load_project( return Ok(()); } - let progress = Progress::global().start(Step::LoadProject, &example.name); + let progress = Progress::global().start(Step::LoadProject, &example.spec.name); let project = setup_project(example, &app_state, &progress, &mut cx).await?; @@ -77,7 +77,7 @@ async fn cursor_position( ) -> Result<(Entity, Anchor)> { let language_registry = project.read_with(cx, |project, _| project.languages().clone())?; let result = language_registry - .load_language_for_file_path(&example.cursor_path) + .load_language_for_file_path(&example.spec.cursor_path) .await; if let Err(error) = result @@ -93,7 +93,7 @@ async fn cursor_position( .context("No visible worktrees") })??; - let cursor_path = RelPath::new(&example.cursor_path, PathStyle::Posix) + let cursor_path = RelPath::new(&example.spec.cursor_path, PathStyle::Posix) .context("Failed to create RelPath")? .into_arc(); let cursor_buffer = project @@ -108,10 +108,11 @@ async fn cursor_position( })? .await?; let cursor_offset_within_excerpt = example + .spec .cursor_position .find(CURSOR_MARKER) .context("missing cursor marker")?; - let mut cursor_excerpt = example.cursor_position.clone(); + let mut cursor_excerpt = example.spec.cursor_position.clone(); cursor_excerpt.replace_range( cursor_offset_within_excerpt..(cursor_offset_within_excerpt + CURSOR_MARKER.len()), "", @@ -123,10 +124,14 @@ async fn cursor_position( let (excerpt_offset, _) = matches.next().with_context(|| { format!( "\nExcerpt:\n\n{cursor_excerpt}\nBuffer text:\n{text}\n.Example: {}\nCursor excerpt did not exist in buffer.", - example.name + example.spec.name ) })?; - anyhow::ensure!(matches.next().is_none(), "More than one cursor position match found for {}", &example.name); + anyhow::ensure!( + matches.next().is_none(), + "More than one cursor position match found for {}", + &example.spec.name + ); Ok(excerpt_offset) })??; @@ -149,7 +154,7 @@ async fn setup_project( let worktree_path = setup_worktree(example, step_progress).await?; - if let Some(project) = app_state.project_cache.get(&example.repository_url) { + if let Some(project) = app_state.project_cache.get(&example.spec.repository_url) { ep_store.update(cx, |ep_store, _| { ep_store.clear_history_for_project(&project); })?; @@ -187,7 +192,7 @@ async fn setup_project( app_state .project_cache - .insert(example.repository_url.clone(), project.clone()); + .insert(example.spec.repository_url.clone(), project.clone()); let buffer_store = project.read_with(cx, |project, _| project.buffer_store().clone())?; cx.subscribe(&buffer_store, { @@ -218,7 +223,7 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Resu run_git(&repo_dir, &["init"]).await?; run_git( &repo_dir, - &["remote", "add", "origin", &example.repository_url], + &["remote", "add", "origin", &example.spec.repository_url], ) .await?; } @@ -226,7 +231,10 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Resu // Resolve the example to a revision, fetching it if needed. let revision = run_git( &repo_dir, - &["rev-parse", &format!("{}^{{commit}}", example.revision)], + &[ + "rev-parse", + &format!("{}^{{commit}}", example.spec.revision), + ], ) .await; let revision = if let Ok(revision) = revision { @@ -235,7 +243,7 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Resu step_progress.set_substatus("fetching"); if run_git( &repo_dir, - &["fetch", "--depth", "1", "origin", &example.revision], + &["fetch", "--depth", "1", "origin", &example.spec.revision], ) .await .is_err() @@ -256,7 +264,7 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Resu let worktree_path_string = worktree_path.to_string_lossy(); run_git( &repo_dir, - &["branch", "-f", &example.name, revision.as_str()], + &["branch", "-f", &example.spec.name, revision.as_str()], ) .await?; run_git( @@ -266,7 +274,7 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Resu "add", "-f", &worktree_path_string, - &example.name, + &example.spec.name, ], ) .await?; @@ -274,7 +282,7 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Resu drop(repo_lock); // Apply the uncommitted diff for this example. - if !example.uncommitted_diff.is_empty() { + if !example.spec.uncommitted_diff.is_empty() { step_progress.set_substatus("applying diff"); let mut apply_process = smol::process::Command::new("git") .current_dir(&worktree_path) @@ -283,7 +291,9 @@ async fn setup_worktree(example: &Example, step_progress: &StepProgress) -> Resu .spawn()?; let mut stdin = apply_process.stdin.take().context("Failed to get stdin")?; - stdin.write_all(example.uncommitted_diff.as_bytes()).await?; + stdin + .write_all(example.spec.uncommitted_diff.as_bytes()) + .await?; stdin.close().await?; drop(stdin); @@ -306,7 +316,7 @@ async fn apply_edit_history( project: &Entity, cx: &mut AsyncApp, ) -> Result { - edit_prediction::udiff::apply_diff(&example.edit_history, project, cx).await + edit_prediction::udiff::apply_diff(&example.spec.edit_history, project, cx).await } thread_local! { diff --git a/crates/edit_prediction_cli/src/main.rs b/crates/edit_prediction_cli/src/main.rs index 3b185103390016f60fc4f621f280d16a58c363e5..dce0fbbed57dbc4b18faf93787cfb8f2341a126a 100644 --- a/crates/edit_prediction_cli/src/main.rs +++ b/crates/edit_prediction_cli/src/main.rs @@ -267,7 +267,7 @@ fn main() { if let Err(e) = result { Progress::global().increment_failed(); let failed_example_path = - FAILED_EXAMPLES_DIR.join(format!("{}.json", example.name)); + FAILED_EXAMPLES_DIR.join(format!("{}.json", example.spec.name)); app_state .fs .write( @@ -276,8 +276,8 @@ fn main() { ) .await .unwrap(); - let err_path = - FAILED_EXAMPLES_DIR.join(format!("{}_err.txt", example.name)); + let err_path = FAILED_EXAMPLES_DIR + .join(format!("{}_err.txt", example.spec.name)); app_state .fs .write(&err_path, e.to_string().as_bytes()) @@ -298,7 +298,7 @@ fn main() { Re-run this example with: cargo run -p edit_prediction_cli -- {} \x1b[36m{}\x1b[0m "}, - example.name, + example.spec.name, e, err_path.display(), failed_example_path.display(), diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 3e6104e3a8afc3adc609df094a70fc34138c1619..aa93c5415dea091164a68b76a34242697aac70e3 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -40,7 +40,7 @@ pub async fn run_prediction( provider, PredictionProvider::Teacher | PredictionProvider::TeacherNonBatching ) { - let _step_progress = Progress::global().start(Step::Predict, &example.name); + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); if example.prompt.is_none() { run_format_prompt(example, PromptFormat::Teacher, app_state.clone(), cx).await?; @@ -52,7 +52,7 @@ pub async fn run_prediction( run_load_project(example, app_state.clone(), cx.clone()).await?; - let _step_progress = Progress::global().start(Step::Predict, &example.name); + let _step_progress = Progress::global().start(Step::Predict, &example.spec.name); if matches!( provider, @@ -90,7 +90,7 @@ pub async fn run_prediction( store.set_edit_prediction_model(model); })?; let state = example.state.as_ref().context("state must be set")?; - let run_dir = RUN_DIR.join(&example.name); + let run_dir = RUN_DIR.join(&example.spec.name); let updated_example = Arc::new(Mutex::new(example.clone())); let current_run_ix = Arc::new(AtomicUsize::new(0)); diff --git a/crates/edit_prediction_cli/src/retrieve_context.rs b/crates/edit_prediction_cli/src/retrieve_context.rs index a07c7ec8752ff987b8783c4fa15904078bd5612d..abba4504edc6c0733ffd8c0677e2e3304d8100fa 100644 --- a/crates/edit_prediction_cli/src/retrieve_context.rs +++ b/crates/edit_prediction_cli/src/retrieve_context.rs @@ -26,7 +26,7 @@ pub async fn run_context_retrieval( run_load_project(example, app_state.clone(), cx.clone()).await?; let step_progress: Arc = Progress::global() - .start(Step::Context, &example.name) + .start(Step::Context, &example.spec.name) .into(); let state = example.state.as_ref().unwrap(); diff --git a/crates/edit_prediction_cli/src/score.rs b/crates/edit_prediction_cli/src/score.rs index 314d19b67259e6a4a0fcff932826325f4366ddde..7b507e6d19c943de92eb0b22c7d24d4026789fed 100644 --- a/crates/edit_prediction_cli/src/score.rs +++ b/crates/edit_prediction_cli/src/score.rs @@ -25,9 +25,9 @@ pub async fn run_scoring( ) .await?; - let _progress = Progress::global().start(Step::Score, &example.name); + let _progress = Progress::global().start(Step::Score, &example.spec.name); - let expected_patch = parse_patch(&example.expected_patch); + let expected_patch = parse_patch(&example.spec.expected_patch); let mut scores = vec![]; @@ -71,7 +71,7 @@ pub fn print_report(examples: &[Example]) { eprintln!( "{:<30} {:>4} {:>4} {:>4} {:>9.2}% {:>7.2}% {:>7.2}% {:>9.2}", - truncate_name(&example.name, 30), + truncate_name(&example.spec.name, 30), line_match.true_positives, line_match.false_positives, line_match.false_negatives, diff --git a/crates/edit_prediction_ui/Cargo.toml b/crates/edit_prediction_ui/Cargo.toml index 63d674250001483bb8963ce62b44af524686399e..b406a450601bef908c27a48be14fe9b1f2204c08 100644 --- a/crates/edit_prediction_ui/Cargo.toml +++ b/crates/edit_prediction_ui/Cargo.toml @@ -15,6 +15,9 @@ doctest = false [dependencies] anyhow.workspace = true buffer_diff.workspace = true +git.workspace = true +log.workspace = true +time.workspace = true client.workspace = true cloud_llm_client.workspace = true codestral.workspace = true diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index b008f09ec8886086578b571b3655dac566fb6c5d..bbf9f4677df278c014379964e7bdc714e6ce78d8 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -46,7 +46,9 @@ use workspace::{ }; use zed_actions::{OpenBrowser, OpenSettingsAt}; -use crate::{RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag}; +use crate::{ + CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag, +}; actions!( edit_prediction, @@ -899,7 +901,13 @@ impl EditPredictionButton { .context(editor_focus_handle) .when( cx.has_flag::(), - |this| this.action("Rate Predictions", RatePredictions.boxed_clone()), + |this| { + this.action( + "Capture Edit Prediction Example", + CaptureExample.boxed_clone(), + ) + .action("Rate Predictions", RatePredictions.boxed_clone()) + }, ); } diff --git a/crates/edit_prediction_ui/src/edit_prediction_ui.rs b/crates/edit_prediction_ui/src/edit_prediction_ui.rs index 74c81fbfe16eec7846e70aefd59bbfeb282072dc..a762fd22aa7c32779a096fa97b2ea20ef3c9b744 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_ui.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_ui.rs @@ -3,15 +3,24 @@ mod edit_prediction_context_view; mod rate_prediction_modal; use std::any::{Any as _, TypeId}; +use std::path::Path; +use std::sync::Arc; use command_palette_hooks::CommandPaletteFilter; -use edit_prediction::{ResetOnboarding, Zeta2FeatureFlag}; +use edit_prediction::{ + EditPredictionStore, ResetOnboarding, Zeta2FeatureFlag, example_spec::ExampleSpec, +}; use edit_prediction_context_view::EditPredictionContextView; +use editor::Editor; use feature_flags::FeatureFlagAppExt as _; -use gpui::actions; +use git::repository::DiffType; +use gpui::{Window, actions}; +use language::ToPoint as _; +use log; use project::DisableAiSettings; use rate_prediction_modal::RatePredictionsModal; use settings::{Settings as _, SettingsStore}; +use text::ToOffset as _; use ui::{App, prelude::*}; use workspace::{SplitDirection, Workspace}; @@ -32,6 +41,8 @@ actions!( [ /// Opens the rate completions modal. RatePredictions, + /// Captures an ExampleSpec from the current editing session and opens it as Markdown. + CaptureExample, ] ); @@ -45,6 +56,7 @@ pub fn init(cx: &mut App) { } }); + workspace.register_action(capture_edit_prediction_example); workspace.register_action_renderer(|div, _, _, cx| { let has_flag = cx.has_flag::(); div.when(has_flag, |div| { @@ -78,6 +90,7 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { let reset_onboarding_action_types = [TypeId::of::()]; let all_action_types = [ TypeId::of::(), + TypeId::of::(), TypeId::of::(), zed_actions::OpenZedPredictOnboarding.type_id(), TypeId::of::(), @@ -124,3 +137,194 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { }) .detach(); } + +fn capture_edit_prediction_example( + workspace: &mut Workspace, + _: &CaptureExample, + window: &mut Window, + cx: &mut Context, +) { + let Some(ep_store) = EditPredictionStore::try_global(cx) else { + return; + }; + + let project = workspace.project().clone(); + + let (worktree_root, repository) = { + let project_ref = project.read(cx); + let worktree_root = project_ref + .visible_worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).abs_path()); + let repository = project_ref.active_repository(cx); + (worktree_root, repository) + }; + + let (Some(worktree_root), Some(repository)) = (worktree_root, repository) else { + log::error!("CaptureExampleSpec: missing worktree or active repository"); + return; + }; + + let repository_snapshot = repository.read(cx).snapshot(); + if worktree_root.as_ref() != repository_snapshot.work_directory_abs_path.as_ref() { + log::error!( + "repository is not at worktree root (repo={:?}, worktree={:?})", + repository_snapshot.work_directory_abs_path, + worktree_root + ); + return; + } + + let Some(repository_url) = repository_snapshot + .remote_origin_url + .clone() + .or_else(|| repository_snapshot.remote_upstream_url.clone()) + else { + log::error!("active repository has no origin/upstream remote url"); + return; + }; + + let Some(revision) = repository_snapshot + .head_commit + .as_ref() + .map(|commit| commit.sha.to_string()) + else { + log::error!("active repository has no head commit"); + return; + }; + + let mut events = ep_store.update(cx, |store, cx| { + store.edit_history_for_project_with_pause_split_last_event(&project, cx) + }); + + let Some(editor) = workspace.active_item_as::(cx) else { + log::error!("no active editor"); + return; + }; + + let Some(project_path) = editor.read(cx).project_path(cx) else { + log::error!("active editor has no project path"); + return; + }; + + let Some((buffer, cursor_anchor)) = editor + .read(cx) + .buffer() + .read(cx) + .text_anchor_for_position(editor.read(cx).selections.newest_anchor().head(), cx) + else { + log::error!("failed to resolve cursor buffer/anchor"); + return; + }; + + let snapshot = buffer.read(cx).snapshot(); + let cursor_point = cursor_anchor.to_point(&snapshot); + let (_editable_range, context_range) = + edit_prediction::cursor_excerpt::editable_and_context_ranges_for_cursor_position( + cursor_point, + &snapshot, + 100, + 50, + ); + + let cursor_path: Arc = repository + .read(cx) + .project_path_to_repo_path(&project_path, cx) + .map(|repo_path| Path::new(repo_path.as_unix_str()).into()) + .unwrap_or_else(|| Path::new(project_path.path.as_unix_str()).into()); + + let cursor_position = { + let context_start_offset = context_range.start.to_offset(&snapshot); + let cursor_offset = cursor_anchor.to_offset(&snapshot); + let cursor_offset_in_excerpt = cursor_offset.saturating_sub(context_start_offset); + let mut excerpt = snapshot.text_for_range(context_range).collect::(); + if cursor_offset_in_excerpt <= excerpt.len() { + excerpt.insert_str(cursor_offset_in_excerpt, zeta_prompt::CURSOR_MARKER); + } + excerpt + }; + + let markdown_language = workspace + .app_state() + .languages + .language_for_name("Markdown"); + + cx.spawn_in(window, async move |workspace_entity, cx| { + let markdown_language = markdown_language.await?; + + let uncommitted_diff_rx = repository.update(cx, |repository, cx| { + repository.diff(DiffType::HeadToWorktree, cx) + })?; + + let uncommitted_diff = match uncommitted_diff_rx.await { + Ok(Ok(diff)) => diff, + Ok(Err(error)) => { + log::error!("failed to compute uncommitted diff: {error:#}"); + return Ok(()); + } + Err(error) => { + log::error!("uncommitted diff channel dropped: {error:#}"); + return Ok(()); + } + }; + + let mut edit_history = String::new(); + let mut expected_patch = String::new(); + if let Some(last_event) = events.pop() { + for event in &events { + zeta_prompt::write_event(&mut edit_history, event); + if !edit_history.ends_with('\n') { + edit_history.push('\n'); + } + edit_history.push('\n'); + } + + zeta_prompt::write_event(&mut expected_patch, &last_event); + } + + let format = + time::format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]"); + let name = match format { + Ok(format) => { + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + now.format(&format) + .unwrap_or_else(|_| "unknown-time".to_string()) + } + Err(_) => "unknown-time".to_string(), + }; + + let markdown = ExampleSpec { + name, + repository_url, + revision, + uncommitted_diff, + cursor_path, + cursor_position, + edit_history, + expected_patch, + } + .to_markdown(); + + let buffer = project + .update(cx, |project, cx| project.create_buffer(false, cx))? + .await?; + buffer.update(cx, |buffer, cx| { + buffer.set_text(markdown, cx); + buffer.set_language(Some(markdown_language), cx); + })?; + + workspace_entity.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane( + Box::new( + cx.new(|cx| Editor::for_buffer(buffer, Some(project.clone()), window, cx)), + ), + None, + true, + window, + cx, + ); + }) + }) + .detach_and_log_err(cx); +} From 0c47984a1940ff7dab1183651788fff0e6b7eb95 Mon Sep 17 00:00:00 2001 From: Michael Benfield Date: Sun, 14 Dec 2025 22:55:41 -0800 Subject: [PATCH 23/67] New evals for inline assistant (#44431) Also factor out some common code in the evals. Release Notes: - N/A --------- Co-authored-by: Mikayla Maki --- crates/agent/src/edit_agent/evals.rs | 1 + crates/agent_ui/Cargo.toml | 5 +- crates/agent_ui/src/agent_ui.rs | 2 - crates/agent_ui/src/buffer_codegen.rs | 184 +++++++----- crates/agent_ui/src/evals.rs | 89 ------ crates/agent_ui/src/inline_assistant.rs | 283 +++++++++++++++--- crates/agent_ui/src/inline_prompt_editor.rs | 12 +- crates/eval_utils/src/eval_utils.rs | 18 ++ crates/feature_flags/src/flags.rs | 2 +- .../language_models/src/provider/mistral.rs | 21 +- 10 files changed, 394 insertions(+), 223 deletions(-) delete mode 100644 crates/agent_ui/src/evals.rs diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index edf8a0f671d231b3bfbd29526c256388fd41f85a..01c81e0103a2d3624c7e8eb9b9c587726fcc4876 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -1343,6 +1343,7 @@ fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput { let test = EditAgentTest::new(&mut cx).await; test.eval(eval, &mut cx).await }); + cx.quit(); match result { Ok(output) => eval_utils::EvalOutput { data: output.to_string(), diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index b235799635ce81b02fd6fcd5d4d7a53a6957eb77..38580b4d2c61597718d9fb718a20e52e84222481 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,7 +13,7 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = ["gpui/test-support", "language/test-support", "reqwest_client"] +test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support"] unit-eval = [] [dependencies] @@ -40,6 +40,7 @@ component.workspace = true context_server.workspace = true db.workspace = true editor.workspace = true +eval_utils = { workspace = true, optional = true } extension.workspace = true extension_host.workspace = true feature_flags.workspace = true @@ -71,6 +72,7 @@ postage.workspace = true project.workspace = true prompt_store.workspace = true proto.workspace = true +rand.workspace = true release_channel.workspace = true rope.workspace = true rules_library.workspace = true @@ -119,7 +121,6 @@ language_model = { workspace = true, "features" = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true -rand.workspace = true reqwest_client.workspace = true tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index cd6113bfa6c611c8d2a6b9d43294e77737b7a9ae..91fccc5fca0221cc72b0972801bf4da382cedee8 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -7,8 +7,6 @@ mod buffer_codegen; mod completion_provider; mod context; mod context_server_configuration; -#[cfg(test)] -mod evals; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index bb05d5e04deb06f82dfc8e5dae0d871648f1d11e..235aea092686e669c029e8c9c7741500c23d14cb 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -41,7 +41,6 @@ use std::{ time::Instant, }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; -use ui::SharedString; /// Use this tool to provide a message to the user when you're unable to complete a task. #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -56,16 +55,16 @@ pub struct FailureMessageInput { /// Replaces text in tags with your replacement_text. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct RewriteSectionInput { + /// The text to replace the section with. + #[serde(default)] + pub replacement_text: String, + /// A brief description of the edit you have made. /// /// The description may use markdown formatting if you wish. /// This is optional - if the edit is simple or obvious, you should leave it empty. #[serde(default)] pub description: String, - - /// The text to replace the section with. - #[serde(default)] - pub replacement_text: String, } pub struct BufferCodegen { @@ -287,8 +286,9 @@ pub struct CodegenAlternative { completion: Option, selected_text: Option, pub message_id: Option, - pub model_explanation: Option, session_id: Uuid, + pub description: Option, + pub failure: Option, } impl EventEmitter for CodegenAlternative {} @@ -346,8 +346,9 @@ impl CodegenAlternative { elapsed_time: None, completion: None, selected_text: None, - model_explanation: None, session_id, + description: None, + failure: None, _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } } @@ -920,6 +921,16 @@ impl CodegenAlternative { self.completion.clone() } + #[cfg(any(test, feature = "test-support"))] + pub fn current_description(&self) -> Option { + self.description.clone() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn current_failure(&self) -> Option { + self.failure.clone() + } + pub fn selected_text(&self) -> Option<&str> { self.selected_text.as_deref() } @@ -1133,32 +1144,69 @@ impl CodegenAlternative { } }; + enum ToolUseOutput { + Rewrite { + text: String, + description: Option, + }, + Failure(String), + } + + enum ModelUpdate { + Description(String), + Failure(String), + } + let chars_read_so_far = Arc::new(Mutex::new(0usize)); - let tool_to_text_and_message = - move |tool_use: LanguageModelToolUse| -> (Option, Option) { - let mut chars_read_so_far = chars_read_so_far.lock(); - match tool_use.name.as_ref() { - "rewrite_section" => { - let Ok(mut input) = - serde_json::from_value::(tool_use.input) - else { - return (None, None); - }; - let value = input.replacement_text[*chars_read_so_far..].to_string(); - *chars_read_so_far = input.replacement_text.len(); - (Some(value), Some(std::mem::take(&mut input.description))) - } - "failure_message" => { - let Ok(mut input) = - serde_json::from_value::(tool_use.input) - else { - return (None, None); - }; - (None, Some(std::mem::take(&mut input.message))) + let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option { + let mut chars_read_so_far = chars_read_so_far.lock(); + let is_complete = tool_use.is_input_complete; + match tool_use.name.as_ref() { + "rewrite_section" => { + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + let text = input.replacement_text[*chars_read_so_far..].to_string(); + *chars_read_so_far = input.replacement_text.len(); + let description = is_complete + .then(|| { + let desc = std::mem::take(&mut input.description); + if desc.is_empty() { None } else { Some(desc) } + }) + .flatten(); + Some(ToolUseOutput::Rewrite { text, description }) + } + "failure_message" => { + if !is_complete { + return None; } - _ => (None, None), + let Ok(mut input) = + serde_json::from_value::(tool_use.input) + else { + return None; + }; + Some(ToolUseOutput::Failure(std::mem::take(&mut input.message))) } - }; + _ => None, + } + }; + + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded::(); + + cx.spawn({ + let codegen = codegen.clone(); + async move |cx| { + while let Some(update) = message_rx.next().await { + let _ = codegen.update(cx, |this, _cx| match update { + ModelUpdate::Description(d) => this.description = Some(d), + ModelUpdate::Failure(f) => this.failure = Some(f), + }); + } + } + }) + .detach(); let mut message_id = None; let mut first_text = None; @@ -1171,24 +1219,23 @@ impl CodegenAlternative { Ok(LanguageModelCompletionEvent::StartMessage { message_id: id }) => { message_id = Some(id); } - Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) - if matches!( - tool_use.name.as_ref(), - "rewrite_section" | "failure_message" - ) => - { - let is_complete = tool_use.is_input_complete; - let (text, message) = tool_to_text_and_message(tool_use); - // Only update the model explanation if the tool use is complete. - // Otherwise the UI element bounces around as it's updated. - if is_complete { - let _ = codegen.update(cx, |this, _cx| { - this.model_explanation = message.map(Into::into); - }); - } - first_text = text; - if first_text.is_some() { - break; + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + if let Some(output) = process_tool_use(tool_use) { + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.unbounded_send(update); + } + first_text = text; + if first_text.is_some() { + break; + } } } Ok(LanguageModelCompletionEvent::UsageUpdate(token_usage)) => { @@ -1215,41 +1262,30 @@ impl CodegenAlternative { return; }; - let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded(); - - cx.spawn({ - let codegen = codegen.clone(); - async move |cx| { - while let Some(message) = message_rx.next().await { - let _ = codegen.update(cx, |this, _cx| { - this.model_explanation = message; - }); - } - } - }) - .detach(); - let move_last_token_usage = last_token_usage.clone(); let text_stream = Box::pin(futures::stream::once(async { Ok(first_text) }).chain( completion_events.filter_map(move |e| { - let tool_to_text_and_message = tool_to_text_and_message.clone(); + let process_tool_use = process_tool_use.clone(); let last_token_usage = move_last_token_usage.clone(); let total_text = total_text.clone(); let mut message_tx = message_tx.clone(); async move { match e { - Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) - if matches!( - tool_use.name.as_ref(), - "rewrite_section" | "failure_message" - ) => - { - let is_complete = tool_use.is_input_complete; - let (text, message) = tool_to_text_and_message(tool_use); - if is_complete { - // Again only send the message when complete to not get a bouncing UI element. - let _ = message_tx.send(message.map(Into::into)).await; + Ok(LanguageModelCompletionEvent::ToolUse(tool_use)) => { + let Some(output) = process_tool_use(tool_use) else { + return None; + }; + let (text, update) = match output { + ToolUseOutput::Rewrite { text, description } => { + (Some(text), description.map(ModelUpdate::Description)) + } + ToolUseOutput::Failure(message) => { + (None, Some(ModelUpdate::Failure(message))) + } + }; + if let Some(update) = update { + let _ = message_tx.send(update).await; } text.map(Ok) } diff --git a/crates/agent_ui/src/evals.rs b/crates/agent_ui/src/evals.rs deleted file mode 100644 index e82d21bd1fdb02a666c61bdf4754f27e79f92fda..0000000000000000000000000000000000000000 --- a/crates/agent_ui/src/evals.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::str::FromStr; - -use crate::inline_assistant::test::run_inline_assistant_test; - -use eval_utils::{EvalOutput, NoProcessor}; -use gpui::TestAppContext; -use language_model::{LanguageModelRegistry, SelectedModel}; -use rand::{SeedableRng as _, rngs::StdRng}; - -#[test] -#[cfg_attr(not(feature = "unit-eval"), ignore)] -fn eval_single_cursor_edit() { - eval_utils::eval(20, 1.0, NoProcessor, move || { - run_eval( - &EvalInput { - prompt: "Rename this variable to buffer_text".to_string(), - buffer: indoc::indoc! {" - struct EvalExampleStruct { - text: Strˇing, - prompt: String, - } - "} - .to_string(), - }, - &|_, output| { - let expected = indoc::indoc! {" - struct EvalExampleStruct { - buffer_text: String, - prompt: String, - } - "}; - if output == expected { - EvalOutput { - outcome: eval_utils::OutcomeKind::Passed, - data: "Passed!".to_string(), - metadata: (), - } - } else { - EvalOutput { - outcome: eval_utils::OutcomeKind::Failed, - data: format!("Failed to rename variable, output: {}", output), - metadata: (), - } - } - }, - ) - }); -} - -struct EvalInput { - buffer: String, - prompt: String, -} - -fn run_eval( - input: &EvalInput, - judge: &dyn Fn(&EvalInput, &str) -> eval_utils::EvalOutput<()>, -) -> eval_utils::EvalOutput<()> { - let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); - let mut cx = TestAppContext::build(dispatcher, None); - cx.skip_drawing(); - - let buffer_text = run_inline_assistant_test( - input.buffer.clone(), - input.prompt.clone(), - |cx| { - // Reconfigure to use a real model instead of the fake one - let model_name = std::env::var("ZED_AGENT_MODEL") - .unwrap_or("anthropic/claude-sonnet-4-latest".into()); - - let selected_model = SelectedModel::from_str(&model_name) - .expect("Invalid model format. Use 'provider/model-id'"); - - log::info!("Selected model: {selected_model:?}"); - - cx.update(|_, cx| { - LanguageModelRegistry::global(cx).update(cx, |registry, cx| { - registry.select_inline_assistant_model(Some(&selected_model), cx); - }); - }); - }, - |_cx| { - log::info!("Waiting for actual response from the LLM..."); - }, - &mut cx, - ); - - judge(input, &buffer_text) -} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 0eb96b3712623cc08632ede6c7836ed09499c02d..d036032e77d74dd905001affd9aba0010bc4f8eb 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -117,14 +117,6 @@ impl InlineAssistant { } } - #[cfg(any(test, feature = "test-support"))] - pub fn set_completion_receiver( - &mut self, - sender: mpsc::UnboundedSender>, - ) { - self._inline_assistant_completions = Some(sender); - } - pub fn register_workspace( &mut self, workspace: &Entity, @@ -1593,6 +1585,27 @@ impl InlineAssistant { .map(InlineAssistTarget::Terminal) } } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_completion_receiver( + &mut self, + sender: mpsc::UnboundedSender>, + ) { + self._inline_assistant_completions = Some(sender); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn get_codegen( + &mut self, + assist_id: InlineAssistId, + cx: &mut App, + ) -> Option> { + self.assists.get(&assist_id).map(|inline_assist| { + inline_assist + .codegen + .update(cx, |codegen, _cx| codegen.active_alternative().clone()) + }) + } } struct EditorInlineAssists { @@ -2014,8 +2027,10 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] pub mod test { + use std::sync::Arc; use agent::HistoryStore; @@ -2026,7 +2041,6 @@ pub mod test { use futures::channel::mpsc; use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use language::Buffer; - use language_model::LanguageModelRegistry; use project::Project; use prompt_store::PromptBuilder; use smol::stream::StreamExt as _; @@ -2035,13 +2049,43 @@ pub mod test { use crate::InlineAssistant; + #[derive(Debug)] + pub enum InlineAssistantOutput { + Success { + completion: Option, + description: Option, + full_buffer_text: String, + }, + Failure { + failure: String, + }, + // These fields are used for logging + #[allow(unused)] + Malformed { + completion: Option, + description: Option, + failure: Option, + }, + } + + impl InlineAssistantOutput { + pub fn buffer_text(&self) -> &str { + match self { + InlineAssistantOutput::Success { + full_buffer_text, .. + } => full_buffer_text, + _ => "", + } + } + } + pub fn run_inline_assistant_test( base_buffer: String, prompt: String, setup: SetupF, test: TestF, cx: &mut TestAppContext, - ) -> String + ) -> InlineAssistantOutput where SetupF: FnOnce(&mut gpui::VisualTestContext), TestF: FnOnce(&mut gpui::VisualTestContext), @@ -2133,39 +2177,198 @@ pub mod test { test(cx); - cx.executor() - .block_test(async { completion_rx.next().await }); + let assist_id = cx + .executor() + .block_test(async { completion_rx.next().await }) + .unwrap() + .unwrap(); + + let (completion, description, failure) = cx.update(|_, cx| { + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let codegen = inline_assistant.get_codegen(assist_id, cx).unwrap(); + + let completion = codegen.read(cx).current_completion(); + let description = codegen.read(cx).current_description(); + let failure = codegen.read(cx).current_failure(); - buffer.read_with(cx, |buffer, _| buffer.text()) + (completion, description, failure) + }) + }); + + if failure.is_some() && (completion.is_some() || description.is_some()) { + InlineAssistantOutput::Malformed { + completion, + description, + failure, + } + } else if let Some(failure) = failure { + InlineAssistantOutput::Failure { failure } + } else { + InlineAssistantOutput::Success { + completion, + description, + full_buffer_text: buffer.read_with(cx, |buffer, _| buffer.text()), + } + } } +} - #[allow(unused)] - pub fn test_inline_assistant( - base_buffer: &'static str, - llm_output: &'static str, - cx: &mut TestAppContext, - ) -> String { - run_inline_assistant_test( - base_buffer.to_string(), - "Prompt doesn't matter because we're using a fake model".to_string(), - |cx| { - cx.update(|_, cx| LanguageModelRegistry::test(cx)); - }, - |cx| { - let fake_model = cx.update(|_, cx| { - LanguageModelRegistry::global(cx) - .update(cx, |registry, _| registry.fake_model()) - }); - let fake = fake_model.as_fake(); +#[cfg(any(test, feature = "unit-eval"))] +#[cfg_attr(not(test), allow(dead_code))] +pub mod evals { + use std::str::FromStr; + + use eval_utils::{EvalOutput, NoProcessor}; + use gpui::TestAppContext; + use language_model::{LanguageModelRegistry, SelectedModel}; + use rand::{SeedableRng as _, rngs::StdRng}; + + use crate::inline_assistant::test::{InlineAssistantOutput, run_inline_assistant_test}; + + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_single_cursor_edit() { + run_eval( + 20, + 1.0, + "Rename this variable to buffer_text".to_string(), + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "} + .to_string(), + exact_buffer_match(indoc::indoc! {" + struct EvalExampleStruct { + buffer_text: String, + prompt: String, + } + "}), + ); + } - // let fake = fake_model; - fake.send_last_completion_stream_text_chunk(llm_output.to_string()); - fake.end_last_completion_stream(); + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_cant_do() { + run_eval( + 20, + 1.0, + "Rename the struct to EvalExampleStructNope", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } - // Run again to process the model's response - cx.run_until_parked(); - }, - cx, - ) + #[test] + #[cfg_attr(not(feature = "unit-eval"), ignore)] + fn eval_unclear() { + run_eval( + 20, + 1.0, + "Make exactly the change I want you to make", + indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "}, + uncertain_output, + ); + } + + fn run_eval( + iterations: usize, + expected_pass_ratio: f32, + prompt: impl Into, + buffer: impl Into, + judge: impl Fn(InlineAssistantOutput) -> eval_utils::EvalOutput<()> + Send + Sync + 'static, + ) { + let buffer = buffer.into(); + let prompt = prompt.into(); + + eval_utils::eval(iterations, expected_pass_ratio, NoProcessor, move || { + let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); + let mut cx = TestAppContext::build(dispatcher, None); + cx.skip_drawing(); + + let output = run_inline_assistant_test( + buffer.clone(), + prompt.clone(), + |cx| { + // Reconfigure to use a real model instead of the fake one + let model_name = std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-latest".into()); + + let selected_model = SelectedModel::from_str(&model_name) + .expect("Invalid model format. Use 'provider/model-id'"); + + log::info!("Selected model: {selected_model:?}"); + + cx.update(|_, cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.select_inline_assistant_model(Some(&selected_model), cx); + }); + }); + }, + |_cx| { + log::info!("Waiting for actual response from the LLM..."); + }, + &mut cx, + ); + + cx.quit(); + + judge(output) + }); + } + + fn uncertain_output(output: InlineAssistantOutput) -> EvalOutput<()> { + match &output { + o @ InlineAssistantOutput::Success { + completion, + description, + .. + } => { + if description.is_some() && completion.is_none() { + EvalOutput::passed(format!( + "Assistant produced no completion, but a description:\n{}", + description.as_ref().unwrap() + )) + } else { + EvalOutput::failed(format!("Assistant produced a completion:\n{:?}", o)) + } + } + InlineAssistantOutput::Failure { + failure: error_message, + } => EvalOutput::passed(format!( + "Assistant produced a failure message: {}", + error_message + )), + o @ InlineAssistantOutput::Malformed { .. } => { + EvalOutput::failed(format!("Assistant produced a malformed response:\n{:?}", o)) + } + } + } + + fn exact_buffer_match( + correct_output: impl Into, + ) -> impl Fn(InlineAssistantOutput) -> EvalOutput<()> { + let correct_output = correct_output.into(); + move |output| { + if output.buffer_text() == correct_output { + EvalOutput::passed("Assistant output matches") + } else { + EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + output + )) + } + } } } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index e262cda87899b0314c9fd8909f5718b4fd7dbfda..278216e28ec6304a9fc596c8456921fb1f1ebdfd 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -101,11 +101,11 @@ impl Render for PromptEditor { let left_gutter_width = gutter.full_width() + (gutter.margin / 2.0); let right_padding = editor_margins.right + RIGHT_PADDING; - let explanation = codegen - .active_alternative() - .read(cx) - .model_explanation - .clone(); + let active_alternative = codegen.active_alternative().read(cx); + let explanation = active_alternative + .description + .clone() + .or_else(|| active_alternative.failure.clone()); (left_gutter_width, right_padding, explanation) } @@ -139,7 +139,7 @@ impl Render for PromptEditor { if let Some(explanation) = &explanation { markdown.update(cx, |markdown, cx| { - markdown.reset(explanation.clone(), cx); + markdown.reset(SharedString::from(explanation), cx); }); } diff --git a/crates/eval_utils/src/eval_utils.rs b/crates/eval_utils/src/eval_utils.rs index 880b1a97e414bbc3219bdf8f7163dbf9b6c9c82b..be3294ed1490d6a602c3a5282d25dbba7d065443 100644 --- a/crates/eval_utils/src/eval_utils.rs +++ b/crates/eval_utils/src/eval_utils.rs @@ -40,6 +40,24 @@ pub struct EvalOutput { pub metadata: M, } +impl EvalOutput { + pub fn passed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Passed, + data: message.into(), + metadata: M::default(), + } + } + + pub fn failed(message: impl Into) -> Self { + EvalOutput { + outcome: OutcomeKind::Failed, + data: message.into(), + metadata: M::default(), + } + } +} + pub struct NoProcessor; impl EvalOutputProcessor for NoProcessor { type Metadata = (); diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 566d5604149567702e8739d2f3ac9fdc6f5f0de8..0d474878f999bc773baff7664ca0305c2031c171 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -18,6 +18,6 @@ impl FeatureFlag for InlineAssistantUseToolFeatureFlag { const NAME: &'static str = "inline-assistant-use-tool"; fn enabled_for_staff() -> bool { - false + true } } diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index 1078e2d7f7841d7ad05284e10a9f862236966ebc..3e99f32be8224bb2b9973feccb0ce973b58eaaed 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -17,7 +17,7 @@ use settings::{Settings, SettingsStore}; use std::collections::HashMap; use std::pin::Pin; use std::str::FromStr; -use std::sync::{Arc, LazyLock, OnceLock}; +use std::sync::{Arc, LazyLock}; use strum::IntoEnumIterator; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; @@ -31,7 +31,6 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); const CODESTRAL_API_KEY_ENV_VAR_NAME: &str = "CODESTRAL_API_KEY"; static CODESTRAL_API_KEY_ENV_VAR: LazyLock = env_var!(CODESTRAL_API_KEY_ENV_VAR_NAME); -static CODESTRAL_API_KEY: OnceLock> = OnceLock::new(); #[derive(Default, Clone, Debug, PartialEq)] pub struct MistralSettings { @@ -49,14 +48,18 @@ pub struct State { codestral_api_key_state: Entity, } +struct CodestralApiKey(Entity); +impl Global for CodestralApiKey {} + pub fn codestral_api_key(cx: &mut App) -> Entity { - return CODESTRAL_API_KEY - .get_or_init(|| { - cx.new(|_| { - ApiKeyState::new(CODESTRAL_API_URL.into(), CODESTRAL_API_KEY_ENV_VAR.clone()) - }) - }) - .clone(); + if cx.has_global::() { + cx.global::().0.clone() + } else { + let api_key_state = cx + .new(|_| ApiKeyState::new(CODESTRAL_API_URL.into(), CODESTRAL_API_KEY_ENV_VAR.clone())); + cx.set_global(CodestralApiKey(api_key_state.clone())); + api_key_state + } } impl State { From 6cab835003e5e083aa3ed8dc61223e0b2cc59026 Mon Sep 17 00:00:00 2001 From: rari404 <138394996+edlsh@users.noreply.github.com> Date: Mon, 15 Dec 2025 02:12:24 -0500 Subject: [PATCH 24/67] terminal: Remove SHLVL from terminal environment to fix incorrect shell level (#44835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #33958 ## Problem When opening a terminal in Zed, `SHLVL` incorrectly starts at 2 instead of 1. On `workspace: reload`, it increases by 2 instead of 1. ## Root Cause 1. Zed's `shell_env::capture()` spawns a login shell (`-l -i -c`) to capture the user's environment, which increments `SHLVL` 2. The captured `SHLVL` is passed through to the PTY options 3. When alacritty_terminal spawns the user's shell, it increments `SHLVL` again Result: `SHLVL` = captured value + 1 = 2 (when launched from Finder) ## Solution Remove `SHLVL` from the environment in `TerminalBuilder::new()` before passing it to alacritty_terminal. This allows the spawned shell to initialize `SHLVL` to 1 on its own, matching the behavior of standalone terminal emulators like iTerm2, Kitty, and Alacritty. ## Testing - Launch Zed from Finder → open terminal → `echo $SHLVL` → should output `1` - Launch Zed from shell → open terminal → `echo $SHLVL` → should output `1` - `workspace: reload` → open terminal → `echo $SHLVL` → should remain `1` - Tested with bash, zsh, fish Release Notes: - Fixed terminal `$SHLVL` starting at 2 instead of 1 ([#33958](https://github.com/zed-industries/zed/issues/33958)) --- crates/terminal/src/terminal.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index caca93eac5b862450cdaa2aede0fd5491eaaf58f..e6bb454fa296b65de60c25f326bba28f484450f0 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -420,6 +420,10 @@ impl TerminalBuilder { ) -> Task> { let version = release_channel::AppVersion::global(cx); let fut = async move { + // Remove SHLVL so the spawned shell initializes it to 1, matching + // the behavior of standalone terminal emulators like iTerm2/Kitty/Alacritty. + env.remove("SHLVL"); + // If the parent environment doesn't have a locale set // (As is the case when launched from a .app on MacOS), // and the Project doesn't have a locale set, then From c2c8b4b9fbf39a6d37447495718022d967483fb7 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 15 Dec 2025 08:13:08 +0100 Subject: [PATCH 25/67] terminal: Fix hyperlinks for `file://` schemas windows drive URIs (#44847) Closes https://github.com/zed-industries/zed/issues/39189 Release Notes: - Fixed terminal hyperlinking not working for `file://` schemes with windows drive letters --- crates/terminal/Cargo.toml | 2 +- crates/terminal/src/terminal_hyperlinks.rs | 28 +++++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index 1266c5a6e5c2141be4255530d062f22cd87046fd..9b4302d02fc0a101cc609274b0abc42105402174 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -38,6 +38,7 @@ smol.workspace = true task.workspace = true theme.workspace = true thiserror.workspace = true +url.workspace = true util.workspace = true urlencoding.workspace = true @@ -49,5 +50,4 @@ gpui = { workspace = true, features = ["test-support"] } rand.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } -url.workspace = true util_macros.workspace = true diff --git a/crates/terminal/src/terminal_hyperlinks.rs b/crates/terminal/src/terminal_hyperlinks.rs index 71a1634076b7081cce4f5cbaa155e7eec5d7f57e..cff27c4567cca84b2310723bf73bfda8d58c166d 100644 --- a/crates/terminal/src/terminal_hyperlinks.rs +++ b/crates/terminal/src/terminal_hyperlinks.rs @@ -14,6 +14,7 @@ use std::{ ops::{Index, Range}, time::{Duration, Instant}, }; +use url::Url; const URL_REGEX: &str = r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`']+"#; const WIDE_CHAR_SPACERS: Flags = @@ -128,8 +129,19 @@ pub(super) fn find_from_grid_point( if is_url { // Treat "file://" IRIs like file paths to ensure // that line numbers at the end of the path are - // handled correctly - if let Some(path) = maybe_url_or_path.strip_prefix("file://") { + // handled correctly. + // Use Url::to_file_path() to properly handle Windows drive letters + // (e.g., file:///C:/path -> C:\path) + if maybe_url_or_path.starts_with("file://") { + if let Ok(url) = Url::parse(&maybe_url_or_path) { + if let Ok(path) = url.to_file_path() { + return (path.to_string_lossy().into_owned(), false, word_match); + } + } + // Fallback: strip file:// prefix if URL parsing fails + let path = maybe_url_or_path + .strip_prefix("file://") + .unwrap_or(&maybe_url_or_path); (path.to_string(), false, word_match) } else { (maybe_url_or_path, true, word_match) @@ -1042,8 +1054,9 @@ mod tests { } mod file_iri { - // File IRIs have a ton of use cases, most of which we currently do not support. A few of - // those cases are documented here as tests which are expected to fail. + // File IRIs have a ton of use cases. Absolute file URIs are supported on all platforms, + // including Windows drive letters (e.g., file:///C:/path) and percent-encoded characters. + // Some cases like relative file IRIs are not supported. // See https://en.wikipedia.org/wiki/File_URI_scheme /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns** @@ -1063,7 +1076,6 @@ mod tests { mod issues { #[cfg(not(target_os = "windows"))] #[test] - #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ @@ -1092,18 +1104,12 @@ mod tests { // See https://en.wikipedia.org/wiki/File_URI_scheme // https://github.com/zed-industries/zed/issues/39189 #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"# - )] fn issue_39189() { test_file_iri!("file:///C:/test/cool/index.rs"); test_file_iri!("file:///C:/test/cool/"); } #[test] - #[should_panic( - expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"# - )] fn issue_file_iri_with_percent_encoded_characters() { // Non-space characters // file:///test/Ῥόδος/ From 82535a5481ff4e6951859b044b69555fade103be Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 15 Dec 2025 08:14:48 +0100 Subject: [PATCH 26/67] gpui: Fix use of `libc::sched_param` on musl (#44846) Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/gpui/src/platform/linux/dispatcher.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/gpui/src/platform/linux/dispatcher.rs b/crates/gpui/src/platform/linux/dispatcher.rs index d88eefd2c8a7fc648b20f7a2e520fe40158acd51..c8ae7269edd495669baa6ab0e22e745917f143b2 100644 --- a/crates/gpui/src/platform/linux/dispatcher.rs +++ b/crates/gpui/src/platform/linux/dispatcher.rs @@ -1,18 +1,21 @@ -use crate::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, - PriorityQueueSender, RealtimePriority, RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, - ThreadTaskTimings, profiler, -}; use calloop::{ EventLoop, PostAction, channel::{self, Sender}, timer::TimeoutAction, }; +use util::ResultExt; + use std::{ + mem::MaybeUninit, thread, time::{Duration, Instant}, }; -use util::ResultExt; + +use crate::{ + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, + PriorityQueueSender, RealtimePriority, RunnableVariant, THREAD_TIMINGS, TaskLabel, TaskTiming, + ThreadTaskTimings, profiler, +}; struct TimerAfter { duration: Duration, @@ -228,7 +231,10 @@ impl PlatformDispatcher for LinuxDispatcher { RealtimePriority::Other => 45, }; - let sched_param = libc::sched_param { sched_priority }; + // SAFETY: all sched_param members are valid when initialized to zero. + let mut sched_param = + unsafe { MaybeUninit::::zeroed().assume_init() }; + sched_param.sched_priority = sched_priority; // SAFETY: sched_param is a valid initialized structure let result = unsafe { libc::pthread_setschedparam(thread_id, policy, &sched_param) }; if result != 0 { From 63918b8955d0ac846a1c1d2058b026d4d9d122f5 Mon Sep 17 00:00:00 2001 From: Haojian Wu Date: Mon, 15 Dec 2025 08:16:48 +0100 Subject: [PATCH 27/67] docs: Document implemented `clangd` extensions (#44308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zed currently doesn’t support all protocol extensions implemented by `clangd`, but it does support two: - `textDocument/inactiveRegion` - `textDocument/switchSourceHeader` Release Notes: - N/A --------- Co-authored-by: Kunall Banerjee --- docs/src/languages/cpp.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/src/languages/cpp.md b/docs/src/languages/cpp.md index c20dd58335caca45a6923cc0527605d6cc4b5564..629a0ab640e245bdfec41370fa966589728c2c94 100644 --- a/docs/src/languages/cpp.md +++ b/docs/src/languages/cpp.md @@ -158,3 +158,26 @@ You can use CodeLLDB or GDB to debug native binaries. (Make sure that your build } ] ``` + +## Protocol Extensions + +Zed currently implements the following `clangd` [extensions](https://clangd.llvm.org/extensions): + +### Inactive Regions + +Automatically dims inactive sections of code due to preprocessor directives, such as `#if`, `#ifdef`, or `#ifndef` blocks that evaluate to false. + +### Switch Between Source and Header Files + +Allows switching between corresponding C++ source files (e.g., `.cpp`) and header files (e.g., `.h`). +by running the command {#action editor::SwitchSourceHeader} from the command palette or by setting +a keybinding for the `editor::SwitchSourceHeader` action. + +```json [settings] +{ + "context": "Editor", + "bindings": { + "alt-enter": "editor::SwitchSourceHeader" + } +} +``` From 3db2d03bb3cfc4ad8acaa2d644519d2470599d7f Mon Sep 17 00:00:00 2001 From: Xipeng Jin <56369076+xipeng-jin@users.noreply.github.com> Date: Mon, 15 Dec 2025 02:22:58 -0500 Subject: [PATCH 28/67] Stop spawning ACP/MCP servers with interactive shells (#44826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary: - Ensure the external agents with ACP servers start via non-interactive shells to prevent shell startup noise from corrupting JSON-RPC. - Apply the same tweak to MCP stdio transports so remote context servers aren’t affected by prompts or greetings. ### Description: Switch both ACP and MCP stdio launch paths to call `ShellBuilder::non_interactive()` before building the command. This removes `-i` on POSIX shells, suppressing prompt/title sequences that previously prefixed the first JSON line and caused `serde_json` parse failures. No functional regressions are expected: both code paths only need a shell for Windows/npm script compatibility, not for interactivity. Release Notes: - Fixed external agents that hung on “Loading…” when shell startup output broke JSON-RPC initialization. --- crates/agent_servers/src/acp.rs | 2 +- crates/context_server/src/transport/stdio_transport.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 41aff48a2092645764d598684d13c1ce61704c44..e99855fe8a7241468e93f01fe6c7b6fee161f600 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -89,7 +89,7 @@ impl AcpConnection { cx: &mut AsyncApp, ) -> Result { let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; - let builder = ShellBuilder::new(&shell, cfg!(windows)); + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); let mut child = builder.build_command(Some(command.path.display().to_string()), &command.args); child diff --git a/crates/context_server/src/transport/stdio_transport.rs b/crates/context_server/src/transport/stdio_transport.rs index 031f348294c04381f1e259b20c7cc818844953b4..e675770e9ee50df9993076e6d71c70befa118c4b 100644 --- a/crates/context_server/src/transport/stdio_transport.rs +++ b/crates/context_server/src/transport/stdio_transport.rs @@ -32,7 +32,7 @@ impl StdioTransport { cx: &AsyncApp, ) -> Result { let shell = cx.update(|cx| TerminalSettings::get(None, cx).shell.clone())?; - let builder = ShellBuilder::new(&shell, cfg!(windows)); + let builder = ShellBuilder::new(&shell, cfg!(windows)).non_interactive(); let mut command = builder.build_command(Some(binary.executable.display().to_string()), &binary.args); From 54c4302cdb2c362ec578f447209e505b9af47eeb Mon Sep 17 00:00:00 2001 From: Lay Sheth Date: Mon, 15 Dec 2025 12:54:57 +0530 Subject: [PATCH 29/67] assistant_slash_commands: Fix AI text thread path display bugs on Windows and all platforms (#41880) ## Fix incorrect directory path folding in slash command file collection **Description:** This PR fixes a bug in the `collect_files` function where the directory folding logic (used to compact chains like `.github/workflows`) failed to reset its state when traversing out of a folded branch. **The Issue:** The `folded_directory_names` accumulator was persisting across loop iterations. If the traversal moved from a folded directory (e.g., `.github/workflows`) to a sibling directory (e.g., `.zed`), the sibling would incorrectly inherit the prefix of the previously folded path, resulting in incorrect paths like `.github/.zed`. **The Fix:** * Introduced `folded_directory_path` to track the specific path currently being folded. * Added a check to reset `folded_directory_names` whenever the traversal encounters an entry that is not a child of the currently folded path. * Ensured state is cleared immediately after a folded directory is rendered. **Release Notes:** - Fixed an issue where using slash commands to collect files would sometimes display incorrect directory paths (e.g., showing `.github/.zed` instead of `.zed`) when adjacent directories were automatically folded. --------- Co-authored-by: Lukas Wirth --- Cargo.lock | 1 - crates/assistant_slash_commands/Cargo.toml | 1 - .../src/file_command.rs | 102 ++++-------------- 3 files changed, 19 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd57996c7ef6dd711c1e67725d1bdfd86d277729..f829bf138a17828d1887409b8f8ea9b48e35f3c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -834,7 +834,6 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", - "globset", "gpui", "html_to_markdown", "http_client", diff --git a/crates/assistant_slash_commands/Cargo.toml b/crates/assistant_slash_commands/Cargo.toml index 85dd92501f93fb79ba1d3f70b3a06f1077356cfa..b2a70449f449f73c7d0017c5d2ba3707e271559a 100644 --- a/crates/assistant_slash_commands/Cargo.toml +++ b/crates/assistant_slash_commands/Cargo.toml @@ -22,7 +22,6 @@ feature_flags.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true -globset.workspace = true gpui.workspace = true html_to_markdown.workspace = true http_client.workspace = true diff --git a/crates/assistant_slash_commands/src/file_command.rs b/crates/assistant_slash_commands/src/file_command.rs index a17e198ed300f00f70d35149cbe0286af3a65a57..ae4e8363b40d520b9ea33e5cba5ffa68d783ab04 100644 --- a/crates/assistant_slash_commands/src/file_command.rs +++ b/crates/assistant_slash_commands/src/file_command.rs @@ -226,10 +226,10 @@ fn collect_files( let Ok(matchers) = glob_inputs .iter() .map(|glob_input| { - custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()]) + util::paths::PathMatcher::new(&[glob_input.to_owned()], project.read(cx).path_style(cx)) .with_context(|| format!("invalid path {glob_input}")) }) - .collect::>>() + .collect::>>() else { return futures::stream::once(async { anyhow::bail!("invalid path"); @@ -250,6 +250,7 @@ fn collect_files( let worktree_id = snapshot.id(); let path_style = snapshot.path_style(); let mut directory_stack: Vec> = Vec::new(); + let mut folded_directory_path: Option> = None; let mut folded_directory_names: Arc = RelPath::empty().into(); let mut is_top_level_directory = true; @@ -277,6 +278,16 @@ fn collect_files( )))?; } + if let Some(folded_path) = &folded_directory_path { + if !entry.path.starts_with(folded_path) { + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; + if directory_stack.is_empty() { + is_top_level_directory = true; + } + } + } + let filename = entry.path.file_name().unwrap_or_default().to_string(); if entry.is_dir() { @@ -292,13 +303,17 @@ fn collect_files( folded_directory_names = folded_directory_names.join(RelPath::unix(&filename).unwrap()); } + folded_directory_path = Some(entry.path.clone()); continue; } } else { // Skip empty directories folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; continue; } + + // Render the directory (either folded or normal) if folded_directory_names.is_empty() { let label = if is_top_level_directory { is_top_level_directory = false; @@ -334,6 +349,8 @@ fn collect_files( }, )))?; directory_stack.push(entry.path.clone()); + folded_directory_names = RelPath::empty().into(); + folded_directory_path = None; } events_tx.unbounded_send(Ok(SlashCommandEvent::Content( SlashCommandContent::Text { @@ -447,87 +464,6 @@ pub fn build_entry_output_section( } } -/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix -/// check. Only subpaths pass the prefix check, rather than any prefix. -mod custom_path_matcher { - use globset::{Glob, GlobSet, GlobSetBuilder}; - use std::fmt::Debug as _; - use util::{paths::SanitizedPath, rel_path::RelPath}; - - #[derive(Clone, Debug, Default)] - pub struct PathMatcher { - sources: Vec, - sources_with_trailing_slash: Vec, - glob: GlobSet, - } - - impl std::fmt::Display for PathMatcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.sources.fmt(f) - } - } - - impl PartialEq for PathMatcher { - fn eq(&self, other: &Self) -> bool { - self.sources.eq(&other.sources) - } - } - - impl Eq for PathMatcher {} - - impl PathMatcher { - pub fn new(globs: &[String]) -> Result { - let globs = globs - .iter() - .map(|glob| Glob::new(&SanitizedPath::new(glob).to_string())) - .collect::, _>>()?; - let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect(); - let sources_with_trailing_slash = globs - .iter() - .map(|glob| glob.glob().to_string() + "/") - .collect(); - let mut glob_builder = GlobSetBuilder::new(); - for single_glob in globs { - glob_builder.add(single_glob); - } - let glob = glob_builder.build()?; - Ok(PathMatcher { - glob, - sources, - sources_with_trailing_slash, - }) - } - - pub fn is_match(&self, other: &RelPath) -> bool { - self.sources - .iter() - .zip(self.sources_with_trailing_slash.iter()) - .any(|(source, with_slash)| { - let as_bytes = other.as_unix_str().as_bytes(); - let with_slash = if source.ends_with('/') { - source.as_bytes() - } else { - with_slash.as_bytes() - }; - - as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes()) - }) - || self.glob.is_match(other.as_std_path()) - || self.check_with_end_separator(other) - } - - fn check_with_end_separator(&self, path: &RelPath) -> bool { - let path_str = path.as_unix_str(); - let separator = "/"; - if path_str.ends_with(separator) { - false - } else { - self.glob.is_match(path_str.to_string() + separator) - } - } - } -} - pub fn append_buffer_to_output( buffer: &BufferSnapshot, path: Option<&str>, From 6067436e9b52ba68b811e163ab21513be8869496 Mon Sep 17 00:00:00 2001 From: Vasyl Protsiv Date: Mon, 15 Dec 2025 09:25:50 +0200 Subject: [PATCH 30/67] rope: Optimize rope construction (#44345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I have noticed you care about `SumTree` (and `Rope`) construction performance, hence using rayon for parallelism and careful `Chunk` splitting to avoid reallocation in `Rope::push`. It seemed strange to me that using multi-threading is that beneficial there, so I tried to investigate why the serial version (`SumTree::from_iter`) is slow in the first place. From my analysis I believe there are two main factors here: 1. `SumTree::from_iter` stores temporary `Node` values in a vector instead of heap-allocating them immediately and storing `SumTree` directly, as `SumTree::from_par_iter` does. 2. `Chunk::new` is quite slow: for some reason the compiler does not vectorize it and seems to struggle to optimize u128 shifts (at least on x86_64). For (1) the solution is simple: allocate `Node` immediately after construction, just like `SumTree::from_par_iter`. For (2) I was able to get better codegen by rewriting it into a simpler per-byte loop and splitting computation into smaller chunks to avoid slow u128 shifts. There was a similar effort recently in #43193 using portable_simd (currently nightly only) to optimize `Chunk::push_str`. From what I understand from that discussion, you seem okay with hand-rolled SIMD for specific architectures. If so, then I also provide sse2 implementation for x86_64. Feel free to remove it if you think this is unnecessary. To test performance I used a big CSV file (~1GB, mostly ASCII) and measured `Rope::from` with this program: ```rust fn main() { let text = std::fs::read_to_string("big.csv").unwrap(); let start = std::time::Instant::now(); let rope = rope::Rope::from(text); println!("{}ms, {}", start.elapsed().as_millis(), rope.len()); } ``` Here are results on my machine (Ryzen 7 4800H) | | Parallel | Serial | | ------------ | -------- | ------ | | Before | 1123ms | 9154ms | | After | 497ms | 2081ms | | After (sse2) | 480ms | 1454ms | Since serial performance is now much closer to parallel, I also increased `PARALLEL_THRESHOLD` to 1000. In my tests the parallel version starts to beat serial at around 150 KB strings. This constant might require more tweaking and testing though, especially on ARM64.
cargo bench (SSE2 vs before) ``` Running benches\rope_benchmark.rs (D:\zed\target\release\deps\rope_benchmark-3f8476f7dfb79154.exe) Gnuplot not found, using plotters backend push/4096 time: [43.592 µs 43.658 µs 43.733 µs] thrpt: [89.320 MiB/s 89.473 MiB/s 89.610 MiB/s] change: time: [-78.523% -78.222% -77.854%] (p = 0.00 < 0.05) thrpt: [+351.56% +359.19% +365.61%] Performance has improved. Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) high mild 1 (1.00%) high severe push/65536 time: [632.36 µs 634.03 µs 635.76 µs] thrpt: [98.308 MiB/s 98.576 MiB/s 98.836 MiB/s] change: time: [-51.521% -50.850% -50.325%] (p = 0.00 < 0.05) thrpt: [+101.31% +103.46% +106.28%] Performance has improved. Found 18 outliers among 100 measurements (18.00%) 11 (11.00%) low mild 6 (6.00%) high mild 1 (1.00%) high severe append/4096 time: [11.635 µs 11.664 µs 11.698 µs] thrpt: [333.92 MiB/s 334.89 MiB/s 335.72 MiB/s] change: time: [-24.543% -23.925% -22.660%] (p = 0.00 < 0.05) thrpt: [+29.298% +31.450% +32.525%] Performance has improved. Found 12 outliers among 100 measurements (12.00%) 2 (2.00%) low mild 2 (2.00%) high mild 8 (8.00%) high severe append/65536 time: [1.1287 µs 1.1324 µs 1.1360 µs] thrpt: [53.727 GiB/s 53.900 GiB/s 54.075 GiB/s] change: time: [-44.153% -37.614% -29.834%] (p = 0.00 < 0.05) thrpt: [+42.518% +60.292% +79.061%] Performance has improved. slice/4096 time: [28.340 µs 28.372 µs 28.406 µs] thrpt: [137.52 MiB/s 137.68 MiB/s 137.83 MiB/s] change: time: [-8.0798% -6.3955% -4.4109%] (p = 0.00 < 0.05) thrpt: [+4.6145% +6.8325% +8.7900%] Performance has improved. Found 3 outliers among 100 measurements (3.00%) 1 (1.00%) low mild 1 (1.00%) high mild 1 (1.00%) high severe slice/65536 time: [527.51 µs 528.17 µs 528.90 µs] thrpt: [118.17 MiB/s 118.33 MiB/s 118.48 MiB/s] change: time: [-53.819% -45.431% -34.578%] (p = 0.00 < 0.05) thrpt: [+52.853% +83.256% +116.54%] Performance has improved. Found 5 outliers among 100 measurements (5.00%) 1 (1.00%) low severe 3 (3.00%) low mild 1 (1.00%) high mild bytes_in_range/4096 time: [3.2545 µs 3.2646 µs 3.2797 µs] thrpt: [1.1631 GiB/s 1.1685 GiB/s 1.1721 GiB/s] change: time: [-3.4829% -2.4391% -1.7166%] (p = 0.00 < 0.05) thrpt: [+1.7466% +2.5001% +3.6085%] Performance has improved. Found 8 outliers among 100 measurements (8.00%) 6 (6.00%) high mild 2 (2.00%) high severe bytes_in_range/65536 time: [80.770 µs 80.832 µs 80.904 µs] thrpt: [772.52 MiB/s 773.21 MiB/s 773.80 MiB/s] change: time: [-1.8710% -1.3843% -0.9044%] (p = 0.00 < 0.05) thrpt: [+0.9126% +1.4037% +1.9067%] Change within noise threshold. Found 8 outliers among 100 measurements (8.00%) 5 (5.00%) high mild 3 (3.00%) high severe chars/4096 time: [790.50 ns 791.10 ns 791.88 ns] thrpt: [4.8173 GiB/s 4.8220 GiB/s 4.8257 GiB/s] change: time: [+0.4318% +1.4558% +2.0256%] (p = 0.00 < 0.05) thrpt: [-1.9854% -1.4349% -0.4299%] Change within noise threshold. Found 6 outliers among 100 measurements (6.00%) 1 (1.00%) low severe 1 (1.00%) low mild 2 (2.00%) high mild 2 (2.00%) high severe chars/65536 time: [12.672 µs 12.688 µs 12.703 µs] thrpt: [4.8046 GiB/s 4.8106 GiB/s 4.8164 GiB/s] change: time: [-2.7794% -1.2987% -0.2020%] (p = 0.04 < 0.05) thrpt: [+0.2025% +1.3158% +2.8588%] Change within noise threshold. Found 15 outliers among 100 measurements (15.00%) 1 (1.00%) low mild 12 (12.00%) high mild 2 (2.00%) high severe clip_point/4096 time: [63.009 µs 63.126 µs 63.225 µs] thrpt: [61.783 MiB/s 61.880 MiB/s 61.995 MiB/s] change: time: [+2.0484% +3.2218% +5.2181%] (p = 0.00 < 0.05) thrpt: [-4.9593% -3.1213% -2.0073%] Performance has regressed. Found 13 outliers among 100 measurements (13.00%) 12 (12.00%) low mild 1 (1.00%) high severe Benchmarking clip_point/65536: Warming up for 3.0000 s Warning: Unable to complete 100 samples in 5.0s. You may wish to increase target time to 7.7s, enable flat sampling, or reduce sample count to 50. clip_point/65536 time: [1.2420 ms 1.2430 ms 1.2439 ms] thrpt: [50.246 MiB/s 50.283 MiB/s 50.322 MiB/s] change: time: [-0.3495% -0.0401% +0.1990%] (p = 0.80 > 0.05) thrpt: [-0.1986% +0.0401% +0.3507%] No change in performance detected. Found 7 outliers among 100 measurements (7.00%) 6 (6.00%) high mild 1 (1.00%) high severe point_to_offset/4096 time: [16.104 µs 16.119 µs 16.134 µs] thrpt: [242.11 MiB/s 242.33 MiB/s 242.56 MiB/s] change: time: [-1.3816% -0.2497% +2.2181%] (p = 0.84 > 0.05) thrpt: [-2.1699% +0.2503% +1.4009%] No change in performance detected. Found 6 outliers among 100 measurements (6.00%) 3 (3.00%) low mild 1 (1.00%) high mild 2 (2.00%) high severe point_to_offset/65536 time: [356.28 µs 356.57 µs 356.86 µs] thrpt: [175.14 MiB/s 175.28 MiB/s 175.42 MiB/s] change: time: [-3.7072% -2.3338% -1.4742%] (p = 0.00 < 0.05) thrpt: [+1.4962% +2.3896% +3.8499%] Performance has improved. Found 1 outliers among 100 measurements (1.00%) 1 (1.00%) low mild cursor/4096 time: [18.893 µs 18.934 µs 18.974 µs] thrpt: [205.87 MiB/s 206.31 MiB/s 206.76 MiB/s] change: time: [-2.3645% -2.0729% -1.7931%] (p = 0.00 < 0.05) thrpt: [+1.8259% +2.1168% +2.4218%] Performance has improved. Found 12 outliers among 100 measurements (12.00%) 12 (12.00%) high mild cursor/65536 time: [459.97 µs 460.40 µs 461.04 µs] thrpt: [135.56 MiB/s 135.75 MiB/s 135.88 MiB/s] change: time: [-5.7445% -4.2758% -3.1344%] (p = 0.00 < 0.05) thrpt: [+3.2358% +4.4668% +6.0946%] Performance has improved. Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) high mild 1 (1.00%) high severe append many/small to large time: [38.364 ms 38.620 ms 38.907 ms] thrpt: [313.75 MiB/s 316.08 MiB/s 318.19 MiB/s] change: time: [-0.2042% +1.0954% +2.3334%] (p = 0.10 > 0.05) thrpt: [-2.2802% -1.0836% +0.2046%] No change in performance detected. Found 21 outliers among 100 measurements (21.00%) 9 (9.00%) high mild 12 (12.00%) high severe append many/large to small time: [48.045 ms 48.322 ms 48.648 ms] thrpt: [250.92 MiB/s 252.62 MiB/s 254.07 MiB/s] change: time: [-6.5298% -5.6919% -4.8532%] (p = 0.00 < 0.05) thrpt: [+5.1007% +6.0354% +6.9859%] Performance has improved. Found 11 outliers among 100 measurements (11.00%) 2 (2.00%) high mild 9 (9.00%) high severe ```
Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/rope/src/chunk.rs | 61 ++++++++++++++++++++++++++------- crates/rope/src/rope.rs | 2 +- crates/sum_tree/src/sum_tree.rs | 22 ++++++------ 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/crates/rope/src/chunk.rs b/crates/rope/src/chunk.rs index a2a8e8d58df2d5ddc3336e8e56dd8446f4dcf118..c1916768c1f8a0980fb4d5aa1b718483b08c6087 100644 --- a/crates/rope/src/chunk.rs +++ b/crates/rope/src/chunk.rs @@ -47,22 +47,59 @@ impl Chunk { #[inline(always)] pub fn new(text: &str) -> Self { - let mut this = Chunk::default(); - this.push_str(text); - this + let text = ArrayString::from(text).unwrap(); + + const CHUNK_SIZE: usize = 8; + + let mut chars_bytes = [0; MAX_BASE / CHUNK_SIZE]; + let mut newlines_bytes = [0; MAX_BASE / CHUNK_SIZE]; + let mut tabs_bytes = [0; MAX_BASE / CHUNK_SIZE]; + let mut chars_utf16_bytes = [0; MAX_BASE / CHUNK_SIZE]; + + let mut chunk_ix = 0; + + let mut bytes = text.as_bytes(); + while !bytes.is_empty() { + let (chunk, rest) = bytes.split_at(bytes.len().min(CHUNK_SIZE)); + bytes = rest; + + let mut chars = 0; + let mut newlines = 0; + let mut tabs = 0; + let mut chars_utf16 = 0; + + for (ix, &b) in chunk.iter().enumerate() { + chars |= (util::is_utf8_char_boundary(b) as u8) << ix; + newlines |= ((b == b'\n') as u8) << ix; + tabs |= ((b == b'\t') as u8) << ix; + // b >= 240 when we are at the first byte of the 4 byte encoded + // utf-8 code point (U+010000 or greater) it means that it would + // be encoded as two 16-bit code units in utf-16 + chars_utf16 |= ((b >= 240) as u8) << ix; + } + + chars_bytes[chunk_ix] = chars; + newlines_bytes[chunk_ix] = newlines; + tabs_bytes[chunk_ix] = tabs; + chars_utf16_bytes[chunk_ix] = chars_utf16; + + chunk_ix += 1; + } + + let chars = Bitmap::from_le_bytes(chars_bytes); + + Chunk { + text, + chars, + chars_utf16: (Bitmap::from_le_bytes(chars_utf16_bytes) << 1) | chars, + newlines: Bitmap::from_le_bytes(newlines_bytes), + tabs: Bitmap::from_le_bytes(tabs_bytes), + } } #[inline(always)] pub fn push_str(&mut self, text: &str) { - for (char_ix, c) in text.char_indices() { - let ix = self.text.len() + char_ix; - self.chars |= 1 << ix; - self.chars_utf16 |= 1 << ix; - self.chars_utf16 |= (c.len_utf16() as Bitmap) << ix; - self.newlines |= ((c == '\n') as Bitmap) << ix; - self.tabs |= ((c == '\t') as Bitmap) << ix; - } - self.text.push_str(text); + self.append(Chunk::new(text).as_slice()); } #[inline(always)] diff --git a/crates/rope/src/rope.rs b/crates/rope/src/rope.rs index 50f9ba044d90072aa9c6fc2fc4abfd6d0e6b98cb..fba7b96aca83fa05c0d6f3e7992ad7443ec7958a 100644 --- a/crates/rope/src/rope.rs +++ b/crates/rope/src/rope.rs @@ -227,7 +227,7 @@ impl Rope { #[cfg(all(test, not(rust_analyzer)))] const PARALLEL_THRESHOLD: usize = 4; #[cfg(not(all(test, not(rust_analyzer))))] - const PARALLEL_THRESHOLD: usize = 4 * (2 * sum_tree::TREE_BASE); + const PARALLEL_THRESHOLD: usize = 84 * (2 * sum_tree::TREE_BASE); if new_chunks.len() >= PARALLEL_THRESHOLD { self.chunks diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index bfc4587969ec67bbda2fb90d34550c7d464317c9..6a76b73c3bbfb922e1b46fc1e228209ddf05b4a5 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -250,11 +250,11 @@ impl SumTree { ::add_summary(&mut summary, item_summary, cx); } - nodes.push(Node::Leaf { + nodes.push(SumTree(Arc::new(Node::Leaf { summary, items, item_summaries, - }); + }))); } let mut parent_nodes = Vec::new(); @@ -263,25 +263,27 @@ impl SumTree { height += 1; let mut current_parent_node = None; for child_node in nodes.drain(..) { - let parent_node = current_parent_node.get_or_insert_with(|| Node::Internal { - summary: ::zero(cx), - height, - child_summaries: ArrayVec::new(), - child_trees: ArrayVec::new(), + let parent_node = current_parent_node.get_or_insert_with(|| { + SumTree(Arc::new(Node::Internal { + summary: ::zero(cx), + height, + child_summaries: ArrayVec::new(), + child_trees: ArrayVec::new(), + })) }); let Node::Internal { summary, child_summaries, child_trees, .. - } = parent_node + } = Arc::get_mut(&mut parent_node.0).unwrap() else { unreachable!() }; let child_summary = child_node.summary(); ::add_summary(summary, child_summary, cx); child_summaries.push(child_summary.clone()); - child_trees.push(Self(Arc::new(child_node))); + child_trees.push(child_node); if child_trees.len() == 2 * TREE_BASE { parent_nodes.extend(current_parent_node.take()); @@ -295,7 +297,7 @@ impl SumTree { Self::new(cx) } else { debug_assert_eq!(nodes.len(), 1); - Self(Arc::new(nodes.pop().unwrap())) + nodes.pop().unwrap() } } From 38f4e21fe8bb72ceac5f601947919fd4d7d5f61e Mon Sep 17 00:00:00 2001 From: Dmitry Nefedov <113844030+dangooddd@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:40:45 +0300 Subject: [PATCH 31/67] themes: Improve Gruvbox terminal colors (#38536) This PR makes zed terminal gruvbox theme consistent with other terminals themes. Current ansi colors is broken, by not only not using colors from original palette, but also by inverting of bright/normal colors... Currently I took colors from Ghostty (Iterm2 themes), making sure that they are consistent with palette. For dim colors I darken them by decreasing "Value" from HSV representation of colors by 30%. I am open to discussion and willing to implement those changes for light theme after receiving feedback. Examples below: | Before | After | | - | - | | image | image | Script to reproduce: ```bash #!/bin/bash echo "Normal ANSI Colors:" for i in {30..37}; do printf "\e[${i}m Text \e[0m" done echo "" echo "Bright ANSI Colors (Foreground):" for i in {90..97}; do printf "\e[${i}m Text \e[0m" done echo "" echo "Bright ANSI Colors (Background):" for i in {100..107}; do printf "\e[${i}m Text \e[0m" done echo "" echo "Foreground and Background Combinations:" for fg in {30..37}; do for bg in {40..47}; do printf "\e[${fg};${bg}m FB \e[0m" done echo "" done echo "Bright Foreground and Background Combinations:" for fg in {90..97}; do for bg in {100..107}; do printf "\e[${fg};${bg}m FB \e[0m" done echo "" done ``` Release Notes: - Fixed ANSI colors definitions in the Gruvbox theme (thanks @dangooddd) --------- Co-authored-by: Oleksiy Syvokon --- assets/themes/gruvbox/gruvbox.json | 292 ++++++++++++++--------------- 1 file changed, 146 insertions(+), 146 deletions(-) diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 90973fd6c3469a1ef0e698d629376dfaaf3b5a76..16ae188712f7a800ab4fb8a81a2d24cac99da56b 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -71,33 +71,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#282828ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#282828ff", + "terminal.dim_foreground": "#766b5dff", "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -478,33 +478,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#1d2021ff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#1d2021ff", - "terminal.ansi.black": "#1d2021ff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -885,33 +885,33 @@ "editor.document_highlight.read_background": "#83a5981a", "editor.document_highlight.write_background": "#92847466", "terminal.background": "#32302fff", - "terminal.foreground": "#fbf1c7ff", + "terminal.foreground": "#ebdbb2ff", "terminal.bright_foreground": "#fbf1c7ff", - "terminal.dim_foreground": "#32302fff", - "terminal.ansi.black": "#32302fff", - "terminal.ansi.bright_black": "#73675eff", + "terminal.dim_foreground": "#766b5dff", + "terminal.ansi.black": "#282828ff", + "terminal.ansi.bright_black": "#928374ff", "terminal.ansi.dim_black": "#fbf1c7ff", - "terminal.ansi.red": "#fb4a35ff", - "terminal.ansi.bright_red": "#93201dff", - "terminal.ansi.dim_red": "#ffaa95ff", - "terminal.ansi.green": "#b7bb26ff", - "terminal.ansi.bright_green": "#605c1bff", - "terminal.ansi.dim_green": "#e0dc98ff", - "terminal.ansi.yellow": "#f9bd2fff", - "terminal.ansi.bright_yellow": "#91611bff", - "terminal.ansi.dim_yellow": "#fedc9bff", - "terminal.ansi.blue": "#83a598ff", - "terminal.ansi.bright_blue": "#414f4aff", - "terminal.ansi.dim_blue": "#c0d2cbff", - "terminal.ansi.magenta": "#d3869bff", - "terminal.ansi.bright_magenta": "#8e5868ff", - "terminal.ansi.dim_magenta": "#ff9ebbff", - "terminal.ansi.cyan": "#8ec07cff", - "terminal.ansi.bright_cyan": "#45603eff", - "terminal.ansi.dim_cyan": "#c7dfbdff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#fb4934ff", + "terminal.ansi.dim_red": "#8e1814ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#b8bb26ff", + "terminal.ansi.dim_green": "#6a6912ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#fabd2fff", + "terminal.ansi.dim_yellow": "#966a17ff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#83a598ff", + "terminal.ansi.dim_blue": "#305d5fff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#d3869bff", + "terminal.ansi.dim_magenta": "#7c455eff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#8ec07cff", + "terminal.ansi.dim_cyan": "#496e4aff", + "terminal.ansi.white": "#a89984ff", + "terminal.ansi.bright_white": "#fbf1c7ff", + "terminal.ansi.dim_white": "#766b5dff", "link_text.hover": "#83a598ff", "version_control.added": "#b7bb26ff", "version_control.modified": "#f9bd2fff", @@ -1295,30 +1295,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#fbf1c7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#0b6678ff", - "terminal.ansi.dim_black": "#5f5650ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#fbf1c7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -1702,30 +1702,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f9f5d7ff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f9f5d7ff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f9f5d7ff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", @@ -2109,30 +2109,30 @@ "terminal.foreground": "#282828ff", "terminal.bright_foreground": "#282828ff", "terminal.dim_foreground": "#f2e5bcff", - "terminal.ansi.black": "#282828ff", - "terminal.ansi.bright_black": "#73675eff", - "terminal.ansi.dim_black": "#f2e5bcff", - "terminal.ansi.red": "#9d0308ff", - "terminal.ansi.bright_red": "#db8b7aff", - "terminal.ansi.dim_red": "#4e1207ff", - "terminal.ansi.green": "#797410ff", - "terminal.ansi.bright_green": "#bfb787ff", - "terminal.ansi.dim_green": "#3e3a11ff", - "terminal.ansi.yellow": "#b57615ff", - "terminal.ansi.bright_yellow": "#e2b88bff", - "terminal.ansi.dim_yellow": "#5c3a12ff", - "terminal.ansi.blue": "#0b6678ff", - "terminal.ansi.bright_blue": "#8fb0baff", - "terminal.ansi.dim_blue": "#14333bff", - "terminal.ansi.magenta": "#8f3e71ff", - "terminal.ansi.bright_magenta": "#c76da0ff", - "terminal.ansi.dim_magenta": "#5c2848ff", - "terminal.ansi.cyan": "#437b59ff", - "terminal.ansi.bright_cyan": "#9fbca8ff", - "terminal.ansi.dim_cyan": "#253e2eff", - "terminal.ansi.white": "#f2e5bcff", - "terminal.ansi.bright_white": "#ffffffff", - "terminal.ansi.dim_white": "#b0a189ff", + "terminal.ansi.black": "#fbf1c7ff", + "terminal.ansi.bright_black": "#928374ff", + "terminal.ansi.dim_black": "#7c6f64ff", + "terminal.ansi.red": "#cc241dff", + "terminal.ansi.bright_red": "#9d0006ff", + "terminal.ansi.dim_red": "#c31c16ff", + "terminal.ansi.green": "#98971aff", + "terminal.ansi.bright_green": "#79740eff", + "terminal.ansi.dim_green": "#929015ff", + "terminal.ansi.yellow": "#d79921ff", + "terminal.ansi.bright_yellow": "#b57614ff", + "terminal.ansi.dim_yellow": "#cf8e1aff", + "terminal.ansi.blue": "#458588ff", + "terminal.ansi.bright_blue": "#076678ff", + "terminal.ansi.dim_blue": "#356f77ff", + "terminal.ansi.magenta": "#b16286ff", + "terminal.ansi.bright_magenta": "#8f3f71ff", + "terminal.ansi.dim_magenta": "#a85580ff", + "terminal.ansi.cyan": "#689d6aff", + "terminal.ansi.bright_cyan": "#427b58ff", + "terminal.ansi.dim_cyan": "#5f9166ff", + "terminal.ansi.white": "#7c6f64ff", + "terminal.ansi.bright_white": "#282828ff", + "terminal.ansi.dim_white": "#282828ff", "link_text.hover": "#0b6678ff", "version_control.added": "#797410ff", "version_control.modified": "#b57615ff", From be57307a6fc094e211a37a634a57e58e9cfb9b7f Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 15 Dec 2025 00:55:03 -0800 Subject: [PATCH 32/67] Inline assistant finishing touches (#44851) Tighten up evals, make assistant less talkative, get them passing a bit more, improve telemetry, stream in failure messages, and turn it on for staff. Release Notes: - N/A --- crates/agent_ui/src/buffer_codegen.rs | 34 +++++--------- crates/agent_ui/src/inline_assistant.rs | 50 ++++++++++++--------- crates/agent_ui/src/inline_prompt_editor.rs | 24 +++++++--- crates/feature_flags/src/flags.rs | 4 -- crates/markdown/src/markdown.rs | 2 +- 5 files changed, 60 insertions(+), 54 deletions(-) diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 235aea092686e669c029e8c9c7741500c23d14cb..d8d0efda0fbd70153b02452f6281ee66b90eca92 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -42,29 +42,24 @@ use std::{ }; use streaming_diff::{CharOperation, LineDiff, LineOperation, StreamingDiff}; -/// Use this tool to provide a message to the user when you're unable to complete a task. +/// Use this tool when you cannot or should not make a rewrite. This includes: +/// - The user's request is unclear, ambiguous, or nonsensical +/// - The requested change cannot be made by only editing the section #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct FailureMessageInput { /// A brief message to the user explaining why you're unable to fulfill the request or to ask a question about the request. - /// - /// The message may use markdown formatting if you wish. #[serde(default)] pub message: String, } /// Replaces text in tags with your replacement_text. +/// Only use this tool when you are confident you understand the user's request and can fulfill it +/// by editing the marked section. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct RewriteSectionInput { /// The text to replace the section with. #[serde(default)] pub replacement_text: String, - - /// A brief description of the edit you have made. - /// - /// The description may use markdown formatting if you wish. - /// This is optional - if the edit is simple or obvious, you should leave it empty. - #[serde(default)] - pub description: String, } pub struct BufferCodegen { @@ -401,7 +396,7 @@ impl CodegenAlternative { &self.last_equal_ranges } - fn use_streaming_tools(model: &dyn LanguageModel, cx: &App) -> bool { + pub fn use_streaming_tools(model: &dyn LanguageModel, cx: &App) -> bool { model.supports_streaming_tools() && cx.has_flag::() && AgentSettings::get_global(cx).inline_assistant_use_streaming_tools @@ -1160,28 +1155,21 @@ impl CodegenAlternative { let chars_read_so_far = Arc::new(Mutex::new(0usize)); let process_tool_use = move |tool_use: LanguageModelToolUse| -> Option { let mut chars_read_so_far = chars_read_so_far.lock(); - let is_complete = tool_use.is_input_complete; match tool_use.name.as_ref() { "rewrite_section" => { - let Ok(mut input) = + let Ok(input) = serde_json::from_value::(tool_use.input) else { return None; }; let text = input.replacement_text[*chars_read_so_far..].to_string(); *chars_read_so_far = input.replacement_text.len(); - let description = is_complete - .then(|| { - let desc = std::mem::take(&mut input.description); - if desc.is_empty() { None } else { Some(desc) } - }) - .flatten(); - Some(ToolUseOutput::Rewrite { text, description }) + Some(ToolUseOutput::Rewrite { + text, + description: None, + }) } "failure_message" => { - if !is_complete { - return None; - } let Ok(mut input) = serde_json::from_value::(tool_use.input) else { diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index d036032e77d74dd905001affd9aba0010bc4f8eb..6e3ab7a162bc69a5b0ec081b060b4a2ba08b09aa 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -2068,17 +2068,6 @@ pub mod test { }, } - impl InlineAssistantOutput { - pub fn buffer_text(&self) -> &str { - match self { - InlineAssistantOutput::Success { - full_buffer_text, .. - } => full_buffer_text, - _ => "", - } - } - } - pub fn run_inline_assistant_test( base_buffer: String, prompt: String, @@ -2253,7 +2242,7 @@ pub mod evals { fn eval_cant_do() { run_eval( 20, - 1.0, + 0.95, "Rename the struct to EvalExampleStructNope", indoc::indoc! {" struct EvalExampleStruct { @@ -2270,7 +2259,7 @@ pub mod evals { fn eval_unclear() { run_eval( 20, - 1.0, + 0.95, "Make exactly the change I want you to make", indoc::indoc! {" struct EvalExampleStruct { @@ -2360,15 +2349,34 @@ pub mod evals { correct_output: impl Into, ) -> impl Fn(InlineAssistantOutput) -> EvalOutput<()> { let correct_output = correct_output.into(); - move |output| { - if output.buffer_text() == correct_output { - EvalOutput::passed("Assistant output matches") - } else { - EvalOutput::failed(format!( - "Assistant output does not match expected output: {:?}", - output - )) + move |output| match output { + InlineAssistantOutput::Success { + description, + full_buffer_text, + .. + } => { + if full_buffer_text == correct_output && description.is_none() { + EvalOutput::passed("Assistant output matches") + } else if full_buffer_text == correct_output { + EvalOutput::failed(format!( + "Assistant output produced an unescessary description description:\n{:?}", + description + )) + } else { + EvalOutput::failed(format!( + "Assistant output does not match expected output:\n{:?}\ndescription:\n{:?}", + full_buffer_text, description + )) + } } + o @ InlineAssistantOutput::Failure { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), + o @ InlineAssistantOutput::Malformed { .. } => EvalOutput::failed(format!( + "Assistant output does not match expected output: {:?}", + o + )), } } } diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 278216e28ec6304a9fc596c8456921fb1f1ebdfd..51e65447b2f888ab70f5942baca108134b239593 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -33,7 +33,7 @@ use workspace::{Toast, Workspace}; use zed_actions::agent::ToggleModelSelector; use crate::agent_model_selector::AgentModelSelector; -use crate::buffer_codegen::BufferCodegen; +use crate::buffer_codegen::{BufferCodegen, CodegenAlternative}; use crate::completion_provider::{ PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextType, }; @@ -585,12 +585,18 @@ impl PromptEditor { } CompletionState::Generated { completion_text } => { let model_info = self.model_selector.read(cx).active_model(cx); - let model_id = { + let (model_id, use_streaming_tools) = { let Some(configured_model) = model_info else { self.toast("No configured model", None, cx); return; }; - configured_model.model.telemetry_id() + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) }; let selected_text = match &self.mode { @@ -616,6 +622,7 @@ impl PromptEditor { prompt = prompt, completion = completion_text, selected_text = selected_text, + use_streaming_tools ); self.session_state.completion = CompletionState::Rated; @@ -641,12 +648,18 @@ impl PromptEditor { } CompletionState::Generated { completion_text } => { let model_info = self.model_selector.read(cx).active_model(cx); - let model_telemetry_id = { + let (model_telemetry_id, use_streaming_tools) = { let Some(configured_model) = model_info else { self.toast("No configured model", None, cx); return; }; - configured_model.model.telemetry_id() + ( + configured_model.model.telemetry_id(), + CodegenAlternative::use_streaming_tools( + configured_model.model.as_ref(), + cx, + ), + ) }; let selected_text = match &self.mode { @@ -672,6 +685,7 @@ impl PromptEditor { prompt = prompt, completion = completion_text, selected_text = selected_text, + use_streaming_tools ); self.session_state.completion = CompletionState::Rated; diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 0d474878f999bc773baff7664ca0305c2031c171..b96b8a04d1412b03f87a011a4ed324e053bf5dc5 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -16,8 +16,4 @@ pub struct InlineAssistantUseToolFeatureFlag; impl FeatureFlag for InlineAssistantUseToolFeatureFlag { const NAME: &'static str = "inline-assistant-use-tool"; - - fn enabled_for_staff() -> bool { - true - } } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 317657ea5f520cee15fc49d462b3f8ac5f0072dc..2e9103787bf2705732e1dad2276ebbdb21c5c2bc 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -251,7 +251,7 @@ impl Markdown { self.autoscroll_request = None; self.pending_parse = None; self.should_reparse = false; - self.parsed_markdown = ParsedMarkdown::default(); + // Don't clear parsed_markdown here - keep existing content visible until new parse completes self.parse(cx); } From 213c1b210b9abba5a7cc450692c4be35869fc15f Mon Sep 17 00:00:00 2001 From: godalming123 <68993177+godalming123@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:03:48 +0000 Subject: [PATCH 33/67] Add global search keybinding from helix (#43363) In helix, `space /` activates a global search picker, so I think that it should be the same in zed's helix mode. Release Notes: - Added helix's `space /` keybinding to open a global search menu to zed's helix mode --- assets/keymaps/vim.json | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 533db14a5f7bba4196f6a45cabfbe5d9052f796a..24cc021709656de204def3ee8b45a790ce7eb1b0 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -517,6 +517,7 @@ "space c": "editor::ToggleComments", "space p": "editor::Paste", "space y": "editor::Copy", + "space /": "pane::DeploySearch", // Other ":": "command_palette::Toggle", From 75c71a9fc5da70efb9db4fd5094bcba9e33c68cf Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 15 Dec 2025 02:14:15 -0800 Subject: [PATCH 34/67] Kick off agent v2 (#44190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔜 TODO: - [x] Add a utility pane to the left and right edges of the workspace - [x] Add a maximize button to the left and right side of the pane - [x] Add a new agents pane - [x] Add a feature flag turning these off POV: You're working agentically Screenshot 2025-12-13 at 11 50 14 PM Release Notes: - N/A --------- Co-authored-by: Nathan Sobo Co-authored-by: Zed --- Cargo.lock | 33 + Cargo.toml | 2 + assets/settings/default.json | 2 + crates/agent_settings/src/agent_settings.rs | 4 +- crates/agent_ui/src/agent_ui.rs | 19 +- crates/agent_ui_v2/Cargo.toml | 40 + crates/agent_ui_v2/LICENSE-GPL | 1 + crates/agent_ui_v2/src/agent_thread_pane.rs | 290 +++++++ crates/agent_ui_v2/src/agent_ui_v2.rs | 4 + crates/agent_ui_v2/src/agents_panel.rs | 438 +++++++++++ crates/agent_ui_v2/src/thread_history.rs | 735 ++++++++++++++++++ crates/debugger_ui/src/debugger_panel.rs | 2 +- crates/debugger_ui/src/session/running.rs | 8 +- crates/editor/src/split.rs | 4 +- crates/feature_flags/src/flags.rs | 6 + crates/settings/src/settings_content/agent.rs | 6 +- crates/terminal_view/src/terminal_panel.rs | 15 +- crates/ui/src/components/tab_bar.rs | 48 +- crates/workspace/Cargo.toml | 1 + crates/workspace/src/dock.rs | 102 ++- crates/workspace/src/pane.rs | 105 ++- crates/workspace/src/pane_group.rs | 85 +- crates/workspace/src/utility_pane.rs | 282 +++++++ crates/workspace/src/workspace.rs | 181 ++++- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 133 +++- crates/zed_actions/src/lib.rs | 2 + 28 files changed, 2452 insertions(+), 98 deletions(-) create mode 100644 crates/agent_ui_v2/Cargo.toml create mode 120000 crates/agent_ui_v2/LICENSE-GPL create mode 100644 crates/agent_ui_v2/src/agent_thread_pane.rs create mode 100644 crates/agent_ui_v2/src/agent_ui_v2.rs create mode 100644 crates/agent_ui_v2/src/agents_panel.rs create mode 100644 crates/agent_ui_v2/src/thread_history.rs create mode 100644 crates/workspace/src/utility_pane.rs diff --git a/Cargo.lock b/Cargo.lock index f829bf138a17828d1887409b8f8ea9b48e35f3c1..7933ef3099af76a81200ae99b75fb2ccbc5671c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,6 +406,37 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "agent_ui_v2" +version = "0.1.0" +dependencies = [ + "agent", + "agent_servers", + "agent_settings", + "agent_ui", + "anyhow", + "assistant_text_thread", + "chrono", + "db", + "editor", + "feature_flags", + "fs", + "fuzzy", + "gpui", + "menu", + "project", + "prompt_store", + "serde", + "serde_json", + "settings", + "text", + "time", + "time_format", + "ui", + "util", + "workspace", +] + [[package]] name = "ahash" version = "0.7.8" @@ -20059,6 +20090,7 @@ dependencies = [ "component", "dap", "db", + "feature_flags", "fs", "futures 0.3.31", "gpui", @@ -20475,6 +20507,7 @@ dependencies = [ "activity_indicator", "agent_settings", "agent_ui", + "agent_ui_v2", "anyhow", "ashpd 0.11.0", "askpass", diff --git a/Cargo.toml b/Cargo.toml index 523dce229e6b58d98f0ef36070fb068a7b743367..f3a5fefc7168c5296d032ae89ec5817673d9c333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/agent_servers", "crates/agent_settings", "crates/agent_ui", + "crates/agent_ui_v2", "crates/ai_onboarding", "crates/anthropic", "crates/askpass", @@ -242,6 +243,7 @@ action_log = { path = "crates/action_log" } agent = { path = "crates/agent" } activity_indicator = { path = "crates/activity_indicator" } agent_ui = { path = "crates/agent_ui" } +agent_ui_v2 = { path = "crates/agent_ui_v2" } agent_settings = { path = "crates/agent_settings" } agent_servers = { path = "crates/agent_servers" } ai_onboarding = { path = "crates/ai_onboarding" } diff --git a/assets/settings/default.json b/assets/settings/default.json index a5180c9e2eaca9be49fa832e32e001d15d65df8f..0283cdd5bad26e423bb914eb40c070912e30bd36 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -906,6 +906,8 @@ "button": true, // Where to dock the agent panel. Can be 'left', 'right' or 'bottom'. "dock": "right", + // Where to dock the agents panel. Can be 'left' or 'right'. + "agents_panel_dock": "left", // 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_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 5dab085a255fe399d5f529791614d51f8b4cc78b..25ca5c78d6b76145a1b1b5d19ac86246ff419d1d 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -9,7 +9,7 @@ use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, + DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NotifyWhenAgentWaiting, RegisterSetting, Settings, }; @@ -24,6 +24,7 @@ pub struct AgentSettings { pub enabled: bool, pub button: bool, pub dock: DockPosition, + pub agents_panel_dock: DockSide, pub default_width: Pixels, pub default_height: Pixels, pub default_model: Option, @@ -152,6 +153,7 @@ impl Settings for AgentSettings { enabled: agent.enabled.unwrap(), button: agent.button.unwrap(), dock: agent.dock.unwrap(), + agents_panel_dock: agent.agents_panel_dock.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_ui.rs b/crates/agent_ui/src/agent_ui.rs index 91fccc5fca0221cc72b0972801bf4da382cedee8..dd8f6912ec9829e7be93ce340d2c8eef8134f897 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -1,4 +1,4 @@ -mod acp; +pub mod acp; mod agent_configuration; mod agent_diff; mod agent_model_selector; @@ -26,7 +26,7 @@ use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; -use feature_flags::FeatureFlagAppExt as _; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{Action, App, Entity, SharedString, actions}; use language::{ @@ -244,11 +244,17 @@ pub fn init( update_command_palette_filter(app_cx); }) .detach(); + + cx.on_flags_ready(|_, cx| { + update_command_palette_filter(cx); + }) + .detach(); } fn update_command_palette_filter(cx: &mut App) { let disable_ai = DisableAiSettings::get_global(cx).disable_ai; let agent_enabled = AgentSettings::get_global(cx).enabled; + let agent_v2_enabled = cx.has_flag::(); let edit_prediction_provider = AllLanguageSettings::get_global(cx) .edit_predictions .provider; @@ -269,6 +275,7 @@ fn update_command_palette_filter(cx: &mut App) { if disable_ai { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); filter.hide_namespace("assistant"); filter.hide_namespace("copilot"); filter.hide_namespace("supermaven"); @@ -280,8 +287,10 @@ fn update_command_palette_filter(cx: &mut App) { } else { if agent_enabled { filter.show_namespace("agent"); + filter.show_namespace("agents"); } else { filter.hide_namespace("agent"); + filter.hide_namespace("agents"); } filter.show_namespace("assistant"); @@ -317,6 +326,9 @@ fn update_command_palette_filter(cx: &mut App) { filter.show_namespace("zed_predict_onboarding"); filter.show_action_types(&[TypeId::of::()]); + if !agent_v2_enabled { + filter.hide_action_types(&[TypeId::of::()]); + } } }); } @@ -415,7 +427,7 @@ mod tests { use gpui::{BorrowAppContext, TestAppContext, px}; use project::DisableAiSettings; use settings::{ - DefaultAgentView, DockPosition, NotifyWhenAgentWaiting, Settings, SettingsStore, + DefaultAgentView, DockPosition, DockSide, NotifyWhenAgentWaiting, Settings, SettingsStore, }; #[gpui::test] @@ -434,6 +446,7 @@ mod tests { enabled: true, button: true, dock: DockPosition::Right, + agents_panel_dock: DockSide::Left, default_width: px(300.), default_height: px(600.), default_model: None, diff --git a/crates/agent_ui_v2/Cargo.toml b/crates/agent_ui_v2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..f24ef47471cdcfe0910cf36c5e220c5276d5f6ae --- /dev/null +++ b/crates/agent_ui_v2/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "agent_ui_v2" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/agent_ui_v2.rs" +doctest = false + +[dependencies] +agent.workspace = true +agent_servers.workspace = true +agent_settings.workspace = true +agent_ui.workspace = true +anyhow.workspace = true +assistant_text_thread.workspace = true +chrono.workspace = true +db.workspace = true +editor.workspace = true +feature_flags.workspace = true +fs.workspace = true +fuzzy.workspace = true +gpui.workspace = true +menu.workspace = true +project.workspace = true +prompt_store.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +text.workspace = true +time.workspace = true +time_format.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true diff --git a/crates/agent_ui_v2/LICENSE-GPL b/crates/agent_ui_v2/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..e0f9dbd5d63fef1630c297edc4ceba4790be6f02 --- /dev/null +++ b/crates/agent_ui_v2/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..cfe861ef09c51af511554b3d15a1c810a793ed15 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -0,0 +1,290 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore, NativeAgentServer}; +use agent_servers::AgentServer; +use agent_settings::AgentSettings; +use agent_ui::acp::AcpThreadView; +use fs::Fs; +use gpui::{ + Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, WeakEntity, prelude::*, +}; +use project::Project; +use prompt_store::PromptStore; +use serde::{Deserialize, Serialize}; +use settings::DockSide; +use settings::Settings as _; +use std::rc::Rc; +use std::sync::Arc; +use ui::{ + App, Clickable as _, Context, DynamicSpacing, IconButton, IconName, IconSize, IntoElement, + Label, LabelCommon as _, LabelSize, Render, Tab, Window, div, +}; +use workspace::Workspace; +use workspace::dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}; +use workspace::utility_pane::UtilityPaneSlot; + +pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum SerializedHistoryEntryId { + AcpThread(String), + TextThread(String), +} + +impl From for SerializedHistoryEntryId { + fn from(id: HistoryEntryId) -> Self { + match id { + HistoryEntryId::AcpThread(session_id) => { + SerializedHistoryEntryId::AcpThread(session_id.0.to_string()) + } + HistoryEntryId::TextThread(path) => { + SerializedHistoryEntryId::TextThread(path.to_string_lossy().to_string()) + } + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SerializedAgentThreadPane { + pub expanded: bool, + pub width: Option, + pub thread_id: Option, +} + +pub enum AgentsUtilityPaneEvent { + StateChanged, +} + +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} +impl EventEmitter for AgentThreadPane {} + +struct ActiveThreadView { + view: Entity, + thread_id: HistoryEntryId, + _notify: Subscription, +} + +pub struct AgentThreadPane { + focus_handle: gpui::FocusHandle, + expanded: bool, + width: Option, + thread_view: Option, + workspace: WeakEntity, +} + +impl AgentThreadPane { + pub fn new(workspace: WeakEntity, cx: &mut ui::Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + focus_handle, + expanded: false, + width: None, + thread_view: None, + workspace, + } + } + + pub fn thread_id(&self) -> Option { + self.thread_view.as_ref().map(|tv| tv.thread_id.clone()) + } + + pub fn serialize(&self) -> SerializedAgentThreadPane { + SerializedAgentThreadPane { + expanded: self.expanded, + width: self.width, + thread_id: self.thread_id().map(SerializedHistoryEntryId::from), + } + } + + pub fn open_thread( + &mut self, + entry: HistoryEntry, + fs: Arc, + workspace: WeakEntity, + project: Entity, + history_store: Entity, + prompt_store: Option>, + window: &mut Window, + cx: &mut Context, + ) { + let thread_id = entry.id(); + + let resume_thread = match &entry { + HistoryEntry::AcpThread(thread) => Some(thread.clone()), + HistoryEntry::TextThread(_) => None, + }; + + let agent: Rc = Rc::new(NativeAgentServer::new(fs, history_store.clone())); + + let thread_view = cx.new(|cx| { + AcpThreadView::new( + agent, + resume_thread, + None, + workspace, + project, + history_store, + prompt_store, + true, + window, + cx, + ) + }); + + let notify = cx.observe(&thread_view, |_, _, cx| { + cx.notify(); + }); + + self.thread_view = Some(ActiveThreadView { + view: thread_view, + thread_id, + _notify: notify, + }); + + cx.notify(); + } + + fn title(&self, cx: &App) -> SharedString { + if let Some(active_thread_view) = &self.thread_view { + let thread_view = active_thread_view.view.read(cx); + if let Some(thread) = thread_view.thread() { + let title = thread.read(cx).title(); + if !title.is_empty() { + return title; + } + } + thread_view.title(cx) + } else { + "Thread".into() + } + } + + fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let position = self.position(window, cx); + let slot = match position { + UtilityPanePosition::Left => UtilityPaneSlot::Left, + UtilityPanePosition::Right => UtilityPaneSlot::Right, + }; + + let workspace = self.workspace.clone(); + let toggle_icon = self.toggle_icon(cx); + let title = self.title(cx); + + let make_toggle_button = |workspace: WeakEntity, cx: &App| { + div().px(DynamicSpacing::Base06.rems(cx)).child( + IconButton::new("toggle_utility_pane", toggle_icon) + .icon_size(IconSize::Small) + .on_click(move |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(slot, window, cx) + }) + .ok(); + }), + ) + }; + + let make_close_button = |id: &'static str, cx: &mut Context| { + let on_click = cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { + cx.emit(ClosePane); + this.thread_view = None; + cx.notify(); + }); + div().px(DynamicSpacing::Base06.rems(cx)).child( + IconButton::new(id, IconName::Close) + .icon_size(IconSize::Small) + .on_click(on_click), + ) + }; + + let make_title_label = |title: SharedString, cx: &App| { + div() + .px(DynamicSpacing::Base06.rems(cx)) + .child(Label::new(title).size(LabelSize::Small)) + }; + + div() + .id("utility-pane-header") + .flex() + .flex_none() + .items_center() + .w_full() + .h(Tab::container_height(cx)) + .when(slot == UtilityPaneSlot::Left, |this| { + this.child(make_toggle_button(workspace.clone(), cx)) + .child(make_title_label(title.clone(), cx)) + .child(div().flex_grow()) + .child(make_close_button("close_utility_pane_left", cx)) + }) + .when(slot == UtilityPaneSlot::Right, |this| { + this.child(make_close_button("close_utility_pane_right", cx)) + .child(make_title_label(title.clone(), cx)) + .child(div().flex_grow()) + .child(make_toggle_button(workspace.clone(), cx)) + }) + } +} + +impl Focusable for AgentThreadPane { + fn focus_handle(&self, cx: &ui::App) -> gpui::FocusHandle { + if let Some(thread_view) = &self.thread_view { + thread_view.view.focus_handle(cx) + } else { + self.focus_handle.clone() + } + } +} + +impl UtilityPane for AgentThreadPane { + fn position(&self, _window: &Window, cx: &App) -> UtilityPanePosition { + match AgentSettings::get_global(cx).agents_panel_dock { + DockSide::Left => UtilityPanePosition::Left, + DockSide::Right => UtilityPanePosition::Right, + } + } + + fn toggle_icon(&self, _cx: &App) -> IconName { + IconName::Thread + } + + fn expanded(&self, _cx: &App) -> bool { + self.expanded + } + + fn set_expanded(&mut self, expanded: bool, cx: &mut Context) { + self.expanded = expanded; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } + + fn width(&self, _cx: &App) -> Pixels { + self.width.unwrap_or(DEFAULT_UTILITY_PANE_WIDTH) + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width; + cx.emit(AgentsUtilityPaneEvent::StateChanged); + cx.notify(); + } +} + +impl Render for AgentThreadPane { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = if let Some(thread_view) = &self.thread_view { + div().size_full().child(thread_view.view.clone()) + } else { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new("Select a thread to view details").size(LabelSize::Default)) + }; + + div() + .size_full() + .flex() + .flex_col() + .child(self.render_header(window, cx)) + .child(content) + } +} diff --git a/crates/agent_ui_v2/src/agent_ui_v2.rs b/crates/agent_ui_v2/src/agent_ui_v2.rs new file mode 100644 index 0000000000000000000000000000000000000000..92a4144e304e9afbdcdde54623a3bbf3c65b8746 --- /dev/null +++ b/crates/agent_ui_v2/src/agent_ui_v2.rs @@ -0,0 +1,4 @@ +mod agent_thread_pane; +mod thread_history; + +pub mod agents_panel; diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..ace5e73f56b9eff4292f34263bfe08a94e2d6050 --- /dev/null +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -0,0 +1,438 @@ +use agent::{HistoryEntry, HistoryEntryId, HistoryStore}; +use agent_settings::AgentSettings; +use anyhow::Result; +use assistant_text_thread::TextThreadStore; +use db::kvp::KEY_VALUE_STORE; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use fs::Fs; +use gpui::{ + Action, AsyncWindowContext, Entity, EventEmitter, Focusable, Pixels, Subscription, Task, + WeakEntity, actions, prelude::*, +}; +use project::Project; +use prompt_store::{PromptBuilder, PromptStore}; +use serde::{Deserialize, Serialize}; +use settings::{Settings as _, update_settings_file}; +use std::sync::Arc; +use ui::{App, Context, IconName, IntoElement, ParentElement, Render, Styled, Window}; +use util::ResultExt; +use workspace::{ + Panel, Workspace, + dock::{ClosePane, DockPosition, PanelEvent, UtilityPane}, + utility_pane::{UtilityPaneSlot, utility_slot_for_dock_position}, +}; + +use crate::agent_thread_pane::{ + AgentThreadPane, AgentsUtilityPaneEvent, SerializedAgentThreadPane, SerializedHistoryEntryId, +}; +use crate::thread_history::{AcpThreadHistory, ThreadHistoryEvent}; + +const AGENTS_PANEL_KEY: &str = "agents_panel"; + +#[derive(Serialize, Deserialize, Debug)] +struct SerializedAgentsPanel { + width: Option, + pane: Option, +} + +actions!( + agents, + [ + /// Toggle the visibility of the agents panel. + ToggleAgentsPanel + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + workspace.register_action(|workspace, _: &ToggleAgentsPanel, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }); + }) + .detach(); +} + +pub struct AgentsPanel { + focus_handle: gpui::FocusHandle, + workspace: WeakEntity, + project: Entity, + agent_thread_pane: Option>, + history: Entity, + history_store: Entity, + prompt_store: Option>, + fs: Arc, + width: Option, + pending_serialization: Task>, + _subscriptions: Vec, +} + +impl AgentsPanel { + pub fn load( + workspace: WeakEntity, + cx: AsyncWindowContext, + ) -> Task, anyhow::Error>> { + cx.spawn(async move |cx| { + let serialized_panel = cx + .background_spawn(async move { + KEY_VALUE_STORE + .read_kvp(AGENTS_PANEL_KEY) + .ok() + .flatten() + .and_then(|panel| { + serde_json::from_str::(&panel).ok() + }) + }) + .await; + + let (fs, project, prompt_builder) = workspace.update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + let project = workspace.project().clone(); + let prompt_builder = PromptBuilder::load(fs.clone(), false, cx); + (fs, project, prompt_builder) + })?; + + let text_thread_store = workspace + .update(cx, |_, cx| { + TextThreadStore::new( + project.clone(), + prompt_builder.clone(), + Default::default(), + cx, + ) + })? + .await?; + + let prompt_store = workspace + .update(cx, |_, cx| PromptStore::global(cx))? + .await + .log_err(); + + workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + let mut panel = Self::new( + workspace.clone(), + fs, + project, + prompt_store, + text_thread_store, + window, + cx, + ); + if let Some(serialized_panel) = serialized_panel { + panel.width = serialized_panel.width; + if let Some(serialized_pane) = serialized_panel.pane { + panel.restore_utility_pane(serialized_pane, window, cx); + } + } + panel + }) + }) + }) + } + + fn new( + workspace: WeakEntity, + fs: Arc, + project: Entity, + prompt_store: Option>, + text_thread_store: Entity, + window: &mut Window, + cx: &mut ui::Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + let history = cx.new(|cx| AcpThreadHistory::new(history_store.clone(), window, cx)); + + let this = cx.weak_entity(); + let subscriptions = vec![ + cx.subscribe_in(&history, window, Self::handle_history_event), + cx.on_flags_ready(move |_, cx| { + this.update(cx, |_, cx| { + cx.notify(); + }) + .ok(); + }), + ]; + + Self { + focus_handle, + workspace, + project, + agent_thread_pane: None, + history, + history_store, + prompt_store, + fs, + width: None, + pending_serialization: Task::ready(None), + _subscriptions: subscriptions, + } + } + + fn restore_utility_pane( + &mut self, + serialized_pane: SerializedAgentThreadPane, + window: &mut Window, + cx: &mut Context, + ) { + let Some(thread_id) = &serialized_pane.thread_id else { + return; + }; + + let entry = self + .history_store + .read(cx) + .entries() + .find(|e| match (&e.id(), thread_id) { + ( + HistoryEntryId::AcpThread(session_id), + SerializedHistoryEntryId::AcpThread(id), + ) => session_id.to_string() == *id, + (HistoryEntryId::TextThread(path), SerializedHistoryEntryId::TextThread(id)) => { + path.to_string_lossy() == *id + } + _ => false, + }); + + if let Some(entry) = entry { + self.open_thread( + entry, + serialized_pane.expanded, + serialized_pane.width, + window, + cx, + ); + } + } + + fn handle_utility_pane_event( + &mut self, + _utility_pane: Entity, + event: &AgentsUtilityPaneEvent, + cx: &mut Context, + ) { + match event { + AgentsUtilityPaneEvent::StateChanged => { + self.serialize(cx); + cx.notify(); + } + } + } + + fn handle_close_pane_event( + &mut self, + _utility_pane: Entity, + _event: &ClosePane, + cx: &mut Context, + ) { + self.agent_thread_pane = None; + self.serialize(cx); + cx.notify(); + } + + fn handle_history_event( + &mut self, + _history: &Entity, + event: &ThreadHistoryEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + ThreadHistoryEvent::Open(entry) => { + self.open_thread(entry.clone(), true, None, window, cx); + } + } + } + + fn open_thread( + &mut self, + entry: HistoryEntry, + expanded: bool, + width: Option, + window: &mut Window, + cx: &mut Context, + ) { + let entry_id = entry.id(); + + if let Some(existing_pane) = &self.agent_thread_pane { + if existing_pane.read(cx).thread_id() == Some(entry_id) { + existing_pane.update(cx, |pane, cx| { + pane.set_expanded(true, cx); + }); + return; + } + } + + let fs = self.fs.clone(); + let workspace = self.workspace.clone(); + let project = self.project.clone(); + let history_store = self.history_store.clone(); + let prompt_store = self.prompt_store.clone(); + + let agent_thread_pane = cx.new(|cx| { + let mut pane = AgentThreadPane::new(workspace.clone(), cx); + pane.open_thread( + entry, + fs, + workspace.clone(), + project, + history_store, + prompt_store, + window, + cx, + ); + if let Some(width) = width { + pane.set_width(Some(width), cx); + } + pane.set_expanded(expanded, cx); + pane + }); + + let state_subscription = cx.subscribe(&agent_thread_pane, Self::handle_utility_pane_event); + let close_subscription = cx.subscribe(&agent_thread_pane, Self::handle_close_pane_event); + + self._subscriptions.push(state_subscription); + self._subscriptions.push(close_subscription); + + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, agent_thread_pane.clone(), cx); + }); + } + + self.agent_thread_pane = Some(agent_thread_pane); + self.serialize(cx); + cx.notify(); + } + + fn utility_slot(&self, window: &Window, cx: &App) -> UtilityPaneSlot { + let position = self.position(window, cx); + utility_slot_for_dock_position(position) + } + + fn re_register_utility_pane(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(pane) = &self.agent_thread_pane { + let slot = self.utility_slot(window, cx); + let panel_id = cx.entity_id(); + let pane = pane.clone(); + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.register_utility_pane(slot, panel_id, pane, cx); + }); + } + } + } + + fn serialize(&mut self, cx: &mut Context) { + let width = self.width; + let pane = self + .agent_thread_pane + .as_ref() + .map(|pane| pane.read(cx).serialize()); + + self.pending_serialization = cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp( + AGENTS_PANEL_KEY.into(), + serde_json::to_string(&SerializedAgentsPanel { width, pane }).unwrap(), + ) + .await + .log_err() + }); + } +} + +impl EventEmitter for AgentsPanel {} + +impl Focusable for AgentsPanel { + fn focus_handle(&self, _cx: &ui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Panel for AgentsPanel { + fn persistent_name() -> &'static str { + "AgentsPanel" + } + + fn panel_key() -> &'static str { + AGENTS_PANEL_KEY + } + + fn position(&self, _window: &Window, cx: &App) -> DockPosition { + match AgentSettings::get_global(cx).agents_panel_dock { + settings::DockSide::Left => DockPosition::Left, + settings::DockSide::Right => DockPosition::Right, + } + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position != DockPosition::Bottom + } + + fn set_position( + &mut self, + position: DockPosition, + window: &mut Window, + cx: &mut Context, + ) { + update_settings_file(self.fs.clone(), cx, move |settings, _| { + settings.agent.get_or_insert_default().agents_panel_dock = Some(match position { + DockPosition::Left => settings::DockSide::Left, + DockPosition::Bottom => settings::DockSide::Right, + DockPosition::Right => settings::DockSide::Left, + }); + }); + self.re_register_utility_pane(window, cx); + } + + fn size(&self, window: &Window, cx: &App) -> Pixels { + let settings = AgentSettings::get_global(cx); + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => { + self.width.unwrap_or(settings.default_width) + } + DockPosition::Bottom => self.width.unwrap_or(settings.default_height), + } + } + + fn set_size(&mut self, size: Option, window: &mut Window, cx: &mut Context) { + match self.position(window, cx) { + DockPosition::Left | DockPosition::Right => self.width = size, + DockPosition::Bottom => {} + } + self.serialize(cx); + cx.notify(); + } + + fn icon(&self, _window: &Window, cx: &App) -> Option { + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgent) + } + + fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { + Some("Agents Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleAgentsPanel) + } + + fn activation_priority(&self) -> u32 { + 4 + } + + fn enabled(&self, cx: &App) -> bool { + AgentSettings::get_global(cx).enabled(cx) && cx.has_flag::() + } +} + +impl Render for AgentsPanel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div().size_full().child(self.history.clone()) + } +} diff --git a/crates/agent_ui_v2/src/thread_history.rs b/crates/agent_ui_v2/src/thread_history.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f6626814902a9489536439e90041437a527e151 --- /dev/null +++ b/crates/agent_ui_v2/src/thread_history.rs @@ -0,0 +1,735 @@ +use agent::{HistoryEntry, HistoryStore}; +use chrono::{Datelike as _, Local, NaiveDate, TimeDelta}; +use editor::{Editor, EditorEvent}; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task, + UniformListScrollHandle, Window, actions, uniform_list, +}; +use std::{fmt::Display, ops::Range}; +use text::Bias; +use time::{OffsetDateTime, UtcOffset}; +use ui::{ + HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tab, Tooltip, WithScrollbar, + prelude::*, +}; + +actions!( + agents, + [ + /// Removes all thread history. + RemoveHistory, + /// Removes the currently selected thread. + RemoveSelectedThread, + ] +); + +pub struct AcpThreadHistory { + pub(crate) history_store: Entity, + scroll_handle: UniformListScrollHandle, + selected_index: usize, + hovered_index: Option, + search_editor: Entity, + search_query: SharedString, + visible_items: Vec, + local_timezone: UtcOffset, + confirming_delete_history: bool, + _update_task: Task<()>, + _subscriptions: Vec, +} + +enum ListItemType { + BucketSeparator(TimeBucket), + Entry { + entry: HistoryEntry, + format: EntryTimeFormat, + }, + SearchResult { + entry: HistoryEntry, + positions: Vec, + }, +} + +impl ListItemType { + fn history_entry(&self) -> Option<&HistoryEntry> { + match self { + ListItemType::Entry { entry, .. } => Some(entry), + ListItemType::SearchResult { entry, .. } => Some(entry), + _ => None, + } + } +} + +#[allow(dead_code)] +pub enum ThreadHistoryEvent { + Open(HistoryEntry), +} + +impl EventEmitter for AcpThreadHistory {} + +impl AcpThreadHistory { + pub fn new( + history_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Search threads...", window, cx); + editor + }); + + let search_editor_subscription = + cx.subscribe(&search_editor, |this, search_editor, event, cx| { + if let EditorEvent::BufferEdited = event { + let query = search_editor.read(cx).text(cx); + if this.search_query != query { + this.search_query = query.into(); + this.update_visible_items(false, cx); + } + } + }); + + let history_store_subscription = cx.observe(&history_store, |this, _, cx| { + this.update_visible_items(true, cx); + }); + + let scroll_handle = UniformListScrollHandle::default(); + + let mut this = Self { + history_store, + scroll_handle, + selected_index: 0, + hovered_index: None, + visible_items: Default::default(), + search_editor, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + search_query: SharedString::default(), + confirming_delete_history: false, + _subscriptions: vec![search_editor_subscription, history_store_subscription], + _update_task: Task::ready(()), + }; + this.update_visible_items(false, cx); + this + } + + fn update_visible_items(&mut self, preserve_selected_item: bool, cx: &mut Context) { + let entries = self + .history_store + .update(cx, |store, _| store.entries().collect()); + let new_list_items = if self.search_query.is_empty() { + self.add_list_separators(entries, cx) + } else { + self.filter_search_results(entries, cx) + }; + let selected_history_entry = if preserve_selected_item { + self.selected_history_entry().cloned() + } else { + None + }; + + self._update_task = cx.spawn(async move |this, cx| { + let new_visible_items = new_list_items.await; + this.update(cx, |this, cx| { + let new_selected_index = if let Some(history_entry) = selected_history_entry { + let history_entry_id = history_entry.id(); + new_visible_items + .iter() + .position(|visible_entry| { + visible_entry + .history_entry() + .is_some_and(|entry| entry.id() == history_entry_id) + }) + .unwrap_or(0) + } else { + 0 + }; + + this.visible_items = new_visible_items; + this.set_selected_index(new_selected_index, Bias::Right, cx); + cx.notify(); + }) + .ok(); + }); + } + + fn add_list_separators(&self, entries: Vec, cx: &App) -> Task> { + cx.background_spawn(async move { + let mut items = Vec::with_capacity(entries.len() + 1); + let mut bucket = None; + let today = Local::now().naive_local().date(); + + for entry in entries.into_iter() { + let entry_date = entry + .updated_at() + .with_timezone(&Local) + .naive_local() + .date(); + let entry_bucket = TimeBucket::from_dates(today, entry_date); + + if Some(entry_bucket) != bucket { + bucket = Some(entry_bucket); + items.push(ListItemType::BucketSeparator(entry_bucket)); + } + + items.push(ListItemType::Entry { + entry, + format: entry_bucket.into(), + }); + } + items + }) + } + + fn filter_search_results( + &self, + entries: Vec, + cx: &App, + ) -> Task> { + let query = self.search_query.clone(); + cx.background_spawn({ + let executor = cx.background_executor().clone(); + async move { + let mut candidates = Vec::with_capacity(entries.len()); + + for (idx, entry) in entries.iter().enumerate() { + candidates.push(StringMatchCandidate::new(idx, entry.title())); + } + + const MAX_MATCHES: usize = 100; + + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + matches + .into_iter() + .map(|search_match| ListItemType::SearchResult { + entry: entries[search_match.candidate_id].clone(), + positions: search_match.positions, + }) + .collect() + } + }) + } + + fn search_produced_no_matches(&self) -> bool { + self.visible_items.is_empty() && !self.search_query.is_empty() + } + + fn selected_history_entry(&self) -> Option<&HistoryEntry> { + self.get_history_entry(self.selected_index) + } + + fn get_history_entry(&self, visible_items_ix: usize) -> Option<&HistoryEntry> { + self.visible_items.get(visible_items_ix)?.history_entry() + } + + fn set_selected_index(&mut self, mut index: usize, bias: Bias, cx: &mut Context) { + if self.visible_items.is_empty() { + self.selected_index = 0; + return; + } + while matches!( + self.visible_items.get(index), + None | Some(ListItemType::BucketSeparator(..)) + ) { + index = match bias { + Bias::Left => { + if index == 0 { + self.visible_items.len() - 1 + } else { + index - 1 + } + } + Bias::Right => { + if index >= self.visible_items.len() - 1 { + 0 + } else { + index + 1 + } + } + }; + } + self.selected_index = index; + self.scroll_handle + .scroll_to_item(index, ScrollStrategy::Top); + cx.notify() + } + + pub fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == 0 { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } else { + self.set_selected_index(self.selected_index - 1, Bias::Left, cx); + } + } + + pub fn select_next( + &mut self, + _: &menu::SelectNext, + _window: &mut Window, + cx: &mut Context, + ) { + if self.selected_index == self.visible_items.len() - 1 { + self.set_selected_index(0, Bias::Right, cx); + } else { + self.set_selected_index(self.selected_index + 1, Bias::Right, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + self.set_selected_index(0, Bias::Right, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + self.set_selected_index(self.visible_items.len() - 1, Bias::Left, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.confirm_entry(self.selected_index, cx); + } + + fn confirm_entry(&mut self, ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(ix) else { + return; + }; + cx.emit(ThreadHistoryEvent::Open(entry.clone())); + } + + fn remove_selected_thread( + &mut self, + _: &RemoveSelectedThread, + _window: &mut Window, + cx: &mut Context, + ) { + self.remove_thread(self.selected_index, cx) + } + + fn remove_thread(&mut self, visible_item_ix: usize, cx: &mut Context) { + let Some(entry) = self.get_history_entry(visible_item_ix) else { + return; + }; + + let task = match entry { + HistoryEntry::AcpThread(thread) => self + .history_store + .update(cx, |this, cx| this.delete_thread(thread.id.clone(), cx)), + HistoryEntry::TextThread(text_thread) => self.history_store.update(cx, |this, cx| { + this.delete_text_thread(text_thread.path.clone(), cx) + }), + }; + task.detach_and_log_err(cx); + } + + fn remove_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.history_store.update(cx, |store, cx| { + store.delete_threads(cx).detach_and_log_err(cx) + }); + self.confirming_delete_history = false; + cx.notify(); + } + + fn prompt_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = true; + cx.notify(); + } + + fn cancel_delete_history(&mut self, _window: &mut Window, cx: &mut Context) { + self.confirming_delete_history = false; + cx.notify(); + } + + fn render_list_items( + &mut self, + range: Range, + _window: &mut Window, + cx: &mut Context, + ) -> Vec { + self.visible_items + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| self.render_list_item(item, range.start + ix, cx)) + .collect() + } + + fn render_list_item(&self, item: &ListItemType, ix: usize, cx: &Context) -> AnyElement { + match item { + ListItemType::Entry { entry, format } => self + .render_history_entry(entry, *format, ix, Vec::default(), cx) + .into_any(), + ListItemType::SearchResult { entry, positions } => self.render_history_entry( + entry, + EntryTimeFormat::DateAndTime, + ix, + positions.clone(), + cx, + ), + ListItemType::BucketSeparator(bucket) => div() + .px(DynamicSpacing::Base06.rems(cx)) + .pt_2() + .pb_1() + .child( + Label::new(bucket.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element(), + } + } + + fn render_history_entry( + &self, + entry: &HistoryEntry, + format: EntryTimeFormat, + ix: usize, + highlight_positions: Vec, + cx: &Context, + ) -> AnyElement { + let selected = ix == self.selected_index; + let hovered = Some(ix) == self.hovered_index; + let timestamp = entry.updated_at().timestamp(); + let thread_timestamp = format.format_timestamp(timestamp, self.local_timezone); + + h_flex() + .w_full() + .pb_1() + .child( + ListItem::new(ix) + .rounded() + .toggle_state(selected) + .spacing(ListItemSpacing::Sparse) + .start_slot( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child( + HighlightedLabel::new(entry.title(), highlight_positions) + .size(LabelSize::Small) + .truncate(), + ) + .child( + Label::new(thread_timestamp) + .color(Color::Muted) + .size(LabelSize::XSmall), + ), + ) + .on_hover(cx.listener(move |this, is_hovered, _window, cx| { + if *is_hovered { + this.hovered_index = Some(ix); + } else if this.hovered_index == Some(ix) { + this.hovered_index = None; + } + + cx.notify(); + })) + .end_slot::(if hovered { + Some( + IconButton::new("delete", IconName::Trash) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(move |_window, cx| { + Tooltip::for_action("Delete", &RemoveSelectedThread, cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.remove_thread(ix, cx); + cx.stop_propagation() + })), + ) + } else { + None + }) + .on_click(cx.listener(move |this, _, _, cx| this.confirm_entry(ix, cx))), + ) + .into_any_element() + } +} + +impl Focusable for AcpThreadHistory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.search_editor.focus_handle(cx) + } +} + +impl Render for AcpThreadHistory { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_no_history = self.history_store.read(cx).is_empty(cx); + + v_flex() + .key_context("ThreadHistory") + .size_full() + .bg(cx.theme().colors().panel_background) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::remove_selected_thread)) + .on_action(cx.listener(|this, _: &RemoveHistory, window, cx| { + this.remove_history(window, cx); + })) + .child( + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .py_1() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(self.search_editor.clone()), + ) + .child({ + let view = v_flex() + .id("list-container") + .relative() + .overflow_hidden() + .flex_grow(); + + if has_no_history { + view.justify_center().items_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else if self.search_produced_no_matches() { + view.justify_center() + .items_center() + .child(Label::new("No threads match your search.").size(LabelSize::Small)) + } else { + view.child( + uniform_list( + "thread-history", + self.visible_items.len(), + cx.processor(|this, range: Range, window, cx| { + this.render_list_items(range, window, cx) + }), + ) + .p_1() + .pr_4() + .track_scroll(&self.scroll_handle) + .flex_grow(), + ) + .vertical_scrollbar_for(&self.scroll_handle, window, cx) + } + }) + .when(!has_no_history, |this| { + this.child( + h_flex() + .p_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when(!self.confirming_delete_history, |this| { + this.child( + Button::new("delete_history", "Delete All History") + .full_width() + .style(ButtonStyle::Outlined) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.prompt_delete_history(window, cx); + })), + ) + }) + .when(self.confirming_delete_history, |this| { + this.w_full() + .gap_2() + .flex_wrap() + .justify_between() + .child( + h_flex() + .flex_wrap() + .gap_1() + .child( + Label::new("Delete all threads?") + .size(LabelSize::Small), + ) + .child( + Label::new("You won't be able to recover them later.") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("cancel_delete", "Cancel") + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_delete_history(window, cx); + })), + ) + .child( + Button::new("confirm_delete", "Delete") + .style(ButtonStyle::Tinted(ui::TintColor::Error)) + .color(Color::Error) + .label_size(LabelSize::Small) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + Box::new(RemoveHistory), + cx, + ); + })), + ), + ) + }), + ) + }) + } +} + +#[derive(Clone, Copy)] +pub enum EntryTimeFormat { + DateAndTime, + TimeOnly, +} + +impl EntryTimeFormat { + fn format_timestamp(&self, timestamp: i64, timezone: UtcOffset) -> String { + let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap(); + + match self { + EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp( + timestamp, + OffsetDateTime::now_utc(), + timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ), + EntryTimeFormat::TimeOnly => time_format::format_time(timestamp.to_offset(timezone)), + } + } +} + +impl From for EntryTimeFormat { + fn from(bucket: TimeBucket) -> Self { + match bucket { + TimeBucket::Today => EntryTimeFormat::TimeOnly, + TimeBucket::Yesterday => EntryTimeFormat::TimeOnly, + TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime, + TimeBucket::PastWeek => EntryTimeFormat::DateAndTime, + TimeBucket::All => EntryTimeFormat::DateAndTime, + } + } +} + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +enum TimeBucket { + Today, + Yesterday, + ThisWeek, + PastWeek, + All, +} + +impl TimeBucket { + fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self { + if date == reference { + return TimeBucket::Today; + } + + if date == reference - TimeDelta::days(1) { + return TimeBucket::Yesterday; + } + + let week = date.iso_week(); + + if reference.iso_week() == week { + return TimeBucket::ThisWeek; + } + + let last_week = (reference - TimeDelta::days(7)).iso_week(); + + if week == last_week { + return TimeBucket::PastWeek; + } + + TimeBucket::All + } +} + +impl Display for TimeBucket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TimeBucket::Today => write!(f, "Today"), + TimeBucket::Yesterday => write!(f, "Yesterday"), + TimeBucket::ThisWeek => write!(f, "This Week"), + TimeBucket::PastWeek => write!(f, "Past Week"), + TimeBucket::All => write!(f, "All"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn test_time_bucket_from_dates() { + let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap(); + + let date = today; + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today); + + let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday); + + let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek); + + // All: not in this week or last week + let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All); + + // Test year boundary cases + let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(); + + let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap(); + assert_eq!( + TimeBucket::from_dates(new_year, date), + TimeBucket::Yesterday + ); + + let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap(); + assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek); + } +} diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index bdb308aafd0d2899f17bef732ac38239c4df6dda..104a85dc097c575e7a4cd8f4a66a98a8bb6b0d69 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -1557,7 +1557,7 @@ impl Panel for DebugPanel { self.sessions_with_children.keys().for_each(|session_item| { session_item.update(cx, |item, cx| { item.running_state() - .update(cx, |state, _| state.invert_axies()) + .update(cx, |state, cx| state.invert_axies(cx)) }) }); } diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index 66e9dd7b434e628898add7056b15c1789e32519c..4898ec95ca3c5b55669896b3c1d898326851c0c3 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -348,7 +348,7 @@ pub(crate) fn new_debugger_pane( debug_assert!(_previous_subscription.is_none()); running .panes - .split(&this_pane, &new_pane, split_direction)?; + .split(&this_pane, &new_pane, split_direction, cx)?; anyhow::Ok(new_pane) }) }) @@ -1462,7 +1462,7 @@ impl RunningState { this.serialize_layout(window, cx); match event { Event::Remove { .. } => { - let _did_find_pane = this.panes.remove(source_pane).is_ok(); + let _did_find_pane = this.panes.remove(source_pane, cx).is_ok(); debug_assert!(_did_find_pane); cx.notify(); } @@ -1889,9 +1889,9 @@ impl RunningState { Member::Axis(group_root) } - pub(crate) fn invert_axies(&mut self) { + pub(crate) fn invert_axies(&mut self, cx: &mut App) { self.dock_axis = self.dock_axis.invert(); - self.panes.invert_axies(); + self.panes.invert_axies(cx); } } diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 8a413a376f2296acdacddc97707a6112e8cd5185..b5090f06dc1e68d609413db31112775e56559689 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -194,7 +194,7 @@ impl SplittableEditor { }); let primary_pane = self.panes.first_pane(); self.panes - .split(&primary_pane, &secondary_pane, SplitDirection::Left) + .split(&primary_pane, &secondary_pane, SplitDirection::Left, cx) .unwrap(); cx.notify(); } @@ -203,7 +203,7 @@ impl SplittableEditor { let Some(secondary) = self.secondary.take() else { return; }; - self.panes.remove(&secondary.pane).unwrap(); + self.panes.remove(&secondary.pane, cx).unwrap(); self.primary_editor.update(cx, |primary, cx| { primary.buffer().update(cx, |buffer, _| { buffer.set_filter_mode(None); diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index b96b8a04d1412b03f87a011a4ed324e053bf5dc5..1768e43d1d0a88433d61c6390f912377c2ba55e3 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -17,3 +17,9 @@ pub struct InlineAssistantUseToolFeatureFlag; impl FeatureFlag for InlineAssistantUseToolFeatureFlag { const NAME: &'static str = "inline-assistant-use-tool"; } + +pub struct AgentV2FeatureFlag; + +impl FeatureFlag for AgentV2FeatureFlag { + const NAME: &'static str = "agent-v2"; +} diff --git a/crates/settings/src/settings_content/agent.rs b/crates/settings/src/settings_content/agent.rs index fccc3e09fceb8e05ad3494101a4d23d95257358e..f7a88deb7d8ba88db6497da2cf79035a64446456 100644 --- a/crates/settings/src/settings_content/agent.rs +++ b/crates/settings/src/settings_content/agent.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; use std::{borrow::Cow, path::PathBuf, sync::Arc}; -use crate::DockPosition; +use crate::{DockPosition, DockSide}; #[with_fallible_options] #[derive(Clone, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom, Debug, Default)] @@ -22,6 +22,10 @@ pub struct AgentSettingsContent { /// /// Default: right pub dock: Option, + /// Where to dock the utility pane (the thread view pane). + /// + /// Default: left + pub agents_panel_dock: Option, /// Default width in pixels when the agent panel is docked to the left or right. /// /// Default: 640 diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ab89787fc88510f4c92e929d96b51c682ff0af61..fb660e759c75aee9752cbaa3bdc8c8e0a47615e3 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -342,7 +342,7 @@ impl TerminalPanel { pane::Event::RemovedItem { .. } => self.serialize(cx), pane::Event::Remove { focus_on_pane } => { let pane_count_before_removal = self.center.panes().len(); - let _removal_result = self.center.remove(pane); + let _removal_result = self.center.remove(pane, cx); if pane_count_before_removal == 1 { self.center.first_pane().update(cx, |pane, cx| { pane.set_zoomed(false, cx); @@ -393,7 +393,10 @@ impl TerminalPanel { }; panel .update_in(cx, |panel, window, cx| { - panel.center.split(&pane, &new_pane, direction).log_err(); + panel + .center + .split(&pane, &new_pane, direction, cx) + .log_err(); window.focus(&new_pane.focus_handle(cx)); }) .ok(); @@ -415,7 +418,7 @@ impl TerminalPanel { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx); }); - self.center.split(&pane, &new_pane, direction).log_err(); + self.center.split(&pane, &new_pane, direction, cx).log_err(); window.focus(&new_pane.focus_handle(cx)); } } @@ -1066,7 +1069,7 @@ impl TerminalPanel { .find_pane_in_direction(&self.active_pane, direction, cx) .cloned() { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); cx.notify(); } } @@ -1074,7 +1077,7 @@ impl TerminalPanel { fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { if self .center - .move_to_border(&self.active_pane, direction) + .move_to_border(&self.active_pane, direction, cx) .unwrap() { cx.notify(); @@ -1189,6 +1192,7 @@ pub fn new_terminal_pane( &this_pane, &new_pane, split_direction, + cx, )?; anyhow::Ok(new_pane) }) @@ -1482,6 +1486,7 @@ impl Render for TerminalPanel { &terminal_panel.active_pane, &new_pane, SplitDirection::Right, + cx, ) .log_err(); let new_pane = new_pane.read(cx); diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 5d41466e3caadf6697b3c1681a405dafa2fb3101..681f9a726e0d5f4796325a4533fca909617f1e08 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -10,6 +10,7 @@ pub struct TabBar { start_children: SmallVec<[AnyElement; 2]>, children: SmallVec<[AnyElement; 2]>, end_children: SmallVec<[AnyElement; 2]>, + pre_end_children: SmallVec<[AnyElement; 2]>, scroll_handle: Option, } @@ -20,6 +21,7 @@ impl TabBar { start_children: SmallVec::new(), children: SmallVec::new(), end_children: SmallVec::new(), + pre_end_children: SmallVec::new(), scroll_handle: None, } } @@ -70,6 +72,15 @@ impl TabBar { self } + pub fn pre_end_child(mut self, end_child: impl IntoElement) -> Self + where + Self: Sized, + { + self.pre_end_children + .push(end_child.into_element().into_any()); + self + } + pub fn end_children(mut self, end_children: impl IntoIterator) -> Self where Self: Sized, @@ -137,18 +148,31 @@ impl RenderOnce for TabBar { .children(self.children), ), ) - .when(!self.end_children.is_empty(), |this| { - this.child( - h_flex() - .flex_none() - .gap(DynamicSpacing::Base04.rems(cx)) - .px(DynamicSpacing::Base06.rems(cx)) - .border_b_1() - .border_l_1() - .border_color(cx.theme().colors().border) - .children(self.end_children), - ) - }) + .when( + !self.end_children.is_empty() || !self.pre_end_children.is_empty(), + |this| { + this.child( + h_flex() + .flex_none() + .gap(DynamicSpacing::Base04.rems(cx)) + .px(DynamicSpacing::Base06.rems(cx)) + .children(self.pre_end_children) + .border_color(cx.theme().colors().border) + .border_b_1() + .when(!self.end_children.is_empty(), |div| { + div.child( + h_flex() + .flex_none() + .pl(DynamicSpacing::Base04.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) + .children(self.end_children), + ) + }), + ) + }, + ) } } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index d5d3016ab2704392c6cc9cc4bcebf6d50701d3be..acf95df37f5d20da65b6e9fa4460ba09b2ea81e3 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -35,6 +35,7 @@ clock.workspace = true collections.workspace = true component.workspace = true db.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index dfc341db9c71fd1059853b9480a7e679109ead40..edc5705a28ecd7d378c0f959ac82a6493c82d325 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -1,8 +1,10 @@ use crate::persistence::model::DockData; +use crate::utility_pane::utility_slot_for_dock_position; use crate::{DraggedDock, Event, ModalLayer, Pane}; use crate::{Workspace, status_bar::StatusItemView}; use anyhow::Context as _; use client::proto; + use gpui::{ Action, AnyView, App, Axis, Context, Corner, Entity, EntityId, EventEmitter, FocusHandle, Focusable, IntoElement, KeyContext, MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, @@ -13,6 +15,7 @@ use settings::SettingsStore; use std::sync::Arc; use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex}; use ui::{prelude::*, right_click_menu}; +use util::ResultExt as _; pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.); @@ -25,6 +28,72 @@ pub enum PanelEvent { pub use proto::PanelId; +pub struct MinimizePane; +pub struct ClosePane; + +pub trait UtilityPane: EventEmitter + EventEmitter + Render { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + /// The icon to render in the adjacent pane's tab bar for toggling this utility pane + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&mut self, expanded: bool, cx: &mut Context); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&mut self, width: Option, cx: &mut Context); +} + +pub trait UtilityPaneHandle: 'static + Send + Sync { + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition; + fn toggle_icon(&self, cx: &App) -> IconName; + fn expanded(&self, cx: &App) -> bool; + fn set_expanded(&self, expanded: bool, cx: &mut App); + fn width(&self, cx: &App) -> Pixels; + fn set_width(&self, width: Option, cx: &mut App); + fn to_any(&self) -> AnyView; + fn box_clone(&self) -> Box; +} + +impl UtilityPaneHandle for Entity +where + T: UtilityPane, +{ + fn position(&self, window: &Window, cx: &App) -> UtilityPanePosition { + self.read(cx).position(window, cx) + } + + fn toggle_icon(&self, cx: &App) -> IconName { + self.read(cx).toggle_icon(cx) + } + + fn expanded(&self, cx: &App) -> bool { + self.read(cx).expanded(cx) + } + + fn set_expanded(&self, expanded: bool, cx: &mut App) { + self.update(cx, |this, cx| this.set_expanded(expanded, cx)) + } + + fn width(&self, cx: &App) -> Pixels { + self.read(cx).width(cx) + } + + fn set_width(&self, width: Option, cx: &mut App) { + self.update(cx, |this, cx| this.set_width(width, cx)) + } + + fn to_any(&self) -> AnyView { + self.clone().into() + } + + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } +} + +pub enum UtilityPanePosition { + Left, + Right, +} + pub trait Panel: Focusable + EventEmitter + Render + Sized { fn persistent_name() -> &'static str; fn panel_key() -> &'static str; @@ -384,6 +453,13 @@ impl Dock { .position(|entry| entry.panel.remote_id() == Some(panel_id)) } + pub fn panel_for_id(&self, panel_id: EntityId) -> Option<&Arc> { + self.panel_entries + .iter() + .find(|entry| entry.panel.panel_id() == panel_id) + .map(|entry| &entry.panel) + } + pub fn first_enabled_panel_idx(&mut self, cx: &mut Context) -> anyhow::Result { self.panel_entries .iter() @@ -491,6 +567,9 @@ impl Dock { new_dock.update(cx, |new_dock, cx| { new_dock.remove_panel(&panel, window, cx); + }); + + new_dock.update(cx, |new_dock, cx| { let index = new_dock.add_panel(panel.clone(), workspace.clone(), window, cx); if was_visible { @@ -498,6 +577,12 @@ impl Dock { new_dock.activate_panel(index, window, cx); } }); + + workspace + .update(cx, |workspace, cx| { + workspace.serialize_workspace(window, cx); + }) + .ok(); } }), cx.subscribe_in( @@ -586,6 +671,7 @@ impl Dock { ); self.restore_state(window, cx); + if panel.read(cx).starts_open(window, cx) { self.activate_panel(index, window, cx); self.set_open(true, window, cx); @@ -637,6 +723,14 @@ impl Dock { std::cmp::Ordering::Greater => {} } } + + let slot = utility_slot_for_dock_position(self.position); + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.clear_utility_pane_if_provider(slot, Entity::entity_id(panel), cx); + }); + } + self.panel_entries.remove(panel_ix); cx.notify(); } @@ -891,7 +985,13 @@ impl Render for PanelButtons { .enumerate() .filter_map(|(i, entry)| { let icon = entry.panel.icon(window, cx)?; - let icon_tooltip = entry.panel.icon_tooltip(window, cx)?; + let icon_tooltip = entry + .panel + .icon_tooltip(window, cx) + .ok_or_else(|| { + anyhow::anyhow!("can't render a panel button without an icon tooltip") + }) + .log_err()?; let name = entry.panel.persistent_name(); let panel = entry.panel.clone(); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index e99f8d1dc959def06deebae7c4acc454c9210933..ee57f06937ee2781e8d1b965b5e498f5a31ad80d 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -11,10 +11,12 @@ use crate::{ move_item, notifications::NotifyResultExt, toolbar::Toolbar, + utility_pane::UtilityPaneSlot, workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings}, }; use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{StreamExt, stream::FuturesUnordered}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div, @@ -396,6 +398,10 @@ pub struct Pane { diagnostic_summary_update: Task<()>, /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here. pub project_item_restoration_data: HashMap>, + + pub in_center_group: bool, + pub is_upper_left: bool, + pub is_upper_right: bool, } pub struct ActivationHistoryEntry { @@ -540,6 +546,9 @@ impl Pane { zoom_out_on_close: true, diagnostic_summary_update: Task::ready(()), project_item_restoration_data: HashMap::default(), + in_center_group: false, + is_upper_left: false, + is_upper_right: false, } } @@ -3033,6 +3042,10 @@ impl Pane { } fn render_tab_bar(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let Some(workspace) = self.workspace.upgrade() else { + return gpui::Empty.into_any(); + }; + let focus_handle = self.focus_handle.clone(); let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small) @@ -3057,6 +3070,44 @@ impl Pane { } }); + let open_aside_left = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Left).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + IconButton::new("open_aside_left", toggle_icon) + .icon_size(IconSize::Small) + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(UtilityPaneSlot::Left, window, cx) + }) + .ok(); + }) + .into_any_element() + }) + }; + + let open_aside_right = { + let workspace = workspace.read(cx); + workspace.utility_pane(UtilityPaneSlot::Right).map(|pane| { + let toggle_icon = pane.toggle_icon(cx); + let workspace_handle = self.workspace.clone(); + + IconButton::new("open_aside_right", toggle_icon) + .icon_size(IconSize::Small) + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(UtilityPaneSlot::Right, window, cx) + }) + .ok(); + }) + .into_any_element() + }) + }; + let navigate_forward = IconButton::new("navigate_forward", IconName::ArrowRight) .icon_size(IconSize::Small) .on_click({ @@ -3103,13 +3154,50 @@ impl Pane { let unpinned_tabs = tab_items.split_off(self.pinned_tab_count); let pinned_tabs = tab_items; + let render_aside_toggle_left = cx.has_flag::() + && self + .is_upper_left + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Left) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + + let render_aside_toggle_right = cx.has_flag::() + && self + .is_upper_right + .then(|| { + self.workspace.upgrade().and_then(|entity| { + let workspace = entity.read(cx); + workspace + .utility_pane(UtilityPaneSlot::Right) + .map(|pane| !pane.expanded(cx)) + }) + }) + .flatten() + .unwrap_or(false); + TabBar::new("tab_bar") + .map(|tab_bar| { + if let Some(open_aside_left) = open_aside_left + && render_aside_toggle_left + { + tab_bar.start_child(open_aside_left) + } else { + tab_bar + } + }) .when( self.display_nav_history_buttons.unwrap_or_default(), |tab_bar| { tab_bar - .start_child(navigate_backward) - .start_child(navigate_forward) + .pre_end_child(navigate_backward) + .pre_end_child(navigate_forward) }, ) .map(|tab_bar| { @@ -3196,6 +3284,15 @@ impl Pane { })), ), ) + .map(|tab_bar| { + if let Some(open_aside_right) = open_aside_right + && render_aside_toggle_right + { + tab_bar.end_child(open_aside_right) + } else { + tab_bar + } + }) .into_any_element() } @@ -6664,8 +6761,8 @@ mod tests { let scroll_bounds = tab_bar_scroll_handle.bounds(); let scroll_offset = tab_bar_scroll_handle.offset(); assert!(tab_bounds.right() <= scroll_bounds.right() + scroll_offset.x); - // -39.5 is the magic number for this setup - assert_eq!(scroll_offset.x, px(-39.5)); + // -35.0 is the magic number for this setup + assert_eq!(scroll_offset.x, px(-35.0)); assert!( !tab_bounds.intersects(&new_tab_button_bounds), "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!" diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index c9d98977139ed644cf5f3bfb7eb26d94ca081d19..393ed74e30c9c34bf7cdb22aabf2de2d05aa84f8 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -28,6 +28,7 @@ const VERTICAL_MIN_SIZE: f32 = 100.; #[derive(Clone)] pub struct PaneGroup { pub root: Member, + pub is_center: bool, } pub struct PaneRenderResult { @@ -37,22 +38,31 @@ pub struct PaneRenderResult { impl PaneGroup { pub fn with_root(root: Member) -> Self { - Self { root } + Self { + root, + is_center: false, + } } pub fn new(pane: Entity) -> Self { Self { root: Member::Pane(pane), + is_center: false, } } + pub fn set_is_center(&mut self, is_center: bool) { + self.is_center = is_center; + } + pub fn split( &mut self, old_pane: &Entity, new_pane: &Entity, direction: SplitDirection, + cx: &mut App, ) -> Result<()> { - match &mut self.root { + let result = match &mut self.root { Member::Pane(pane) => { if pane == old_pane { self.root = Member::new_axis(old_pane.clone(), new_pane.clone(), direction); @@ -62,7 +72,11 @@ impl PaneGroup { } } Member::Axis(axis) => axis.split(old_pane, new_pane, direction), + }; + if result.is_ok() { + self.mark_positions(cx); } + result } pub fn bounding_box_for_pane(&self, pane: &Entity) -> Option> { @@ -90,6 +104,7 @@ impl PaneGroup { &mut self, active_pane: &Entity, direction: SplitDirection, + cx: &mut App, ) -> Result { if let Some(pane) = self.find_pane_at_border(direction) && pane == active_pane @@ -97,7 +112,7 @@ impl PaneGroup { return Ok(false); } - if !self.remove(active_pane)? { + if !self.remove_internal(active_pane)? { return Ok(false); } @@ -110,6 +125,7 @@ impl PaneGroup { 0 }; root.insert_pane(idx, active_pane); + self.mark_positions(cx); return Ok(true); } @@ -119,6 +135,7 @@ impl PaneGroup { vec![Member::Pane(active_pane.clone()), self.root.clone()] }; self.root = Member::Axis(PaneAxis::new(direction.axis(), members)); + self.mark_positions(cx); Ok(true) } @@ -133,7 +150,15 @@ impl PaneGroup { /// - Ok(true) if it found and removed a pane /// - Ok(false) if it found but did not remove the pane /// - Err(_) if it did not find the pane - pub fn remove(&mut self, pane: &Entity) -> Result { + pub fn remove(&mut self, pane: &Entity, cx: &mut App) -> Result { + let result = self.remove_internal(pane); + if let Ok(true) = result { + self.mark_positions(cx); + } + result + } + + fn remove_internal(&mut self, pane: &Entity) -> Result { match &mut self.root { Member::Pane(_) => Ok(false), Member::Axis(axis) => { @@ -151,6 +176,7 @@ impl PaneGroup { direction: Axis, amount: Pixels, bounds: &Bounds, + cx: &mut App, ) { match &mut self.root { Member::Pane(_) => {} @@ -158,22 +184,29 @@ impl PaneGroup { let _ = axis.resize(pane, direction, amount, bounds); } }; + self.mark_positions(cx); } - pub fn reset_pane_sizes(&mut self) { + pub fn reset_pane_sizes(&mut self, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => { let _ = axis.reset_pane_sizes(); } }; + self.mark_positions(cx); } - pub fn swap(&mut self, from: &Entity, to: &Entity) { + pub fn swap(&mut self, from: &Entity, to: &Entity, cx: &mut App) { match &mut self.root { Member::Pane(_) => {} Member::Axis(axis) => axis.swap(from, to), }; + self.mark_positions(cx); + } + + pub fn mark_positions(&mut self, cx: &mut App) { + self.root.mark_positions(self.is_center, true, true, cx); } pub fn render( @@ -232,8 +265,9 @@ impl PaneGroup { self.pane_at_pixel_position(target) } - pub fn invert_axies(&mut self) { + pub fn invert_axies(&mut self, cx: &mut App) { self.root.invert_pane_axies(); + self.mark_positions(cx); } } @@ -243,6 +277,43 @@ pub enum Member { Pane(Entity), } +impl Member { + pub fn mark_positions( + &mut self, + in_center_group: bool, + is_upper_left: bool, + is_upper_right: bool, + cx: &mut App, + ) { + match self { + Member::Axis(pane_axis) => { + let len = pane_axis.members.len(); + for (idx, member) in pane_axis.members.iter_mut().enumerate() { + let member_upper_left = match pane_axis.axis { + Axis::Vertical => is_upper_left && idx == 0, + Axis::Horizontal => is_upper_left && idx == 0, + }; + let member_upper_right = match pane_axis.axis { + Axis::Vertical => is_upper_right && idx == 0, + Axis::Horizontal => is_upper_right && idx == len - 1, + }; + member.mark_positions( + in_center_group, + member_upper_left, + member_upper_right, + cx, + ); + } + } + Member::Pane(entity) => entity.update(cx, |pane, _| { + pane.in_center_group = in_center_group; + pane.is_upper_left = is_upper_left; + pane.is_upper_right = is_upper_right; + }), + } + } +} + #[derive(Clone, Copy)] pub struct PaneRenderContext<'a> { pub project: &'a Entity, diff --git a/crates/workspace/src/utility_pane.rs b/crates/workspace/src/utility_pane.rs new file mode 100644 index 0000000000000000000000000000000000000000..2760000216d9164367c58d41d4f1b1893dc8cd75 --- /dev/null +++ b/crates/workspace/src/utility_pane.rs @@ -0,0 +1,282 @@ +use gpui::{ + AppContext as _, EntityId, MouseButton, Pixels, Render, StatefulInteractiveElement, + Subscription, WeakEntity, deferred, px, +}; +use ui::{ + ActiveTheme as _, Context, FluentBuilder as _, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, Styled as _, Window, div, +}; + +use crate::{ + DockPosition, Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPaneHandle}, +}; + +pub(crate) const UTILITY_PANE_RESIZE_HANDLE_SIZE: Pixels = px(6.0); +pub(crate) const UTILITY_PANE_MIN_WIDTH: Pixels = px(20.0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UtilityPaneSlot { + Left, + Right, +} + +struct UtilityPaneSlotState { + panel_id: EntityId, + utility_pane: Box, + _subscriptions: Vec, +} + +#[derive(Default)] +pub struct UtilityPaneState { + left_slot: Option, + right_slot: Option, +} + +#[derive(Clone)] +pub struct DraggedUtilityPane(pub UtilityPaneSlot); + +impl Render for DraggedUtilityPane { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +pub fn utility_slot_for_dock_position(position: DockPosition) -> UtilityPaneSlot { + match position { + DockPosition::Left => UtilityPaneSlot::Left, + DockPosition::Right => UtilityPaneSlot::Right, + DockPosition::Bottom => UtilityPaneSlot::Left, + } +} + +impl Workspace { + pub fn utility_pane(&self, slot: UtilityPaneSlot) -> Option<&dyn UtilityPaneHandle> { + match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .map(|s| s.utility_pane.as_ref()), + } + } + + pub fn toggle_utility_pane( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let current = handle.expanded(cx); + handle.set_expanded(!current, cx); + } + cx.notify(); + self.serialize_workspace(window, cx); + } + + pub fn register_utility_pane( + &mut self, + slot: UtilityPaneSlot, + panel_id: EntityId, + handle: gpui::Entity, + cx: &mut Context, + ) { + let minimize_subscription = + cx.subscribe(&handle, move |this, _, _event: &MinimizePane, cx| { + if let Some(handle) = this.utility_pane(slot) { + handle.set_expanded(false, cx); + } + cx.notify(); + }); + + let close_subscription = cx.subscribe(&handle, move |this, _, _event: &ClosePane, cx| { + this.clear_utility_pane(slot, cx); + }); + + let subscriptions = vec![minimize_subscription, close_subscription]; + let boxed_handle: Box = Box::new(handle); + + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = Some(UtilityPaneSlotState { + panel_id, + utility_pane: boxed_handle, + _subscriptions: subscriptions, + }); + } + } + cx.notify(); + } + + pub fn clear_utility_pane(&mut self, slot: UtilityPaneSlot, cx: &mut Context) { + match slot { + UtilityPaneSlot::Left => { + self.utility_panes.left_slot = None; + } + UtilityPaneSlot::Right => { + self.utility_panes.right_slot = None; + } + } + cx.notify(); + } + + pub fn clear_utility_pane_if_provider( + &mut self, + slot: UtilityPaneSlot, + provider_panel_id: EntityId, + cx: &mut Context, + ) { + let should_clear = match slot { + UtilityPaneSlot::Left => self + .utility_panes + .left_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + UtilityPaneSlot::Right => self + .utility_panes + .right_slot + .as_ref() + .is_some_and(|slot| slot.panel_id == provider_panel_id), + }; + + if should_clear { + self.clear_utility_pane(slot, cx); + } + } + + pub fn resize_utility_pane( + &mut self, + slot: UtilityPaneSlot, + new_width: Pixels, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + let max_width = self.max_utility_pane_width(window, cx); + let width = new_width.max(UTILITY_PANE_MIN_WIDTH).min(max_width); + handle.set_width(Some(width), cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } + + pub fn reset_utility_pane_width( + &mut self, + slot: UtilityPaneSlot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = self.utility_pane(slot) { + handle.set_width(None, cx); + cx.notify(); + self.serialize_workspace(window, cx); + } + } +} + +#[derive(IntoElement)] +pub struct UtilityPaneFrame { + workspace: WeakEntity, + slot: UtilityPaneSlot, + handle: Box, +} + +impl UtilityPaneFrame { + pub fn new( + slot: UtilityPaneSlot, + handle: Box, + cx: &mut Context, + ) -> Self { + let workspace = cx.weak_entity(); + Self { + workspace, + slot, + handle, + } + } +} + +impl RenderOnce for UtilityPaneFrame { + fn render(self, _window: &mut Window, cx: &mut ui::App) -> impl IntoElement { + let workspace = self.workspace.clone(); + let slot = self.slot; + let width = self.handle.width(cx); + + let create_resize_handle = || { + let workspace_handle = workspace.clone(); + let handle = div() + .id(match slot { + UtilityPaneSlot::Left => "utility-pane-resize-handle-left", + UtilityPaneSlot::Right => "utility-pane-resize-handle-right", + }) + .on_drag(DraggedUtilityPane(slot), move |pane, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| pane.clone()) + }) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + move |e: &gpui::MouseUpEvent, window, cx| { + if e.click_count == 2 { + workspace_handle + .update(cx, |workspace, cx| { + workspace.reset_utility_pane_width(slot, window, cx); + }) + .ok(); + cx.stop_propagation(); + } + }, + ) + .occlude(); + + match slot { + UtilityPaneSlot::Left => deferred( + handle + .absolute() + .right(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + UtilityPaneSlot::Right => deferred( + handle + .absolute() + .left(-UTILITY_PANE_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(UTILITY_PANE_RESIZE_HANDLE_SIZE) + .cursor_col_resize(), + ), + } + }; + + div() + .h_full() + .bg(cx.theme().colors().tab_bar_background) + .w(width) + .border_color(cx.theme().colors().border) + .when(self.slot == UtilityPaneSlot::Left, |this| this.border_r_1()) + .when(self.slot == UtilityPaneSlot::Right, |this| { + this.border_l_1() + }) + .child(create_resize_handle()) + .child(self.handle.to_any()) + .into_any_element() + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d2a9ef71fc7fc2aacb1fc2f9be41ce001f5cef5e..56dfb2398997a19e98c339876987419bb925f324 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -15,6 +15,7 @@ pub mod tasks; mod theme_preview; mod toast_layer; mod toolbar; +pub mod utility_pane; mod workspace_settings; pub use crate::notifications::NotificationFrame; @@ -30,6 +31,7 @@ use client::{ }; use collections::{HashMap, HashSet, hash_map}; use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE}; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use futures::{ Future, FutureExt, StreamExt, channel::{ @@ -126,11 +128,16 @@ pub use workspace_settings::{ }; use zed_actions::{Spawn, feedback::FileBugReport}; -use crate::persistence::{ - SerializedAxis, - model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, +use crate::{ + item::ItemBufferKind, notifications::NotificationId, utility_pane::UTILITY_PANE_MIN_WIDTH, +}; +use crate::{ + persistence::{ + SerializedAxis, + model::{DockData, DockStructure, SerializedItem, SerializedPane, SerializedPaneGroup}, + }, + utility_pane::{DraggedUtilityPane, UtilityPaneFrame, UtilityPaneSlot, UtilityPaneState}, }; -use crate::{item::ItemBufferKind, notifications::NotificationId}; pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200); @@ -1175,6 +1182,7 @@ pub struct Workspace { scheduled_tasks: Vec>, last_open_dock_positions: Vec, removing: bool, + utility_panes: UtilityPaneState, } impl EventEmitter for Workspace {} @@ -1466,12 +1474,17 @@ impl Workspace { this.update_window_title(window, cx); this.show_initial_notifications(cx); }); + + let mut center = PaneGroup::new(center_pane.clone()); + center.set_is_center(true); + center.mark_positions(cx); + Workspace { weak_self: weak_handle.clone(), zoomed: None, zoomed_position: None, previous_dock_drag_coordinates: None, - center: PaneGroup::new(center_pane.clone()), + center, panes: vec![center_pane.clone()], panes_by_item: Default::default(), active_pane: center_pane.clone(), @@ -1519,6 +1532,7 @@ impl Workspace { scheduled_tasks: Vec::new(), last_open_dock_positions: Vec::new(), removing: false, + utility_panes: UtilityPaneState::default(), } } @@ -3771,7 +3785,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&split_off_pane, &new_pane, direction) + .split(&split_off_pane, &new_pane, direction, cx) .log_err() .is_none() { @@ -3956,7 +3970,7 @@ impl Workspace { let new_pane = self.add_pane(window, cx); if self .center - .split(&self.active_pane, &new_pane, action.direction) + .split(&self.active_pane, &new_pane, action.direction, cx) .log_err() .is_none() { @@ -4010,7 +4024,7 @@ impl Workspace { pub fn swap_pane_in_direction(&mut self, direction: SplitDirection, cx: &mut Context) { if let Some(to) = self.find_pane_in_direction(direction, cx) { - self.center.swap(&self.active_pane, &to); + self.center.swap(&self.active_pane, &to, cx); cx.notify(); } } @@ -4018,7 +4032,7 @@ impl Workspace { pub fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context) { if self .center - .move_to_border(&self.active_pane, direction) + .move_to_border(&self.active_pane, direction, cx) .unwrap() { cx.notify(); @@ -4048,13 +4062,13 @@ impl Workspace { } } else { self.center - .resize(&self.active_pane, axis, amount, &self.bounds); + .resize(&self.active_pane, axis, amount, &self.bounds, cx); } cx.notify(); } pub fn reset_pane_sizes(&mut self, cx: &mut Context) { - self.center.reset_pane_sizes(); + self.center.reset_pane_sizes(cx); cx.notify(); } @@ -4240,7 +4254,7 @@ impl Workspace { ) -> Entity { let new_pane = self.add_pane(window, cx); self.center - .split(&pane_to_split, &new_pane, split_direction) + .split(&pane_to_split, &new_pane, split_direction, cx) .unwrap(); cx.notify(); new_pane @@ -4260,7 +4274,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(item, true, true, None, window, cx) }); - self.center.split(&pane, &new_pane, direction).unwrap(); + self.center.split(&pane, &new_pane, direction, cx).unwrap(); cx.notify(); } @@ -4285,7 +4299,7 @@ impl Workspace { new_pane.update(cx, |pane, cx| { pane.add_item(clone, true, true, None, window, cx) }); - this.center.split(&pane, &new_pane, direction).unwrap(); + this.center.split(&pane, &new_pane, direction, cx).unwrap(); cx.notify(); new_pane }) @@ -4332,7 +4346,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - if self.center.remove(&pane).unwrap() { + if self.center.remove(&pane, cx).unwrap() { self.force_remove_pane(&pane, &focus_on, window, cx); self.unfollow_in_pane(&pane, window, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); @@ -5684,6 +5698,9 @@ impl Workspace { // Swap workspace center group workspace.center = PaneGroup::with_root(center_group); + workspace.center.set_is_center(true); + workspace.center.mark_positions(cx); + if let Some(active_pane) = active_pane { workspace.set_active_pane(&active_pane, window, cx); cx.focus_self(window); @@ -6309,6 +6326,7 @@ impl Workspace { left_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_right_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6331,6 +6349,7 @@ impl Workspace { right_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); } fn resize_bottom_dock(&mut self, new_size: Pixels, window: &mut Window, cx: &mut App) { @@ -6345,6 +6364,42 @@ impl Workspace { bottom_dock.resize_active_panel(Some(size), window, cx); } }); + self.clamp_utility_pane_widths(window, cx); + } + + fn max_utility_pane_width(&self, window: &Window, cx: &App) -> Pixels { + let left_dock_width = self + .left_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let right_dock_width = self + .right_dock + .read(cx) + .active_panel_size(window, cx) + .unwrap_or(px(0.0)); + let center_pane_width = self.bounds.size.width - left_dock_width - right_dock_width; + center_pane_width - px(10.0) + } + + fn clamp_utility_pane_widths(&mut self, window: &mut Window, cx: &mut App) { + let max_width = self.max_utility_pane_width(window, cx); + + // Clamp left slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Left) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } + + // Clamp right slot utility pane if it exists + if let Some(handle) = self.utility_pane(UtilityPaneSlot::Right) { + let current_width = handle.width(cx); + if current_width > max_width { + handle.set_width(Some(max_width.max(UTILITY_PANE_MIN_WIDTH)), cx); + } + } } fn toggle_edit_predictions_all_files( @@ -6812,6 +6867,34 @@ impl Render for Workspace { } }, )) + .on_drag_move(cx.listener( + move |workspace, + e: &DragMoveEvent, + window, + cx| { + let slot = e.drag(cx).0; + match slot { + UtilityPaneSlot::Left => { + let left_dock_width = workspace.left_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = e.event.position.x + - workspace.bounds.left() + - left_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + UtilityPaneSlot::Right => { + let right_dock_width = workspace.right_dock.read(cx) + .active_panel_size(window, cx) + .unwrap_or(gpui::px(0.0)); + let new_width = workspace.bounds.right() + - e.event.position.x + - right_dock_width; + workspace.resize_utility_pane(slot, new_width, window, cx); + } + } + }, + )) }) .child({ match bottom_dock_layout { @@ -6831,6 +6914,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6872,6 +6964,15 @@ impl Render for Workspace { ), ), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, @@ -6902,6 +7003,15 @@ impl Render for Workspace { .flex_row() .flex_1() .children(self.render_dock(DockPosition::Left, &self.left_dock, window, cx)) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6929,6 +7039,13 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) ) .child( div() @@ -6953,6 +7070,15 @@ impl Render for Workspace { window, cx, )) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) + }) .child( div() .flex() @@ -6991,6 +7117,15 @@ impl Render for Workspace { .when_some(paddings.1, |this, p| this.child(p.border_l_1())), ) ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock(DockPosition::Right, &self.right_dock, window, cx)) ) .child( @@ -7010,6 +7145,13 @@ impl Render for Workspace { window, cx, )) + .when_some(self.utility_pane(UtilityPaneSlot::Left), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Left, pane.box_clone(), cx) + ) + }) + }) .child( div() .flex() @@ -7047,6 +7189,15 @@ impl Render for Workspace { cx, )), ) + .when(cx.has_flag::(), |this| { + this.when_some(self.utility_pane(UtilityPaneSlot::Right), |this, pane| { + this.when(pane.expanded(cx), |this| { + this.child( + UtilityPaneFrame::new(UtilityPaneSlot::Right, pane.box_clone(), cx) + ) + }) + }) + }) .children(self.render_dock( DockPosition::Right, &self.right_dock, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 92a274da9640bbe9ee3afefeacd7566c853bdd2d..141de1139fb571020377ef9b115ed8204bad100b 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -26,6 +26,7 @@ acp_tools.workspace = true activity_indicator.workspace = true agent_settings.workspace = true agent_ui.workspace = true +agent_ui_v2.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index dfbc57d293e392f654b0920455e5614dd969bcdb..6d94a15a666c6659f522d4b61962c932347b6304 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -597,6 +597,7 @@ pub fn main() { false, cx, ); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); recent_projects::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 71653124b1c4af993d9878b2b689d07f4f2acd02..3bc05ef540769800ef96a76bcbcfd24b09680192 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -10,6 +10,7 @@ mod quick_action_bar; pub(crate) mod windows_only_instance; use agent_ui::{AgentDiffToolbar, AgentPanelDelegate}; +use agent_ui_v2::agents_panel::AgentsPanel; use anyhow::Context as _; pub use app_menus::*; use assets::Assets; @@ -81,8 +82,9 @@ use vim_mode_setting::VimModeSetting; use workspace::notifications::{ NotificationId, SuppressEvent, dismiss_app_notification, show_app_notification, }; +use workspace::utility_pane::utility_slot_for_dock_position; use workspace::{ - AppState, NewFile, NewWindow, OpenLog, Toast, Workspace, WorkspaceSettings, + AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings, create_and_open_local_file, notifications::simple_message_notification::MessageNotification, open_new, }; @@ -679,7 +681,8 @@ fn initialize_panels( add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(notification_panel, workspace_handle.clone(), cx.clone()), add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()), - initialize_agent_panel(workspace_handle, prompt_builder, cx.clone()).map(|r| r.log_err()) + initialize_agent_panel(workspace_handle.clone(), prompt_builder, cx.clone()).map(|r| r.log_err()), + initialize_agents_panel(workspace_handle, cx.clone()).map(|r| r.log_err()) ); anyhow::Ok(()) @@ -687,58 +690,65 @@ fn initialize_panels( .detach(); } +fn setup_or_teardown_ai_panel( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + load_panel: impl FnOnce( + WeakEntity, + AsyncWindowContext, + ) -> Task>> + + 'static, +) -> Task> { + let disable_ai = SettingsStore::global(cx) + .get::(None) + .disable_ai + || cfg!(test); + let existing_panel = workspace.panel::

(cx); + + match (disable_ai, existing_panel) { + (false, None) => cx.spawn_in(window, async move |workspace, cx| { + let panel = load_panel(workspace.clone(), cx.clone()).await?; + workspace.update_in(cx, |workspace, window, cx| { + let disable_ai = SettingsStore::global(cx) + .get::(None) + .disable_ai; + let have_panel = workspace.panel::

(cx).is_some(); + if !disable_ai && !have_panel { + workspace.add_panel(panel, window, cx); + } + }) + }), + (true, Some(existing_panel)) => { + workspace.remove_panel::

(&existing_panel, window, cx); + Task::ready(Ok(())) + } + _ => Task::ready(Ok(())), + } +} + async fn initialize_agent_panel( workspace_handle: WeakEntity, prompt_builder: Arc, mut cx: AsyncWindowContext, ) -> anyhow::Result<()> { - fn setup_or_teardown_agent_panel( - workspace: &mut Workspace, - prompt_builder: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Task> { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai - || cfg!(test); - let existing_panel = workspace.panel::(cx); - match (disable_ai, existing_panel) { - (false, None) => cx.spawn_in(window, async move |workspace, cx| { - let panel = - agent_ui::AgentPanel::load(workspace.clone(), prompt_builder, cx.clone()) - .await?; - workspace.update_in(cx, |workspace, window, cx| { - let disable_ai = SettingsStore::global(cx) - .get::(None) - .disable_ai; - let have_panel = workspace.panel::(cx).is_some(); - if !disable_ai && !have_panel { - workspace.add_panel(panel, window, cx); - } - }) - }), - (true, Some(existing_panel)) => { - workspace.remove_panel::(&existing_panel, window, cx); - Task::ready(Ok(())) - } - _ => Task::ready(Ok(())), - } - } - workspace_handle .update_in(&mut cx, |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) + let prompt_builder = prompt_builder.clone(); + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) })? .await?; workspace_handle.update_in(&mut cx, |workspace, window, cx| { - cx.observe_global_in::(window, { + let prompt_builder = prompt_builder.clone(); + cx.observe_global_in::(window, move |workspace, window, cx| { let prompt_builder = prompt_builder.clone(); - move |workspace, window, cx| { - setup_or_teardown_agent_panel(workspace, prompt_builder.clone(), window, cx) - .detach_and_log_err(cx); - } + setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| { + agent_ui::AgentPanel::load(workspace, prompt_builder, cx) + }) + .detach_and_log_err(cx); }) .detach(); @@ -763,6 +773,31 @@ async fn initialize_agent_panel( anyhow::Ok(()) } +async fn initialize_agents_panel( + workspace_handle: WeakEntity, + mut cx: AsyncWindowContext, +) -> anyhow::Result<()> { + workspace_handle + .update_in(&mut cx, |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + })? + .await?; + + workspace_handle.update_in(&mut cx, |_workspace, window, cx| { + cx.observe_global_in::(window, move |workspace, window, cx| { + setup_or_teardown_ai_panel(workspace, window, cx, |workspace, cx| { + AgentsPanel::load(workspace, cx) + }) + .detach_and_log_err(cx); + }) + .detach(); + })?; + + anyhow::Ok(()) +} + fn register_actions( app_state: Arc, workspace: &mut Workspace, @@ -1052,6 +1087,18 @@ fn register_actions( workspace.toggle_panel_focus::(window, cx); }, ) + .register_action( + |workspace: &mut Workspace, + _: &zed_actions::agent::ToggleAgentPane, + window: &mut Window, + cx: &mut Context| { + if let Some(panel) = workspace.panel::(cx) { + let position = panel.read(cx).position(window, cx); + let slot = utility_slot_for_dock_position(position); + workspace.toggle_utility_pane(slot, window, cx); + } + }, + ) .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &NewWindow, _, cx| { @@ -4714,6 +4761,7 @@ mod tests { "action", "activity_indicator", "agent", + "agents", #[cfg(not(target_os = "macos"))] "app_menu", "assistant", @@ -4941,6 +4989,7 @@ mod tests { false, cx, ); + agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); repl::notebook::init(cx); tasks_ui::init(cx); diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index a89e943e021e79058953de46bca57713f51598bc..f69baa03b002fdcac5207f977a23cfc924283e2d 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -350,6 +350,8 @@ pub mod agent { AddSelectionToThread, /// Resets the agent panel zoom levels (agent UI and buffer font sizes). ResetAgentZoom, + /// Toggles the utility/agent pane open/closed state. + ToggleAgentPane, ] ); } From c952de4bfbbc98a6c11644d7945e7830d802b9e4 Mon Sep 17 00:00:00 2001 From: Abderrahmane TAHRI JOUTI <302837+atahrijouti@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:20:12 +0100 Subject: [PATCH 35/67] Cleanup helix keymaps (#43735) Release Notes: - Add search category to helix keymaps - Cleanup unnecessary comments - Indicate non helix keymap --- assets/keymaps/vim.json | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 24cc021709656de204def3ee8b45a790ce7eb1b0..34bbd44fc3be6a8bd6fa35944e073f5118d6cd33 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -445,9 +445,9 @@ "shift-r": "editor::Paste", "`": "vim::ConvertToLowerCase", "alt-`": "vim::ConvertToUpperCase", - "insert": "vim::InsertBefore", + "insert": "vim::InsertBefore", // not a helix default "shift-u": "editor::Redo", - "ctrl-r": "vim::Redo", + "ctrl-r": "vim::Redo", // not a helix default "y": "vim::HelixYank", "p": "vim::HelixPaste", "shift-p": ["vim::HelixPaste", { "before": true }], @@ -476,6 +476,7 @@ "alt-p": "editor::SelectPreviousSyntaxNode", "alt-n": "editor::SelectNextSyntaxNode", + // Search "n": "vim::HelixSelectNext", "shift-n": "vim::HelixSelectPrevious", @@ -483,27 +484,27 @@ "g e": "vim::EndOfDocument", "g h": "vim::StartOfLine", "g l": "vim::EndOfLine", - "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" + "g s": "vim::FirstNonWhitespace", "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", - "g r": "editor::FindAllReferences", // zed specific + "g r": "editor::FindAllReferences", "g n": "pane::ActivateNextItem", - "shift-l": "pane::ActivateNextItem", + "shift-l": "pane::ActivateNextItem", // not a helix default "g p": "pane::ActivatePreviousItem", - "shift-h": "pane::ActivatePreviousItem", - "g .": "vim::HelixGotoLastModification", // go to last modification + "shift-h": "pane::ActivatePreviousItem", // not a helix default + "g .": "vim::HelixGotoLastModification", // Window mode + "space w v": "pane::SplitDown", + "space w s": "pane::SplitRight", "space w h": "workspace::ActivatePaneLeft", - "space w l": "workspace::ActivatePaneRight", - "space w k": "workspace::ActivatePaneUp", "space w j": "workspace::ActivatePaneDown", + "space w k": "workspace::ActivatePaneUp", + "space w l": "workspace::ActivatePaneRight", "space w q": "pane::CloseActiveItem", - "space w s": "pane::SplitRight", - "space w r": "pane::SplitRight", - "space w v": "pane::SplitDown", - "space w d": "pane::SplitDown", + "space w r": "pane::SplitRight", // not a helix default + "space w d": "pane::SplitDown", // not a helix default // Space mode "space f": "file_finder::Toggle", @@ -525,9 +526,7 @@ "]": ["vim::PushHelixNext", { "around": true }], "[": ["vim::PushHelixPrevious", { "around": true }], "g q": "vim::PushRewrap", - "g w": "vim::PushRewrap" - // "tab": "pane::ActivateNextItem", - // "shift-tab": "pane::ActivatePrevItem", + "g w": "vim::PushRewrap" // not a helix default & clashes with helix `goto_word` } }, { From a78ffdafa9cf1aa111634d753a06628cf6167ab9 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 15 Dec 2025 11:33:10 +0100 Subject: [PATCH 36/67] search: Retain replace status when re-deploying active search panels (#44862) Closes https://github.com/zed-industries/zed/issues/15918 Release Notes: - Fixed search bars losing their replace state if you re-focus on them via actions or keybinds --- crates/search/src/buffer_search.rs | 14 ++++++++------ crates/search/src/project_search.rs | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index a9c26ac9bad0f524acdb47d6f09c2bd67cb8dfc6..686d385aa07accac168062fa598790b36e80199f 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -729,12 +729,14 @@ impl BufferSearchBar { self.search_suggested(window, cx); self.smartcase(window, cx); self.sync_select_next_case_sensitivity(cx); - self.replace_enabled = deploy.replace_enabled; - self.selection_search_enabled = if deploy.selection_search_enabled { - Some(FilteredSearchRange::Default) - } else { - None - }; + self.replace_enabled |= deploy.replace_enabled; + self.selection_search_enabled = + self.selection_search_enabled + .or(if deploy.selection_search_enabled { + Some(FilteredSearchRange::Default) + } else { + None + }); if deploy.focus { let mut handle = self.query_editor.focus_handle(cx); let mut select_query = true; diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index a9ca77a5b8bd30b8492cf8f8dfa2b17fdcdb6a5b..278f2e86b7b13fd5a82777054c12ff2e1b6239bb 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1147,7 +1147,7 @@ impl ProjectSearchView { }; search.update(cx, |search, cx| { - search.replace_enabled = action.replace_enabled; + search.replace_enabled |= action.replace_enabled; if let Some(query) = query { search.set_query(&query, window, cx); } From dd13c95158b147f4676ea730723e476fb4b1bce7 Mon Sep 17 00:00:00 2001 From: Zachiah Sawyer Date: Mon, 15 Dec 2025 02:40:37 -0800 Subject: [PATCH 37/67] Make `cmd-click` require the modifier on mousedown (#44579) Closes #44537 Release Notes: - Improved Cmd+Click behavior. Now requires Cmd to be pressed before the click starts or it doesn't run --- crates/editor/src/element.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 653cf291a7ff2ea79152535392241ae94eaf05f3..5a5b32e1755f5a026800f3af3c1cedaf6b11996d 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1017,10 +1017,16 @@ impl EditorElement { let pending_nonempty_selections = editor.has_pending_nonempty_selection(); let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&event.modifiers(), cx); + let mouse_down_hovered_link_modifier = if let ClickEvent::Mouse(mouse_event) = event { + Editor::is_cmd_or_ctrl_pressed(&mouse_event.down.modifiers, cx) + } else { + true + }; if let Some(mouse_position) = event.mouse_position() && !pending_nonempty_selections && hovered_link_modifier + && mouse_down_hovered_link_modifier && text_hitbox.is_hovered(window) { let point = position_map.point_for_position(mouse_position); From 693b978c8dbeb5683d34d282f6d6f09dc17cf4d5 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 15 Dec 2025 11:54:08 +0100 Subject: [PATCH 38/67] proto: Add two language servers and change used grammar (#44440) Closes #43784 Closes #44375 Closes #21057 This PR updates the Proto extension to include support for two new language servers as well as an updated grammar for better highlighting. Release Notes: - Improved Proto support to work better out of the box. --- Cargo.lock | 2 +- assets/settings/default.json | 3 + extensions/proto/Cargo.toml | 2 +- extensions/proto/extension.toml | 13 +- extensions/proto/src/language_servers.rs | 8 ++ extensions/proto/src/language_servers/buf.rs | 114 ++++++++++++++++++ .../protobuf_language_server.rs | 52 ++++++++ .../proto/src/language_servers/protols.rs | 113 +++++++++++++++++ extensions/proto/src/language_servers/util.rs | 19 +++ extensions/proto/src/proto.rs | 86 +++++-------- typos.toml | 3 + 11 files changed, 358 insertions(+), 57 deletions(-) create mode 100644 extensions/proto/src/language_servers.rs create mode 100644 extensions/proto/src/language_servers/buf.rs create mode 100644 extensions/proto/src/language_servers/protobuf_language_server.rs create mode 100644 extensions/proto/src/language_servers/protols.rs create mode 100644 extensions/proto/src/language_servers/util.rs diff --git a/Cargo.lock b/Cargo.lock index 7933ef3099af76a81200ae99b75fb2ccbc5671c6..29de11a496fb25b86fcbc87c1f394c65a8e364b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20828,7 +20828,7 @@ dependencies = [ name = "zed_proto" version = "0.2.3" dependencies = [ - "zed_extension_api 0.1.0", + "zed_extension_api 0.7.0", ] [[package]] diff --git a/assets/settings/default.json b/assets/settings/default.json index 0283cdd5bad26e423bb914eb40c070912e30bd36..c4c66f47a6948bc755e588cc37504dc01e954e36 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1932,6 +1932,9 @@ "words": "disabled", }, }, + "Proto": { + "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."] + }, "Python": { "code_actions_on_format": { "source.organizeImports.ruff": true, diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 1013d62cfa085275a1230d0816049da6c35ba38a..d4c966a686a1ef0bfa2fe658c45f3b391e54ccee 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -13,4 +13,4 @@ path = "src/proto.rs" crate-type = ["cdylib"] [dependencies] -zed_extension_api = "0.1.0" +zed_extension_api = "0.7.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index 9bb8625065fe957308c47488c4aeb9010a773984..ff8c5758d0c1780031ef850a912d294dcef1a40e 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -7,9 +7,18 @@ authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" [grammars.proto] -repository = "https://github.com/zed-industries/tree-sitter-proto" -commit = "0848bd30a64be48772e15fbb9d5ba8c0cc5772ad" +repository = "https://github.com/coder3101/tree-sitter-proto" +commit = "a6caac94b5aa36b322b5b70040d5b67132f109d0" + + +[language_servers.buf] +name = "Buf" +languages = ["Proto"] [language_servers.protobuf-language-server] name = "Protobuf Language Server" languages = ["Proto"] + +[language_servers.protols] +name = "Protols" +languages = ["Proto"] diff --git a/extensions/proto/src/language_servers.rs b/extensions/proto/src/language_servers.rs new file mode 100644 index 0000000000000000000000000000000000000000..47a5e72d8aadf5d0286667148f0a7dd95fea10ba --- /dev/null +++ b/extensions/proto/src/language_servers.rs @@ -0,0 +1,8 @@ +mod buf; +mod protobuf_language_server; +mod protols; +mod util; + +pub(crate) use buf::*; +pub(crate) use protobuf_language_server::*; +pub(crate) use protols::*; diff --git a/extensions/proto/src/language_servers/buf.rs b/extensions/proto/src/language_servers/buf.rs new file mode 100644 index 0000000000000000000000000000000000000000..92106298d3d1deb6ed2b0f4194ab09321fa09552 --- /dev/null +++ b/extensions/proto/src/language_servers/buf.rs @@ -0,0 +1,114 @@ +use std::fs; + +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct BufLsp { + cached_binary_path: Option, +} + +impl BufLsp { + pub(crate) const SERVER_NAME: &str = "buf"; + + pub(crate) fn new() -> Self { + BufLsp { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| ["lsp", "serve"].map(ToOwned::to_owned).into()); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env: Default::default(), + }); + } + + let latest_release = zed::latest_github_release( + "bufbuild/buf", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "Darwin-arm64", + (Os::Mac, Architecture::X8664) => "Darwin-x86_64", + (Os::Linux, Architecture::Aarch64) => "Linux-aarch64", + (Os::Linux, Architecture::X8664) => "Linux-x86_64", + (Os::Windows, Architecture::Aarch64) => "Windows-arm64.exe", + (Os::Windows, Architecture::X8664) => "Windows-x86_64.exe", + _ => { + return Err("Platform and architecture not supported by buf CLI".to_string()); + } + }; + + let release_name = format!("buf-{release_suffix}"); + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + fs::create_dir_all(&version_dir).map_err(|_| "Could not create directory")?; + + let binary_path = format!("{version_dir}/buf"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in buf CLI release", + &release_name + ) + })?; + + zed::download_file( + &download_target.download_url, + &binary_path, + DownloadedFileType::Uncompressed, + )?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env: Default::default(), + }) + } +} diff --git a/extensions/proto/src/language_servers/protobuf_language_server.rs b/extensions/proto/src/language_servers/protobuf_language_server.rs new file mode 100644 index 0000000000000000000000000000000000000000..f4b13077f73182dd0c30486ee274ade26ec1e40e --- /dev/null +++ b/extensions/proto/src/language_servers/protobuf_language_server.rs @@ -0,0 +1,52 @@ +use zed_extension_api::{self as zed, Result, settings::LspSettings}; + +pub(crate) struct ProtobufLanguageServer { + cached_binary_path: Option, +} + +impl ProtobufLanguageServer { + pub(crate) const SERVER_NAME: &str = "protobuf-language-server"; + + pub(crate) fn new() -> Self { + ProtobufLanguageServer { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_else(|| vec!["-logs".into(), "".into()]); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = self.cached_binary_path.clone() { + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + Ok(zed::Command { + command: path, + args, + env: Default::default(), + }) + } else { + Err(format!("{} not found in PATH", Self::SERVER_NAME)) + } + } +} diff --git a/extensions/proto/src/language_servers/protols.rs b/extensions/proto/src/language_servers/protols.rs new file mode 100644 index 0000000000000000000000000000000000000000..90d365eae7d99ccb27d60f774ed700b47323d8d0 --- /dev/null +++ b/extensions/proto/src/language_servers/protols.rs @@ -0,0 +1,113 @@ +use zed_extension_api::{ + self as zed, Architecture, DownloadedFileType, GithubReleaseOptions, Os, Result, + settings::LspSettings, +}; + +use crate::language_servers::util; + +pub(crate) struct ProtoLs { + cached_binary_path: Option, +} + +impl ProtoLs { + pub(crate) const SERVER_NAME: &str = "protols"; + + pub(crate) fn new() -> Self { + ProtoLs { + cached_binary_path: None, + } + } + + pub(crate) fn language_server_binary( + &mut self, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::SERVER_NAME, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let args = binary_settings + .as_ref() + .and_then(|binary_settings| binary_settings.arguments.clone()) + .unwrap_or_default(); + + let env = worktree.shell_env(); + + if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = self.cached_binary_path.clone() { + return Ok(zed::Command { + command: path, + args, + env, + }); + } else if let Some(path) = worktree.which(Self::SERVER_NAME) { + self.cached_binary_path = Some(path.clone()); + return Ok(zed::Command { + command: path, + args, + env, + }); + } + + let latest_release = zed::latest_github_release( + "coder3101/protols", + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (os, arch) = zed::current_platform(); + + let release_suffix = match (os, arch) { + (Os::Mac, Architecture::Aarch64) => "aarch64-apple-darwin.tar.gz", + (Os::Mac, Architecture::X8664) => "x86_64-apple-darwin.tar.gz", + (Os::Linux, Architecture::Aarch64) => "aarch64-unknown-linux-gnu.tar.gz", + (Os::Linux, Architecture::X8664) => "x86_64-unknown-linux-gnu.tar.gz", + (Os::Windows, Architecture::X8664) => "x86_64-pc-windows-msvc.zip", + _ => { + return Err("Platform and architecture not supported by Protols".to_string()); + } + }; + + let release_name = format!("protols-{release_suffix}"); + + let file_type = if os == Os::Windows { + DownloadedFileType::Zip + } else { + DownloadedFileType::GzipTar + }; + + let version_dir = format!("{}-{}", Self::SERVER_NAME, latest_release.version); + let binary_path = format!("{version_dir}/protols"); + + let download_target = latest_release + .assets + .into_iter() + .find(|asset| asset.name == release_name) + .ok_or_else(|| { + format!( + "Could not find asset with name {} in Protols release", + &release_name + ) + })?; + + zed::download_file(&download_target.download_url, &version_dir, file_type)?; + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::SERVER_NAME, &version_dir)?; + + self.cached_binary_path = Some(binary_path.clone()); + + Ok(zed::Command { + command: binary_path, + args, + env, + }) + } +} diff --git a/extensions/proto/src/language_servers/util.rs b/extensions/proto/src/language_servers/util.rs new file mode 100644 index 0000000000000000000000000000000000000000..3036c9bc3aaf9cc3fccd462fe0ad70aa31892012 --- /dev/null +++ b/extensions/proto/src/language_servers/util.rs @@ -0,0 +1,19 @@ +use std::fs; + +use zed_extension_api::Result; + +pub(super) fn remove_outdated_versions( + language_server_id: &'static str, + version_dir: &str, +) -> Result<()> { + let entries = fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str().is_none_or(|file_name| { + file_name.starts_with(language_server_id) && file_name != version_dir + }) { + fs::remove_dir_all(entry.path()).ok(); + } + } + Ok(()) +} diff --git a/extensions/proto/src/proto.rs b/extensions/proto/src/proto.rs index 36ba0faf5feda66af8824387240e34a730a476b7..07e0ccedcee287f037576db56d5a9d7958ea83f9 100644 --- a/extensions/proto/src/proto.rs +++ b/extensions/proto/src/proto.rs @@ -1,48 +1,22 @@ use zed_extension_api::{self as zed, Result, settings::LspSettings}; -const PROTOBUF_LANGUAGE_SERVER_NAME: &str = "protobuf-language-server"; +use crate::language_servers::{BufLsp, ProtoLs, ProtobufLanguageServer}; -struct ProtobufLanguageServerBinary { - path: String, - args: Option>, -} - -struct ProtobufExtension; - -impl ProtobufExtension { - fn language_server_binary( - &self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - let binary_settings = LspSettings::for_worktree("protobuf-language-server", worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.binary); - let binary_args = binary_settings - .as_ref() - .and_then(|binary_settings| binary_settings.arguments.clone()); - - if let Some(path) = binary_settings.and_then(|binary_settings| binary_settings.path) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } - - if let Some(path) = worktree.which(PROTOBUF_LANGUAGE_SERVER_NAME) { - return Ok(ProtobufLanguageServerBinary { - path, - args: binary_args, - }); - } +mod language_servers; - Err(format!("{PROTOBUF_LANGUAGE_SERVER_NAME} not found in PATH",)) - } +struct ProtobufExtension { + protobuf_language_server: Option, + protols: Option, + buf_lsp: Option, } impl zed::Extension for ProtobufExtension { fn new() -> Self { - Self + Self { + protobuf_language_server: None, + protols: None, + buf_lsp: None, + } } fn language_server_command( @@ -50,14 +24,24 @@ impl zed::Extension for ProtobufExtension { language_server_id: &zed_extension_api::LanguageServerId, worktree: &zed_extension_api::Worktree, ) -> zed_extension_api::Result { - let binary = self.language_server_binary(language_server_id, worktree)?; - Ok(zed::Command { - command: binary.path, - args: binary - .args - .unwrap_or_else(|| vec!["-logs".into(), "".into()]), - env: Default::default(), - }) + match language_server_id.as_ref() { + ProtobufLanguageServer::SERVER_NAME => self + .protobuf_language_server + .get_or_insert_with(ProtobufLanguageServer::new) + .language_server_binary(worktree), + + ProtoLs::SERVER_NAME => self + .protols + .get_or_insert_with(ProtoLs::new) + .language_server_binary(worktree), + + BufLsp::SERVER_NAME => self + .buf_lsp + .get_or_insert_with(BufLsp::new) + .language_server_binary(worktree), + + _ => Err(format!("Unknown language server ID {}", language_server_id)), + } } fn language_server_workspace_configuration( @@ -65,10 +49,8 @@ impl zed::Extension for ProtobufExtension { server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let settings = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.settings); - Ok(settings) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings) } fn language_server_initialization_options( @@ -76,10 +58,8 @@ impl zed::Extension for ProtobufExtension { server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> Result> { - let initialization_options = LspSettings::for_worktree(server_id.as_ref(), worktree) - .ok() - .and_then(|lsp_settings| lsp_settings.initialization_options); - Ok(initialization_options) + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options) } } diff --git a/typos.toml b/typos.toml index 20a7b511a85676e3c5e49c23cab71c52e471cee9..8e42bd674a64d8adc1e684df181c8e4ce67988e9 100644 --- a/typos.toml +++ b/typos.toml @@ -31,6 +31,9 @@ extend-exclude = [ "crates/rpc/src/auth.rs", # glsl isn't recognized by this tool. "extensions/glsl/languages/glsl/", + # Protols is the name of the language server. + "extensions/proto/extension.toml", + "extensions/proto/src/language_servers/protols.rs", # Windows likes its abbreviations. "crates/gpui/src/platform/windows/directx_renderer.rs", "crates/gpui/src/platform/windows/events.rs", From 79d4f7d33d75789a6aaef2f3b82d9a9a20659ed1 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 15 Dec 2025 12:01:15 +0100 Subject: [PATCH 39/67] extension_api: Add `digest` to `GithubReleaseAsset` (#44399) Release Notes: - N/A --- crates/extension_api/wit/since_v0.8.0/github.wit | 2 ++ crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs | 1 + crates/project/src/agent_server_store.rs | 6 +++--- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/extension_api/wit/since_v0.8.0/github.wit b/crates/extension_api/wit/since_v0.8.0/github.wit index 21cd5d48056af08441d3bb5aa8547edd97a874d7..6d7e5d952ae921925459f475bceb74d6c384d8be 100644 --- a/crates/extension_api/wit/since_v0.8.0/github.wit +++ b/crates/extension_api/wit/since_v0.8.0/github.wit @@ -13,6 +13,8 @@ interface github { name: string, /// The download URL for the asset. download-url: string, + /// The SHA-256 of the release asset if provided by the GitHub API. + digest: option, } /// The options used to filter down GitHub releases. diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index a2776f9f3b5b055d00787fb59c9bbca582352b1f..b32ab97983642d68aba041ee3afb902a0c5d2455 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -783,6 +783,7 @@ impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAs Self { name: value.name, download_url: value.browser_download_url, + digest: value.digest, } } } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index a2cc57beae9702e4d5b495a135e7c357c638c17a..62937476b8eea4b30f02637b3501ea2b56db81a1 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1495,7 +1495,7 @@ impl ExternalAgentServer for LocalCodex { let digest = asset .digest .as_deref() - .and_then(|d| d.strip_prefix("sha256:").or(Some(d))); + .map(|d| d.strip_prefix("sha256:").unwrap_or(d)); match ::http_client::github_download::download_server_binary( &*http, &asset.browser_download_url, @@ -1727,10 +1727,10 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { release.assets.iter().find(|a| a.name == filename) { // Strip "sha256:" prefix if present - asset.digest.as_ref().and_then(|d| { + asset.digest.as_ref().map(|d| { d.strip_prefix("sha256:") .map(|s| s.to_string()) - .or_else(|| Some(d.clone())) + .unwrap_or_else(|| d.clone()) }) } else { None From 2f63543380c8d450e48523e9e3ffd800d8fdde7b Mon Sep 17 00:00:00 2001 From: Oscar Villavicencio Date: Mon, 15 Dec 2025 03:11:26 -0800 Subject: [PATCH 40/67] agent: Disable git pager to avoid hangs (#43277) - Set PAGER='' and GIT_PAGER=cat for agent/terminal commands so pager configs (e.g. delta) don't hang tool output\n\nFixes #42943 Release Notes: - Prevent git pager configs from hanging agent/terminal git commands by forcing PAGER and GIT_PAGER off. --- crates/acp_thread/src/terminal.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 2da4125209d3bcf902d23380c5273d9b31902905..f70e044fbc1b380768dbcd807f1833f6fb5cd48b 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -187,8 +187,10 @@ pub async fn create_terminal_entity( Default::default() }; - // Disables paging for `git` and hopefully other commands + // Disable pagers so agent/terminal commands don't hang behind interactive UIs env.insert("PAGER".into(), "".into()); + // Override user core.pager (e.g. delta) which Git prefers over PAGER + env.insert("GIT_PAGER".into(), "cat".into()); env.extend(env_vars); // Use remote shell or default system shell, as appropriate From b633de66f79305541dae194b2b6859ae9fac5c16 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Mon, 15 Dec 2025 19:12:29 +0800 Subject: [PATCH 41/67] gpui: Improve `cx.on_action` method to support chaining (#44353) Release Notes: - N/A To let `cx.on_action` support chaining like the `on_action` method of Div. https://github.com/zed-industries/zed/blob/ebcb2b2e646f10006dc40167d16e82ae74caa3a2/crates/agent_ui/src/acp/thread_view.rs#L5867-L5872 --- crates/client/src/client.rs | 10 ++-- crates/editor/src/editor.rs | 4 +- crates/gpui/src/app.rs | 6 +- crates/keymap_editor/src/keymap_editor.rs | 4 +- crates/workspace/src/workspace.rs | 71 +++++++++++------------ crates/zed/src/zed.rs | 62 ++++++++++---------- 6 files changed, 79 insertions(+), 78 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 6d6d229b940433ceac4c80f11891319550d269a2..14311d6bbf52ecb6df8dcc4a2fbc9454836a4834 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -150,9 +150,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach_and_log_err(cx); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client.clone(); move |_: &SignOut, cx| { if let Some(client) = client.upgrade() { @@ -162,9 +161,8 @@ pub fn init(client: &Arc, cx: &mut App) { .detach(); } } - }); - - cx.on_action({ + }) + .on_action({ let client = client; move |_: &Reconnect, cx| { if let Some(client) = client.upgrade() { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 923b5dc1540d93bd849f5a50a8d51052f79f93a0..29be039cdd182d1d45b0f3189e676d293486089f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -351,8 +351,8 @@ pub fn init(cx: &mut App) { ) .detach(); } - }); - cx.on_action(move |_: &workspace::NewWindow, cx| { + }) + .on_action(move |_: &workspace::NewWindow, cx| { let app_state = workspace::AppState::global(cx); if let Some(app_state) = app_state.upgrade() { workspace::open_new( diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index f7c57ef015e73618b8cfd9d5da8dbb717905577b..aa1acae33b8fb55fc5e2f8fa8c0f5b8bb91758f3 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1777,7 +1777,10 @@ impl App { /// Register a global handler for actions invoked via the keyboard. These handlers are run at /// the end of the bubble phase for actions, and so will only be invoked if there are no other /// handlers or if they called `cx.propagate()`. - pub fn on_action(&mut self, listener: impl Fn(&A, &mut Self) + 'static) { + pub fn on_action( + &mut self, + listener: impl Fn(&A, &mut Self) + 'static, + ) -> &mut Self { self.global_action_listeners .entry(TypeId::of::()) .or_default() @@ -1787,6 +1790,7 @@ impl App { listener(action, cx) } })); + self } /// Event handlers propagate events by default. Call this method to stop dispatching to diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index 113d5026eb89587714172ff4c76698bcadb5fd6a..e81b1077c70d4eb3828715a6bcd28dfe564ab188 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -123,8 +123,8 @@ pub fn init(cx: &mut App) { }) } - cx.on_action(|_: &OpenKeymap, cx| common(None, cx)); - cx.on_action(|action: &ChangeKeybinding, cx| common(Some(action.action.clone()), cx)); + cx.on_action(|_: &OpenKeymap, cx| common(None, cx)) + .on_action(|action: &ChangeKeybinding, cx| common(Some(action.action.clone()), cx)); register_serializable_item::(cx); } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 56dfb2398997a19e98c339876987419bb925f324..0a50faf867c2647874c1c7bb6d7887da6fee1388 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -576,44 +576,43 @@ pub fn init(app_state: Arc, cx: &mut App) { toast_layer::init(cx); history_manager::init(cx); - cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)); - cx.on_action(|_: &Reload, cx| reload(cx)); - - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &Open, cx: &mut App| { - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories: true, - multiple: true, - prompt: None, - }, - cx, - ); + cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) + .on_action(|_: &Reload, cx| reload(cx)) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &Open, cx: &mut App| { + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories: true, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); - cx.on_action({ - let app_state = Arc::downgrade(&app_state); - move |_: &OpenFiles, cx: &mut App| { - let directories = cx.can_select_mixed_files_and_dirs(); - if let Some(app_state) = app_state.upgrade() { - prompt_and_open_paths( - app_state, - PathPromptOptions { - files: true, - directories, - multiple: true, - prompt: None, - }, - cx, - ); + }) + .on_action({ + let app_state = Arc::downgrade(&app_state); + move |_: &OpenFiles, cx: &mut App| { + let directories = cx.can_select_mixed_files_and_dirs(); + if let Some(app_state) = app_state.upgrade() { + prompt_and_open_paths( + app_state, + PathPromptOptions { + files: true, + directories, + multiple: true, + prompt: None, + }, + cx, + ); + } } - } - }); + }); } type BuildProjectItemFn = diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3bc05ef540769800ef96a76bcbcfd24b09680192..ed22d7ef510e367b71b2a1057513471a4e32306a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -161,15 +161,15 @@ pub fn init(cx: &mut App) { || flag.await { cx.update(|cx| { - cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")); - cx.on_action(|_: &TestCrash, _| { - unsafe extern "C" { - fn puts(s: *const i8); - } - unsafe { - puts(0xabad1d3a as *const i8); - } - }); + cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action")) + .on_action(|_: &TestCrash, _| { + unsafe extern "C" { + fn puts(s: *const i8); + } + unsafe { + puts(0xabad1d3a as *const i8); + } + }); }) .ok(); }; @@ -179,11 +179,11 @@ pub fn init(cx: &mut App) { with_active_or_new_workspace(cx, |workspace, window, cx| { open_log_file(workspace, window, cx); }); - }); - cx.on_action(|_: &workspace::RevealLogInFileManager, cx| { + }) + .on_action(|_: &workspace::RevealLogInFileManager, cx| { cx.reveal_path(paths::log_file().as_path()); - }); - cx.on_action(|_: &zed_actions::OpenLicenses, cx| { + }) + .on_action(|_: &zed_actions::OpenLicenses, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -194,13 +194,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenTelemetryLog, cx| { + }) + .on_action(|_: &zed_actions::OpenTelemetryLog, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_telemetry_log_file(workspace, window, cx); }); - }); - cx.on_action(|&zed_actions::OpenKeymapFile, cx| { + }) + .on_action(|&zed_actions::OpenKeymapFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::keymap_file(), @@ -209,8 +209,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenSettingsFile, cx| { + }) + .on_action(|_: &OpenSettingsFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::settings_file(), @@ -219,13 +219,13 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenAccountSettings, cx| { + }) + .on_action(|_: &OpenAccountSettings, cx| { with_active_or_new_workspace(cx, |_, _, cx| { cx.open_url(&zed_urls::account_url(cx)); }); - }); - cx.on_action(|_: &OpenTasks, cx| { + }) + .on_action(|_: &OpenTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::tasks_file(), @@ -234,8 +234,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDebugTasks, cx| { + }) + .on_action(|_: &OpenDebugTasks, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( paths::debug_scenarios_file(), @@ -244,8 +244,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &OpenDefaultSettings, cx| { + }) + .on_action(|_: &OpenDefaultSettings, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -256,8 +256,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { + }) + .on_action(|_: &zed_actions::OpenDefaultKeymap, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { open_bundled_file( workspace, @@ -268,8 +268,8 @@ pub fn init(cx: &mut App) { cx, ); }); - }); - cx.on_action(|_: &zed_actions::About, cx| { + }) + .on_action(|_: &zed_actions::About, cx| { with_active_or_new_workspace(cx, |workspace, window, cx| { about(workspace, window, cx); }); From 886832281da80f862dd7f35944c449530fe9dc40 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 15 Dec 2025 12:23:55 +0100 Subject: [PATCH 42/67] Fix formatting of default settings (#44867) Another day, another me wishing for [merge queue](https://github.com/user-attachments/assets/ee1c313b-7d26-4d4a-9cc0-f1faeaac8251) Release Notes: - N/A --- assets/settings/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index c4c66f47a6948bc755e588cc37504dc01e954e36..2bca46cc38334475c9ccb5ad7862afd9a2f7b9eb 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1933,7 +1933,7 @@ }, }, "Proto": { - "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."] + "language_servers": ["buf", "!protols", "!protobuf-language-server", "..."], }, "Python": { "code_actions_on_format": { From 8fb2bde2c9fb752bd1d21c3c6e66ee5891487600 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 15 Dec 2025 12:50:44 +0100 Subject: [PATCH 43/67] html: Bump to v0.3.0 (#44865) Release Notes: - N/A --- Cargo.lock | 2 +- extensions/html/Cargo.toml | 2 +- extensions/html/extension.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 29de11a496fb25b86fcbc87c1f394c65a8e364b2..70686d8a83ab5248d00dfba696663e6e04bd4740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20819,7 +20819,7 @@ dependencies = [ [[package]] name = "zed_html" -version = "0.2.3" +version = "0.3.0" dependencies = [ "zed_extension_api 0.7.0", ] diff --git a/extensions/html/Cargo.toml b/extensions/html/Cargo.toml index 22cdb401a7ebcf4bb6afab7702fb81f345b7aa14..2c89f86cb450b7ea8476bffdff003a94b137d213 100644 --- a/extensions/html/Cargo.toml +++ b/extensions/html/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_html" -version = "0.2.3" +version = "0.3.0" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/html/extension.toml b/extensions/html/extension.toml index 1ded7af6413d0f1990a178a19a4014caadf48240..68ab0e4b9d3f56fca17cbd518d5990edc2ec711a 100644 --- a/extensions/html/extension.toml +++ b/extensions/html/extension.toml @@ -1,7 +1,7 @@ id = "html" name = "HTML" description = "HTML support." -version = "0.2.3" +version = "0.3.0" schema_version = 1 authors = ["Isaac Clayton "] repository = "https://github.com/zed-industries/zed" From 3e8d55739cec84d22093af57790cfb884bd20aa0 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 15 Dec 2025 12:51:18 +0100 Subject: [PATCH 44/67] proto: Bump to v0.3.0 (#44866) Release Notes: - N/A --- Cargo.lock | 2 +- extensions/proto/Cargo.toml | 2 +- extensions/proto/extension.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70686d8a83ab5248d00dfba696663e6e04bd4740..1dfcabfb552e128dfa6b0b47ebb5f33bfa2aa4a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20826,7 +20826,7 @@ dependencies = [ [[package]] name = "zed_proto" -version = "0.2.3" +version = "0.3.0" dependencies = [ "zed_extension_api 0.7.0", ] diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index d4c966a686a1ef0bfa2fe658c45f3b391e54ccee..c3606f668aa01d7a8baa20d54d073a7004a6f8c0 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.2.3" +version = "0.3.0" edition.workspace = true publish.workspace = true license = "Apache-2.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index ff8c5758d0c1780031ef850a912d294dcef1a40e..13c4054eef083e131ab311b1ec6e5a63aff545d8 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,7 +1,7 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.2.3" +version = "0.3.0" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" From 59b01651e162a06a0b58bf11c58d8dd89cc022c0 Mon Sep 17 00:00:00 2001 From: Devzeth <47153906+devzeth@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:58:22 +0100 Subject: [PATCH 45/67] ui: Improve focused border color consistency across panels (#44754) The issue is that we aren't consistent in using the same `panel_focus_border` color across zed. Might completely fix my issue: #44750 For focused items in: - outline panel - git panel While these: - project panel - keymap editor tab Are actually using the panel_focused_border option. Not sure if this warrants a release note, feel free to adapt. Release Notes: - N/A --- crates/git_ui/src/git_panel.rs | 4 ++-- crates/outline_panel/src/outline_panel.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 81d2a547bf11d91df98935efa0c167d28644e073..20ba1d5b903582214a8b982551f279b07278872e 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4813,7 +4813,7 @@ impl GitPanel { .items_center() .border_1() .when(selected && self.focus_handle.is_focused(window), |el| { - el.border_color(cx.theme().colors().border_focused) + el.border_color(cx.theme().colors().panel_focused_border) }) .px(rems(0.75)) // ~12px .overflow_hidden() @@ -4977,7 +4977,7 @@ impl GitPanel { .items_center() .border_1() .when(selected && self.focus_handle.is_focused(window), |el| { - el.border_color(cx.theme().colors().border_focused) + el.border_color(cx.theme().colors().panel_focused_border) }) .px(rems(0.75)) .overflow_hidden() diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index a787ad5b032ffcabc38790668fd4e0901ac1bebc..943025b1d0a96692f34f2ebcefff83a0ad2ddaee 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -2610,7 +2610,7 @@ impl OutlinePanel { }) .when( is_active && self.focus_handle.contains_focused(window, cx), - |div| div.border_color(Color::Selected.color(cx)), + |div| div.border_color(cx.theme().colors().panel_focused_border), ) } From bd481dea48e62a08e5420ba4d17662d1ba5f2bb3 Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Mon, 15 Dec 2025 20:06:17 +0800 Subject: [PATCH 46/67] git_ui: Add dismiss button to status toast (#44813) Release Notes: - N/A --------- Signed-off-by: Xiaobo Liu Co-authored-by: Danilo Leal --- crates/git_ui/src/git_panel.rs | 1 + crates/notifications/src/status_toast.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 20ba1d5b903582214a8b982551f279b07278872e..527e5062ae45a48c286bffe957821f12705ec60c 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3598,6 +3598,7 @@ impl GitPanel { .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)) .action(text, move |_, cx| cx.open_url(&link)), } + .dismiss_button(true) }); workspace.toggle_status_toast(status_toast, cx) }); diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs index 7affa93f5a496bd0e436c74e5ff32f8aa871d026..40c5bdc8f85d0b9a46474760954247e8bba76ca9 100644 --- a/crates/notifications/src/status_toast.rs +++ b/crates/notifications/src/status_toast.rs @@ -137,7 +137,8 @@ impl Render for StatusToast { let handle = self.this_handle.clone(); this.child( IconButton::new("dismiss", IconName::Close) - .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Dismiss")) .on_click(move |_click_event, _window, cx| { From 5805f62f1812b3ece91ba8fa00203ccf7a238ad2 Mon Sep 17 00:00:00 2001 From: Devzeth <47153906+devzeth@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:14:43 +0100 Subject: [PATCH 47/67] git_ui: Show missing right border on selected items (#44747) For folders and files basically any selected item in the git panel we draw a border around it. The issue is that the right side of this border wasn't ever visible. In the project_panel.rs file I've saw that the decision was to make the right side border 2 pixels. And this panel doesn't have this issue, no matter which side of the dock is selected. So it was a very easy `look at how we did x do y`. Before: ![image](https://github.com/user-attachments/assets/8ce32728-8ad6-487c-80f5-1c46d9756f4a) After: ![image](https://github.com/user-attachments/assets/998899b4-af98-4cc2-9435-4df6c98c1a50) I don't think it warrants a release note. Release Notes: - N/A --------- Co-authored-by: Danilo Leal --- crates/git_ui/src/git_panel.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 527e5062ae45a48c286bffe957821f12705ec60c..cf588d6b0448c2a7c8e7feb50d34c6e405845116 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4811,8 +4811,8 @@ impl GitPanel { .id(id) .h(self.list_item_height()) .w_full() - .items_center() .border_1() + .border_r_2() .when(selected && self.focus_handle.is_focused(window), |el| { el.border_color(cx.theme().colors().panel_focused_border) }) @@ -4977,6 +4977,7 @@ impl GitPanel { .w_full() .items_center() .border_1() + .border_r_2() .when(selected && self.focus_handle.is_focused(window), |el| { el.border_color(cx.theme().colors().panel_focused_border) }) From c996934b57184a8f8a3b4ac39621de49e181914c Mon Sep 17 00:00:00 2001 From: Kasper Date: Mon, 15 Dec 2025 13:14:57 +0100 Subject: [PATCH 48/67] Helix: Fix visual/textual line up/down (#42676) Release Notes: - Make Helix keybinds use visual line movement for `j`, `Down`, `k` and `Up`, and textual line movement for `g j`, `g Down`, `g k` and `g Up`. --- assets/keymaps/vim.json | 222 +++++++++++++++++++++------------------- 1 file changed, 115 insertions(+), 107 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 34bbd44fc3be6a8bd6fa35944e073f5118d6cd33..bbae6e2f4d738ef60b3a1a5ba33a26a9ab68f497 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -181,8 +181,8 @@ "ctrl-w space": "editor::OpenExcerptsSplit", "ctrl-w g space": "editor::OpenExcerptsSplit", "ctrl-^": "pane::AlternateFile", - ".": "vim::Repeat" - } + ".": "vim::Repeat", + }, }, { "context": "vim_mode == normal || vim_mode == visual || vim_mode == operator", @@ -223,8 +223,8 @@ "] r": "vim::GoToNextReference", // tree-sitter related commands "[ x": "vim::SelectLargerSyntaxNode", - "] x": "vim::SelectSmallerSyntaxNode" - } + "] x": "vim::SelectSmallerSyntaxNode", + }, }, { "context": "vim_mode == normal", @@ -261,16 +261,16 @@ "[ d": "editor::GoToPreviousDiagnostic", "] c": "editor::GoToHunk", "[ c": "editor::GoToPreviousHunk", - "g c": "vim::PushToggleComments" - } + "g c": "vim::PushToggleComments", + }, }, { "context": "VimControl && VimCount", "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand", - "%": "vim::GoToPercentage" - } + "%": "vim::GoToPercentage", + }, }, { "context": "vim_mode == visual", @@ -322,8 +322,8 @@ "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", - "\"": "vim::PushRegister" - } + "\"": "vim::PushRegister", + }, }, { "context": "vim_mode == helix_select", @@ -343,8 +343,8 @@ "ctrl-pageup": "pane::ActivatePreviousItem", "ctrl-pagedown": "pane::ActivateNextItem", ".": "vim::Repeat", - "alt-.": "vim::RepeatFind" - } + "alt-.": "vim::RepeatFind", + }, }, { "context": "vim_mode == insert", @@ -374,8 +374,8 @@ "ctrl-r": "vim::PushRegister", "insert": "vim::ToggleReplace", "ctrl-o": "vim::TemporaryNormal", - "ctrl-s": "editor::ShowSignatureHelp" - } + "ctrl-s": "editor::ShowSignatureHelp", + }, }, { "context": "showing_completions", @@ -383,8 +383,8 @@ "ctrl-d": "vim::ScrollDown", "ctrl-u": "vim::ScrollUp", "ctrl-e": "vim::LineDown", - "ctrl-y": "vim::LineUp" - } + "ctrl-y": "vim::LineUp", + }, }, { "context": "(vim_mode == normal || vim_mode == helix_normal) && !menu", @@ -409,23 +409,31 @@ "shift-s": "vim::SubstituteLine", "\"": "vim::PushRegister", "ctrl-pagedown": "pane::ActivateNextItem", - "ctrl-pageup": "pane::ActivatePreviousItem" - } + "ctrl-pageup": "pane::ActivatePreviousItem", + }, }, { "context": "VimControl && vim_mode == helix_normal && !menu", "bindings": { + "j": ["vim::Down", { "display_lines": true }], + "down": ["vim::Down", { "display_lines": true }], + "k": ["vim::Up", { "display_lines": true }], + "up": ["vim::Up", { "display_lines": true }], + "g j": "vim::Down", + "g down": "vim::Down", + "g k": "vim::Up", + "g up": "vim::Up", "escape": "vim::SwitchToHelixNormalMode", "i": "vim::HelixInsert", "a": "vim::HelixAppend", - "ctrl-[": "editor::Cancel" - } + "ctrl-[": "editor::Cancel", + }, }, { "context": "vim_mode == helix_select && !menu", "bindings": { - "escape": "vim::SwitchToHelixNormalMode" - } + "escape": "vim::SwitchToHelixNormalMode", + }, }, { "context": "(vim_mode == helix_normal || vim_mode == helix_select) && !menu", @@ -526,22 +534,22 @@ "]": ["vim::PushHelixNext", { "around": true }], "[": ["vim::PushHelixPrevious", { "around": true }], "g q": "vim::PushRewrap", - "g w": "vim::PushRewrap" // not a helix default & clashes with helix `goto_word` - } + "g w": "vim::PushRewrap", // not a helix default & clashes with helix `goto_word` + }, }, { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ShowWordCompletions", - "ctrl-n": "editor::ShowWordCompletions" - } + "ctrl-n": "editor::ShowWordCompletions", + }, }, { "context": "(vim_mode == insert || vim_mode == normal) && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, { "context": "vim_mode == replace", @@ -557,8 +565,8 @@ "backspace": "vim::UndoReplace", "tab": "vim::Tab", "enter": "vim::Enter", - "insert": "vim::InsertBefore" - } + "insert": "vim::InsertBefore", + }, }, { "context": "vim_mode == waiting", @@ -570,14 +578,14 @@ "escape": "vim::ClearOperators", "ctrl-k": ["vim::PushDigraph", {}], "ctrl-v": ["vim::PushLiteral", {}], - "ctrl-q": ["vim::PushLiteral", {}] - } + "ctrl-q": ["vim::PushLiteral", {}], + }, }, { "context": "Editor && vim_mode == waiting && (vim_operator == ys || vim_operator == cs)", "bindings": { - "escape": "vim::SwitchToNormalMode" - } + "escape": "vim::SwitchToNormalMode", + }, }, { "context": "vim_mode == operator", @@ -585,8 +593,8 @@ "ctrl-c": "vim::ClearOperators", "ctrl-[": "vim::ClearOperators", "escape": "vim::ClearOperators", - "g c": "vim::Comment" - } + "g c": "vim::Comment", + }, }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs || vim_operator == helix_next || vim_operator == helix_previous", @@ -623,14 +631,14 @@ "shift-i": ["vim::IndentObj", { "include_below": true }], "f": "vim::Method", "c": "vim::Class", - "e": "vim::EntireFile" - } + "e": "vim::EntireFile", + }, }, { "context": "vim_operator == helix_m", "bindings": { - "m": "vim::Matching" - } + "m": "vim::Matching", + }, }, { "context": "vim_operator == helix_next", @@ -647,8 +655,8 @@ "x": "editor::SelectSmallerSyntaxNode", "d": "editor::GoToDiagnostic", "c": "editor::GoToHunk", - "space": "vim::InsertEmptyLineBelow" - } + "space": "vim::InsertEmptyLineBelow", + }, }, { "context": "vim_operator == helix_previous", @@ -665,8 +673,8 @@ "x": "editor::SelectLargerSyntaxNode", "d": "editor::GoToPreviousDiagnostic", "c": "editor::GoToPreviousHunk", - "space": "vim::InsertEmptyLineAbove" - } + "space": "vim::InsertEmptyLineAbove", + }, }, { "context": "vim_operator == c", @@ -674,8 +682,8 @@ "c": "vim::CurrentLine", "x": "vim::Exchange", "d": "editor::Rename", // zed specific - "s": ["vim::PushChangeSurrounds", {}] - } + "s": ["vim::PushChangeSurrounds", {}], + }, }, { "context": "vim_operator == d", @@ -687,36 +695,36 @@ "shift-o": "git::ToggleStaged", "p": "git::Restore", // "d p" "u": "git::StageAndNext", // "d u" - "shift-u": "git::UnstageAndNext" // "d shift-u" - } + "shift-u": "git::UnstageAndNext", // "d shift-u" + }, }, { "context": "vim_operator == gu", "bindings": { "g u": "vim::CurrentLine", - "u": "vim::CurrentLine" - } + "u": "vim::CurrentLine", + }, }, { "context": "vim_operator == gU", "bindings": { "g shift-u": "vim::CurrentLine", - "shift-u": "vim::CurrentLine" - } + "shift-u": "vim::CurrentLine", + }, }, { "context": "vim_operator == g~", "bindings": { "g ~": "vim::CurrentLine", - "~": "vim::CurrentLine" - } + "~": "vim::CurrentLine", + }, }, { "context": "vim_operator == g?", "bindings": { "g ?": "vim::CurrentLine", - "?": "vim::CurrentLine" - } + "?": "vim::CurrentLine", + }, }, { "context": "vim_operator == gq", @@ -724,66 +732,66 @@ "g q": "vim::CurrentLine", "q": "vim::CurrentLine", "g w": "vim::CurrentLine", - "w": "vim::CurrentLine" - } + "w": "vim::CurrentLine", + }, }, { "context": "vim_operator == y", "bindings": { "y": "vim::CurrentLine", "v": "vim::PushForcedMotion", - "s": ["vim::PushAddSurrounds", {}] - } + "s": ["vim::PushAddSurrounds", {}], + }, }, { "context": "vim_operator == ys", "bindings": { - "s": "vim::CurrentLine" - } + "s": "vim::CurrentLine", + }, }, { "context": "vim_operator == >", "bindings": { - ">": "vim::CurrentLine" - } + ">": "vim::CurrentLine", + }, }, { "context": "vim_operator == <", "bindings": { - "<": "vim::CurrentLine" - } + "<": "vim::CurrentLine", + }, }, { "context": "vim_operator == eq", "bindings": { - "=": "vim::CurrentLine" - } + "=": "vim::CurrentLine", + }, }, { "context": "vim_operator == sh", "bindings": { - "!": "vim::CurrentLine" - } + "!": "vim::CurrentLine", + }, }, { "context": "vim_operator == gc", "bindings": { - "c": "vim::CurrentLine" - } + "c": "vim::CurrentLine", + }, }, { "context": "vim_operator == gR", "bindings": { "r": "vim::CurrentLine", - "shift-r": "vim::CurrentLine" - } + "shift-r": "vim::CurrentLine", + }, }, { "context": "vim_operator == cx", "bindings": { "x": "vim::CurrentLine", - "c": "vim::ClearExchange" - } + "c": "vim::ClearExchange", + }, }, { "context": "vim_mode == literal", @@ -825,15 +833,15 @@ "tab": ["vim::Literal", ["tab", "\u0009"]], // zed extensions: "backspace": ["vim::Literal", ["backspace", "\u0008"]], - "delete": ["vim::Literal", ["delete", "\u007F"]] - } + "delete": ["vim::Literal", ["delete", "\u007F"]], + }, }, { "context": "BufferSearchBar && !in_replace", "bindings": { "enter": "vim::SearchSubmit", - "escape": "buffer_search::Dismiss" - } + "escape": "buffer_search::Dismiss", + }, }, { "context": "VimControl && !menu || !Editor && !Terminal", @@ -894,8 +902,8 @@ "ctrl-w ctrl-n": "workspace::NewFileSplitHorizontal", "ctrl-w n": "workspace::NewFileSplitHorizontal", "g t": "vim::GoToTab", - "g shift-t": "vim::GoToPreviousTab" - } + "g shift-t": "vim::GoToPreviousTab", + }, }, { "context": "!Editor && !Terminal", @@ -905,8 +913,8 @@ "] b": "pane::ActivateNextItem", "[ b": "pane::ActivatePreviousItem", "] shift-b": "pane::ActivateLastItem", - "[ shift-b": ["pane::ActivateItem", 0] - } + "[ shift-b": ["pane::ActivateItem", 0], + }, }, { // netrw compatibility @@ -956,8 +964,8 @@ "6": ["vim::Number", 6], "7": ["vim::Number", 7], "8": ["vim::Number", 8], - "9": ["vim::Number", 9] - } + "9": ["vim::Number", 9], + }, }, { "context": "OutlinePanel && not_editing", @@ -965,8 +973,8 @@ "j": "menu::SelectNext", "k": "menu::SelectPrevious", "shift-g": "menu::SelectLast", - "g g": "menu::SelectFirst" - } + "g g": "menu::SelectFirst", + }, }, { "context": "GitPanel && ChangesList", @@ -981,8 +989,8 @@ "x": "git::ToggleStaged", "shift-x": "git::StageAll", "g x": "git::StageRange", - "shift-u": "git::UnstageAll" - } + "shift-u": "git::UnstageAll", + }, }, { "context": "Editor && mode == auto_height && VimControl", @@ -993,8 +1001,8 @@ "#": null, "*": null, "n": null, - "shift-n": null - } + "shift-n": null, + }, }, { "context": "Picker > Editor", @@ -1003,29 +1011,29 @@ "ctrl-u": "editor::DeleteToBeginningOfLine", "ctrl-w": "editor::DeleteToPreviousWordStart", "ctrl-p": "menu::SelectPrevious", - "ctrl-n": "menu::SelectNext" - } + "ctrl-n": "menu::SelectNext", + }, }, { "context": "GitCommit > Editor && VimControl && vim_mode == normal", "bindings": { "ctrl-c": "menu::Cancel", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Editor && edit_prediction", "bindings": { // This is identical to the binding in the base keymap, but the vim bindings above to // "vim::Tab" shadow it, so it needs to be bound again. - "tab": "editor::AcceptEditPrediction" - } + "tab": "editor::AcceptEditPrediction", + }, }, { "context": "MessageEditor > Editor && VimControl", "bindings": { - "enter": "agent::Chat" - } + "enter": "agent::Chat", + }, }, { "context": "os != macos && Editor && edit_prediction_conflict", @@ -1033,8 +1041,8 @@ // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This // is because alt-tab may not be available, as it is often used for window switching on Linux // and Windows. - "alt-l": "editor::AcceptEditPrediction" - } + "alt-l": "editor::AcceptEditPrediction", + }, }, { "context": "SettingsWindow > NavigationMenu && !search", @@ -1044,8 +1052,8 @@ "k": "settings_editor::FocusPreviousNavEntry", "j": "settings_editor::FocusNextNavEntry", "g g": "settings_editor::FocusFirstNavEntry", - "shift-g": "settings_editor::FocusLastNavEntry" - } + "shift-g": "settings_editor::FocusLastNavEntry", + }, }, { "context": "MarkdownPreview", @@ -1053,7 +1061,7 @@ "ctrl-u": "markdown::ScrollPageUp", "ctrl-d": "markdown::ScrollPageDown", "ctrl-y": "markdown::ScrollUp", - "ctrl-e": "markdown::ScrollDown" - } - } + "ctrl-e": "markdown::ScrollDown", + }, + }, ] From a61c14cf3b302bd5cdcd4906a0ee884ad5a63623 Mon Sep 17 00:00:00 2001 From: Jake Go Date: Mon, 15 Dec 2025 07:25:17 -0500 Subject: [PATCH 49/67] Add setting to hide user menu in the title bar (#44466) Closes #44417 Release Notes: - Added a setting `show_user_menu` (defaulting to true) which shows or hides the user menu (the one with the user avatar) in title bar. --------- Co-authored-by: Danilo Leal --- assets/settings/default.json | 2 ++ crates/settings/src/settings_content.rs | 4 +++ crates/settings_ui/src/page_data.rs | 42 +++++++++++++++------- crates/title_bar/src/title_bar.rs | 8 +++-- crates/title_bar/src/title_bar_settings.rs | 2 ++ docs/src/configuring-zed.md | 2 ++ docs/src/visual-customization.md | 1 + 7 files changed, 46 insertions(+), 15 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 2bca46cc38334475c9ccb5ad7862afd9a2f7b9eb..146915dd1a242e2a8b70ba1010bb5fbe09dbbbbc 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -436,6 +436,8 @@ "show_onboarding_banner": true, // Whether to show user picture in the titlebar. "show_user_picture": true, + // Whether to show the user menu in the titlebar. + "show_user_menu": true, // Whether to show the sign in button in the titlebar. "show_sign_in": true, // Whether to show the menus in the titlebar. diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 743e22b04d9cf87a0d09a73aef879c781a50cca2..ba349b865bf2ac4dfd9d19b22c5693307ebae20a 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -286,6 +286,10 @@ pub struct TitleBarSettingsContent { /// /// Default: true pub show_sign_in: Option, + /// Whether to show the user menu button in the title bar. + /// + /// Default: true + pub show_user_menu: Option, /// Whether to show the menus in the title bar. /// /// Default: false diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index b03ce327877f7251d41c39ee1eed5d424c18ce84..79fc1cc11158399265a184a289fd8d7a71ce8d69 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -2913,40 +2913,58 @@ pub(crate) fn settings_data(cx: &App) -> Vec { files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "Show User Picture", - description: "Show user picture in the titlebar.", + title: "Show Sign In", + description: "Show the sign in button in the titlebar.", field: Box::new(SettingField { - json_path: Some("title_bar.show_user_picture"), + json_path: Some("title_bar.show_sign_in"), pick: |settings_content| { + settings_content.title_bar.as_ref()?.show_sign_in.as_ref() + }, + write: |settings_content, value| { settings_content .title_bar - .as_ref()? - .show_user_picture - .as_ref() + .get_or_insert_default() + .show_sign_in = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Show User Menu", + description: "Show the user menu button in the titlebar.", + field: Box::new(SettingField { + json_path: Some("title_bar.show_user_menu"), + pick: |settings_content| { + settings_content.title_bar.as_ref()?.show_user_menu.as_ref() }, write: |settings_content, value| { settings_content .title_bar .get_or_insert_default() - .show_user_picture = value; + .show_user_menu = value; }, }), metadata: None, files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "Show Sign In", - description: "Show the sign in button in the titlebar.", + title: "Show User Picture", + description: "Show user picture in the titlebar.", field: Box::new(SettingField { - json_path: Some("title_bar.show_sign_in"), + json_path: Some("title_bar.show_user_picture"), pick: |settings_content| { - settings_content.title_bar.as_ref()?.show_sign_in.as_ref() + settings_content + .title_bar + .as_ref()? + .show_user_picture + .as_ref() }, write: |settings_content, value| { settings_content .title_bar .get_or_insert_default() - .show_sign_in = value; + .show_user_picture = value; }, }), metadata: None, diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index bd606e4a021eaad30b95322d785e23d694734c06..5bd47d02691c9a5c7fec968b5ea6e97265b956b2 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -202,9 +202,11 @@ impl Render for TitleBar { .children(self.render_connection_status(status, cx)) .when( user.is_none() && TitleBarSettings::get_global(cx).show_sign_in, - |el| el.child(self.render_sign_in_button(cx)), + |this| this.child(self.render_sign_in_button(cx)), ) - .child(self.render_app_menu_button(cx)) + .when(TitleBarSettings::get_global(cx).show_user_menu, |this| { + this.child(self.render_user_menu_button(cx)) + }) .into_any_element(), ); @@ -685,7 +687,7 @@ impl TitleBar { }) } - pub fn render_app_menu_button(&mut self, cx: &mut Context) -> impl Element { + pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { let user_store = self.user_store.read(cx); let user = user_store.current_user(); diff --git a/crates/title_bar/src/title_bar_settings.rs b/crates/title_bar/src/title_bar_settings.rs index 29fae4d31eb33ac70a22c21010f09350847439c2..155b7b7bc797567927a70b12c677372cb92c9453 100644 --- a/crates/title_bar/src/title_bar_settings.rs +++ b/crates/title_bar/src/title_bar_settings.rs @@ -8,6 +8,7 @@ pub struct TitleBarSettings { pub show_branch_name: bool, pub show_project_items: bool, pub show_sign_in: bool, + pub show_user_menu: bool, pub show_menus: bool, } @@ -21,6 +22,7 @@ impl Settings for TitleBarSettings { show_branch_name: content.show_branch_name.unwrap(), show_project_items: content.show_project_items.unwrap(), show_sign_in: content.show_sign_in.unwrap(), + show_user_menu: content.show_user_menu.unwrap(), show_menus: content.show_menus.unwrap(), } } diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 477885a4537580aaf562aa596c1a06cae1c65bc8..76c0b528fa106ae087297d3c9191ee70620116ba 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -4309,6 +4309,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a "show_project_items": true, "show_onboarding_banner": true, "show_user_picture": true, + "show_user_menu": true, "show_sign_in": true, "show_menus": false } @@ -4321,6 +4322,7 @@ Run the {#action theme_selector::Toggle} action in the command palette to see a - `show_project_items`: Whether to show the project host and name in the titlebar - `show_onboarding_banner`: Whether to show onboarding banners in the titlebar - `show_user_picture`: Whether to show user picture in the titlebar +- `show_user_menu`: Whether to show the user menu button in the titlebar (the one that displays your avatar by default and contains options like Settings, Keymap, Themes, etc.) - `show_sign_in`: Whether to show the sign in button in the titlebar - `show_menus`: Whether to show the menus in the titlebar diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index e5185719279dde488c40573d94fd842c06860f4d..234776b1d3223a4b8634b42df1973a27c736616c 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -118,6 +118,7 @@ To disable this behavior use: "show_project_items": true, // Show/hide project host and name "show_onboarding_banner": true, // Show/hide onboarding banners "show_user_picture": true, // Show/hide user avatar + "show_user_menu": true, // Show/hide app user button "show_sign_in": true, // Show/hide sign-in button "show_menus": false // Show/hide menus }, From 5fe7fd97bd00e8273e1e03bdc37952c82fc0927c Mon Sep 17 00:00:00 2001 From: Lennart Date: Mon, 15 Dec 2025 13:56:07 +0100 Subject: [PATCH 50/67] editor: Fix block cursor offset when selecting text (#42837) Vim visual mode and Helix selection mode both require the cursor to be on the last character of the selection. Until now, this was implemented by offsetting the cursor one character to the left whenever a block cursor is used. (Since the visual modes use a block cursor.) However, this oversees the problem that **some users might want to use the block cursor without being in visual mode**. Meaning that the cursor is offset by one character to the left even though Vim/Helix mode isn't even activated. Since the Vim mode implementation is separate from the `editor` crate the solution is not as straightforward as just checking the current vim mode. Therefore this PR introduces a new `Editor` struct field called `cursor_offset_on_selection`. This field replaces the previous check condition and is set to `true` whenever the Vim mode is changed to a visual mode, and `false` otherwise. Closes #36677 and #20121 Release Notes: - Fixes block and hollow cursor being offset when selecting text --------- Co-authored-by: dino --- crates/editor/src/editor.rs | 8 ++++++++ crates/editor/src/element.rs | 17 +++++++++++------ crates/vim/src/vim.rs | 1 + 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 29be039cdd182d1d45b0f3189e676d293486089f..5149c01ebeb5e52c4eb093de0c1d10690b2a7035 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1107,6 +1107,9 @@ pub struct Editor { pending_rename: Option, searchable: bool, cursor_shape: CursorShape, + /// Whether the cursor is offset one character to the left when something is + /// selected (needed for vim visual mode) + cursor_offset_on_selection: bool, current_line_highlight: Option, pub collapse_matches: bool, autoindent_mode: Option, @@ -2281,6 +2284,7 @@ impl Editor { cursor_shape: EditorSettings::get_global(cx) .cursor_shape .unwrap_or_default(), + cursor_offset_on_selection: false, current_line_highlight: None, autoindent_mode: Some(AutoindentMode::EachLine), collapse_matches: false, @@ -3095,6 +3099,10 @@ impl Editor { self.cursor_shape } + pub fn set_cursor_offset_on_selection(&mut self, set_cursor_offset_on_selection: bool) { + self.cursor_offset_on_selection = set_cursor_offset_on_selection; + } + pub fn set_current_line_highlight( &mut self, current_line_highlight: Option, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5a5b32e1755f5a026800f3af3c1cedaf6b11996d..ea619140dca36405f35521e316361942c72f644c 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -132,6 +132,7 @@ impl SelectionLayout { fn new( selection: Selection, line_mode: bool, + cursor_offset: bool, cursor_shape: CursorShape, map: &DisplaySnapshot, is_newest: bool, @@ -152,12 +153,9 @@ impl SelectionLayout { } // any vim visual mode (including line mode) - if (cursor_shape == CursorShape::Block || cursor_shape == CursorShape::Hollow) - && !range.is_empty() - && !selection.reversed - { + if cursor_offset && !range.is_empty() && !selection.reversed { if head.column() > 0 { - head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left) + head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left); } else if head.row().0 > 0 && head != map.max_point() { head = map.clip_point( DisplayPoint::new( @@ -1441,6 +1439,7 @@ impl EditorElement { let layout = SelectionLayout::new( selection, editor.selections.line_mode(), + editor.cursor_offset_on_selection, editor.cursor_shape, &snapshot.display_snapshot, is_newest, @@ -1487,6 +1486,7 @@ impl EditorElement { let drag_cursor_layout = SelectionLayout::new( drop_cursor.clone(), false, + editor.cursor_offset_on_selection, CursorShape::Bar, &snapshot.display_snapshot, false, @@ -1550,6 +1550,7 @@ impl EditorElement { .push(SelectionLayout::new( selection.selection, selection.line_mode, + editor.cursor_offset_on_selection, selection.cursor_shape, &snapshot.display_snapshot, false, @@ -1560,6 +1561,8 @@ impl EditorElement { selections.extend(remote_selections.into_values()); } else if !editor.is_focused(window) && editor.show_cursor_when_unfocused { + let cursor_offset_on_selection = editor.cursor_offset_on_selection; + let layouts = snapshot .buffer_snapshot() .selections_in_range(&(start_anchor..end_anchor), true) @@ -1567,6 +1570,7 @@ impl EditorElement { SelectionLayout::new( selection, line_mode, + cursor_offset_on_selection, cursor_shape, &snapshot.display_snapshot, false, @@ -3290,6 +3294,7 @@ impl EditorElement { SelectionLayout::new( newest, editor.selections.line_mode(), + editor.cursor_offset_on_selection, editor.cursor_shape, &snapshot.display_snapshot, true, @@ -11858,7 +11863,7 @@ mod tests { window .update(cx, |editor, window, cx| { - editor.cursor_shape = CursorShape::Block; + editor.cursor_offset_on_selection = true; editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.select_ranges([ Point::new(0, 0)..Point::new(1, 0), diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 9a9a1a001c32fcf8b22892ce5300d8d2aec3dd37..26fec968fb261fbb80a9f84211357623147ca0f4 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1943,6 +1943,7 @@ impl Vim { editor.set_collapse_matches(collapse_matches); editor.set_input_enabled(vim.editor_input_enabled()); editor.set_autoindent(vim.should_autoindent()); + editor.set_cursor_offset_on_selection(vim.mode.is_visual()); editor .selections .set_line_mode(matches!(vim.mode, Mode::VisualLine)); From 63bfb6131f1c36031d891df93acd2521ac38eb02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Mon, 15 Dec 2025 14:18:06 +0100 Subject: [PATCH 51/67] scheduler: Fix background threads ending early (#44878) Release Notes: - N/A Co-authored-by: kate --- crates/gpui/src/queue.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 3a4ef912ffd5fb85b80384454f7afd84cecb1648..9e9da710977ee80df1853791918eebe5e7f01096 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -58,8 +58,7 @@ impl PriorityQueueState { return Err(crate::queue::RecvError); } - // parking_lot doesn't do spurious wakeups so an if is fine - if queues.is_empty() { + while queues.is_empty() { self.condvar.wait(&mut queues); } @@ -265,7 +264,7 @@ impl Iterator for Iter { type Item = T; fn next(&mut self) -> Option { - self.0.pop_inner(true).ok().flatten() + self.0.pop().ok() } } impl FusedIterator for Iter {} @@ -283,7 +282,7 @@ impl Iterator for TryIter { return None; } - let res = self.receiver.pop_inner(false); + let res = self.receiver.try_pop(); self.ended = res.is_err(); res.transpose() From a3ac59573799d10ec55f88426438a45af08a56fa Mon Sep 17 00:00:00 2001 From: Serophots <47299955+Serophots@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:30:13 +0000 Subject: [PATCH 52/67] gpui: Make refining a `Style` properly refine the `TextStyle` (#42852) ## Motivating problem The gpui API currently has this counter intuitive behaviour ```rust div() .id("hallo") .cursor_pointer() .text_color(white()) .font_weight(FontWeight::SEMIBOLD) .text_size(px(20.0)) .child("hallo") .active(|this| this.text_color(red())) ``` By changing the text_color when the div is active, the current behaviour is to overwrite all of the text styling rather than do a proper refinement of the existing text styling leading to this odd result: The button being active inadvertently changes the font size. https://github.com/user-attachments/assets/1ff51169-0d76-4ee5-bbb0-004eb9ffdf2c ## Solution Previously refining a Style would not recursively refine the TextStyle inside of it, leading to this behaviour: ```rust let mut style = Style::default(); style.refine(&StyleRefinement::default().text_size(px(20.0))); style.refine(&StyleRefinement::default().font_weight(FontWeight::SEMIBOLD)); assert!(style.text_style().unwrap().font_size.is_none()); //assertion passes ``` (As best as I can tell) Style deliberately has `pub text: TextStyleRefinement` storing the `TextStyleRefinement` rather than the absolute `TextStyle` so that these refinements can be elsewhere used in cascading text styles down to element's children. But a consequence of that is that the refine macro was not properly recursively refining the `text` field as it ought to. I've modified the refine macro so that the `#[refineable]` attribute works with `TextStyleRefinement` as well as the usual `TextStyle`. (Perhaps a little bit haphazardly by simply checking whether the name ends in Refinement - there may be a better solution there). This PR resolves the motivating problem and triggers the assertion in the above code as you'd expect. I've compiled zed under these changes and all seems to be in order there. Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/acp_tools/src/acp_tools.rs | 4 +- crates/agent_ui/src/acp/thread_view.rs | 4 +- .../src/rate_prediction_modal.rs | 4 +- crates/gpui/src/style.rs | 18 +++ crates/gpui/src/styled.rs | 112 ++++++------------ crates/markdown/examples/markdown_as_child.rs | 4 +- crates/markdown/src/markdown.rs | 14 +-- .../src/derive_refineable.rs | 7 +- crates/refineable/src/refineable.rs | 2 +- crates/ui/src/components/keybinding_hint.rs | 4 +- crates/ui/src/components/label/label_like.rs | 4 +- 11 files changed, 74 insertions(+), 103 deletions(-) diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index 0905effce38d1bfd4fa18e1d00169d6c7ef6c2d7..b0d30367da0634dc82f8db96fc099e268aa4790e 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -371,13 +371,13 @@ impl AcpTools { syntax: cx.theme().syntax().clone(), code_block_overflow_x_scroll: true, code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some((base_size * 0.8).into()), ..Default::default() - }), + }, ..Default::default() }, ..Default::default() diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 63ea9eb279d26ff610c12f9785ef882be61f5e26..6cd2ec2fa3442bbf4961dffb0c4538ac9615d982 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -6053,13 +6053,13 @@ fn default_markdown_style( }, border_color: Some(colors.border_variant), background: Some(colors.editor_background.into()), - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some(theme_settings.buffer_font.family.clone()), font_fallbacks: theme_settings.buffer_font.fallbacks.clone(), font_features: Some(theme_settings.buffer_font.features.clone()), font_size: Some(buffer_font_size.into()), ..Default::default() - }), + }, ..Default::default() }, inline_code: TextStyleRefinement { diff --git a/crates/edit_prediction_ui/src/rate_prediction_modal.rs b/crates/edit_prediction_ui/src/rate_prediction_modal.rs index 54933fbf904f8fc7146dcce9a6bd3340884cc8bf..22e82bc445b394cc122e1cb1aa3604b45c25d1d1 100644 --- a/crates/edit_prediction_ui/src/rate_prediction_modal.rs +++ b/crates/edit_prediction_ui/src/rate_prediction_modal.rs @@ -510,13 +510,13 @@ impl RatePredictionsModal { base_text_style: window.text_style(), syntax: cx.theme().syntax().clone(), code_block: StyleRefinement { - text: Some(TextStyleRefinement { + text: TextStyleRefinement { font_family: Some( theme_settings.buffer_font.family.clone(), ), font_size: Some(buffer_font_size.into()), ..Default::default() - }), + }, padding: EdgesRefinement { top: Some(DefiniteLength::Absolute( AbsoluteLength::Pixels(px(8.)), diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 42f8f25e47620fe673720055037b7f91f44165a2..446c3ad2a325681a39689577a261ed1ffdde6d5b 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -252,6 +252,7 @@ pub struct Style { pub box_shadow: Vec, /// The text style of this element + #[refineable] pub text: TextStyleRefinement, /// The mouse cursor style shown when the mouse pointer is over an element. @@ -1469,4 +1470,21 @@ mod tests { ] ); } + + #[perf] + fn test_text_style_refinement() { + let mut style = Style::default(); + style.refine(&StyleRefinement::default().text_size(px(20.0))); + style.refine(&StyleRefinement::default().font_weight(FontWeight::SEMIBOLD)); + + assert_eq!( + Some(AbsoluteLength::from(px(20.0))), + style.text_style().unwrap().font_size + ); + + assert_eq!( + Some(FontWeight::SEMIBOLD), + style.text_style().unwrap().font_weight + ); + } } diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 752038c1ed63a1d0d5960bf0a74a1c2fdbc43392..e01649be481e27f89643db2ffb3a9ccd294b9b73 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -64,43 +64,33 @@ pub trait Styled: Sized { /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Normal); + self.text_style().white_space = Some(WhiteSpace::Normal); self } /// Sets the whitespace of the element to `nowrap`. /// [Docs](https://tailwindcss.com/docs/whitespace#nowrap) fn whitespace_nowrap(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .white_space = Some(WhiteSpace::Nowrap); + self.text_style().white_space = Some(WhiteSpace::Nowrap); self } /// Sets the truncate overflowing text with an ellipsis (…) if needed. /// [Docs](https://tailwindcss.com/docs/text-overflow#ellipsis) fn text_ellipsis(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); + self.text_style().text_overflow = Some(TextOverflow::Truncate(ELLIPSIS)); self } /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_overflow = Some(overflow); + self.text_style().text_overflow = Some(overflow); self } /// Set the text alignment of the element. fn text_align(mut self, align: TextAlign) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .text_align = Some(align); + self.text_style().text_align = Some(align); self } @@ -128,7 +118,7 @@ pub trait Styled: Sized { /// Sets number of lines to show before truncating the text. /// [Docs](https://tailwindcss.com/docs/line-clamp) fn line_clamp(mut self, lines: usize) -> Self { - let mut text_style = self.text_style().get_or_insert_with(Default::default); + let mut text_style = self.text_style(); text_style.line_clamp = Some(lines); self.overflow_hidden() } @@ -396,7 +386,7 @@ pub trait Styled: Sized { } /// Returns a mutable reference to the text style that has been configured on this element. - fn text_style(&mut self) -> &mut Option { + fn text_style(&mut self) -> &mut TextStyleRefinement { let style: &mut StyleRefinement = self.style(); &mut style.text } @@ -405,7 +395,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_color(mut self, color: impl Into) -> Self { - self.text_style().get_or_insert_with(Default::default).color = Some(color.into()); + self.text_style().color = Some(color.into()); self } @@ -413,9 +403,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn font_weight(mut self, weight: FontWeight) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_weight = Some(weight); + self.text_style().font_weight = Some(weight); self } @@ -423,9 +411,7 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_bg(mut self, bg: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .background_color = Some(bg.into()); + self.text_style().background_color = Some(bg.into()); self } @@ -433,97 +419,77 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_size(mut self, size: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(size.into()); + self.text_style().font_size = Some(size.into()); self } /// Sets the text size to 'extra small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xs(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.75).into()); + self.text_style().font_size = Some(rems(0.75).into()); self } /// Sets the text size to 'small'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_sm(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(0.875).into()); + self.text_style().font_size = Some(rems(0.875).into()); self } /// Sets the text size to 'base'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_base(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.0).into()); + self.text_style().font_size = Some(rems(1.0).into()); self } /// Sets the text size to 'large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_lg(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.125).into()); + self.text_style().font_size = Some(rems(1.125).into()); self } /// Sets the text size to 'extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.25).into()); + self.text_style().font_size = Some(rems(1.25).into()); self } /// Sets the text size to 'extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_2xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.5).into()); + self.text_style().font_size = Some(rems(1.5).into()); self } /// Sets the text size to 'extra extra extra large'. /// [Docs](https://tailwindcss.com/docs/font-size#setting-the-font-size) fn text_3xl(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_size = Some(rems(1.875).into()); + self.text_style().font_size = Some(rems(1.875).into()); self } /// Sets the font style of the element to italic. /// [Docs](https://tailwindcss.com/docs/font-style#italicizing-text) fn italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + self.text_style().font_style = Some(FontStyle::Italic); self } /// Sets the font style of the element to normal (not italic). /// [Docs](https://tailwindcss.com/docs/font-style#displaying-text-normally) fn not_italic(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Normal); + self.text_style().font_style = Some(FontStyle::Normal); self } /// Sets the text decoration to underline. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#underling-text) fn underline(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.underline = Some(UnderlineStyle { thickness: px(1.), ..Default::default() @@ -534,7 +500,7 @@ pub trait Styled: Sized { /// Sets the decoration of the text to have a line through it. /// [Docs](https://tailwindcss.com/docs/text-decoration-line#adding-a-line-through-text) fn line_through(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); style.strikethrough = Some(StrikethroughStyle { thickness: px(1.), ..Default::default() @@ -546,15 +512,13 @@ pub trait Styled: Sized { /// /// This value cascades to its child elements. fn text_decoration_none(mut self) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .underline = None; + self.text_style().underline = None; self } /// Sets the color for the underline on this element fn text_decoration_color(mut self, color: impl Into) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.color = Some(color.into()); self @@ -563,7 +527,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a solid line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_solid(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = false; self @@ -572,7 +536,7 @@ pub trait Styled: Sized { /// Sets the text decoration style to a wavy line. /// [Docs](https://tailwindcss.com/docs/text-decoration-style) fn text_decoration_wavy(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.wavy = true; self @@ -581,7 +545,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 0px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_0(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(0.); self @@ -590,7 +554,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 1px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_1(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(1.); self @@ -599,7 +563,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 2px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_2(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(2.); self @@ -608,7 +572,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 4px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_4(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(4.); self @@ -617,7 +581,7 @@ pub trait Styled: Sized { /// Sets the text decoration to be 8px thick. /// [Docs](https://tailwindcss.com/docs/text-decoration-thickness) fn text_decoration_8(mut self) -> Self { - let style = self.text_style().get_or_insert_with(Default::default); + let style = self.text_style(); let underline = style.underline.get_or_insert_with(Default::default); underline.thickness = px(8.); self @@ -625,17 +589,13 @@ pub trait Styled: Sized { /// Sets the font family of this element and its children. fn font_family(mut self, family_name: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_family = Some(family_name.into()); + self.text_style().font_family = Some(family_name.into()); self } /// Sets the font features of this element and its children. fn font_features(mut self, features: FontFeatures) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .font_features = Some(features); + self.text_style().font_features = Some(features); self } @@ -649,7 +609,7 @@ pub trait Styled: Sized { style, } = font; - let text_style = self.text_style().get_or_insert_with(Default::default); + let text_style = self.text_style(); text_style.font_family = Some(family); text_style.font_features = Some(features); text_style.font_weight = Some(weight); @@ -661,9 +621,7 @@ pub trait Styled: Sized { /// Sets the line height of this element and its children. fn line_height(mut self, line_height: impl Into) -> Self { - self.text_style() - .get_or_insert_with(Default::default) - .line_height = Some(line_height.into()); + self.text_style().line_height = Some(line_height.into()); self } diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index 6affa243ae5cc5f4cac1dc7fea0af9b9cc183aa6..775e2a141a849636512264dda2628e28254c8e2b 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -54,11 +54,11 @@ impl Render for HelloWorld { ..Default::default() }, code_block: StyleRefinement { - text: Some(gpui::TextStyleRefinement { + text: gpui::TextStyleRefinement { font_family: Some("Zed Mono".into()), background_color: Some(cx.theme().colors().editor_background), ..Default::default() - }), + }, margin: gpui::EdgesRefinement { top: Some(Length::Definite(rems(4.).into())), left: Some(Length::Definite(rems(4.).into())), diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 2e9103787bf2705732e1dad2276ebbdb21c5c2bc..d6ba3babecf3b6b43155780e569bdc4515762d40 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -838,8 +838,7 @@ impl Element for MarkdownElement { heading.style().refine(&self.style.heading); - let text_style = - self.style.heading.text_style().clone().unwrap_or_default(); + let text_style = self.style.heading.text_style().clone(); builder.push_text_style(text_style); builder.push_div(heading, range, markdown_end); @@ -933,10 +932,7 @@ impl Element for MarkdownElement { } }); - if let Some(code_block_text_style) = &self.style.code_block.text - { - builder.push_text_style(code_block_text_style.to_owned()); - } + builder.push_text_style(self.style.code_block.text.to_owned()); builder.push_code_block(language); builder.push_div(code_block, range, markdown_end); } @@ -1091,9 +1087,7 @@ impl Element for MarkdownElement { builder.pop_div(); builder.pop_code_block(); - if self.style.code_block.text.is_some() { - builder.pop_text_style(); - } + builder.pop_text_style(); if let CodeBlockRenderer::Default { copy_button: true, .. @@ -1346,7 +1340,7 @@ fn apply_heading_style( }; if let Some(style) = style_opt { - heading.style().text = Some(style.clone()); + heading.style().text = style.clone(); } } diff --git a/crates/refineable/derive_refineable/src/derive_refineable.rs b/crates/refineable/derive_refineable/src/derive_refineable.rs index ddf3855a4dc5ae6917309ced57391bd244f1b465..c7c8a91ad9b05d054a94c8ca7f55a54c75150d81 100644 --- a/crates/refineable/derive_refineable/src/derive_refineable.rs +++ b/crates/refineable/derive_refineable/src/derive_refineable.rs @@ -528,7 +528,12 @@ fn get_wrapper_type(field: &Field, ty: &Type) -> syn::Type { } else { panic!("Expected struct type for a refineable field"); }; - let refinement_struct_name = format_ident!("{}Refinement", struct_name); + + let refinement_struct_name = if struct_name.to_string().ends_with("Refinement") { + format_ident!("{}", struct_name) + } else { + format_ident!("{}Refinement", struct_name) + }; let generics = if let Type::Path(tp) = ty { &tp.path.segments.last().unwrap().arguments } else { diff --git a/crates/refineable/src/refineable.rs b/crates/refineable/src/refineable.rs index d2a7c3d3f2148ae174be10121dc23b4dbfc5a650..b2305d4b5a7c1e5e45287394a49a258d98767c66 100644 --- a/crates/refineable/src/refineable.rs +++ b/crates/refineable/src/refineable.rs @@ -13,7 +13,7 @@ pub use derive_refineable::Refineable; /// wrapped appropriately: /// /// - **Refineable fields** (marked with `#[refineable]`): Become the corresponding refinement type -/// (e.g., `Bar` becomes `BarRefinement`) +/// (e.g., `Bar` becomes `BarRefinement`, or `BarRefinement` remains `BarRefinement`) /// - **Optional fields** (`Option`): Remain as `Option` /// - **Regular fields**: Become `Option` /// diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index c998e29f0ed6f5bccab976b11080320d4d65a7dd..7c19953ca43c907070829f7140f97a4fde495b57 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -234,9 +234,7 @@ impl RenderOnce for KeybindingHint { let mut base = h_flex(); - base.text_style() - .get_or_insert_with(Default::default) - .font_style = Some(FontStyle::Italic); + base.text_style().font_style = Some(FontStyle::Italic); base.gap_1() .font_buffer(cx) diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index e51d65c3b6c8ecb38ba26a1926c3bfdbb988a1f8..31fb7bfd88f1343ac6145c86f228bdcbd6a22e10 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -223,9 +223,7 @@ impl RenderOnce for LabelLike { }) .when(self.italic, |this| this.italic()) .when(self.underline, |mut this| { - this.text_style() - .get_or_insert_with(Default::default) - .underline = Some(UnderlineStyle { + this.text_style().underline = Some(UnderlineStyle { thickness: px(1.), color: Some(cx.theme().colors().text_muted.opacity(0.4)), wavy: false, From 3bf57dc7790b359d4c49f5639d0bb7b80eed4b17 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 15 Dec 2025 14:37:05 +0100 Subject: [PATCH 53/67] Revert "extension_api: Add `digest` to `GithubReleaseAsset`" (#44880) Reverts zed-industries/zed#44399 --- crates/extension_api/wit/since_v0.8.0/github.wit | 2 -- crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs | 1 - crates/project/src/agent_server_store.rs | 6 +++--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/extension_api/wit/since_v0.8.0/github.wit b/crates/extension_api/wit/since_v0.8.0/github.wit index 6d7e5d952ae921925459f475bceb74d6c384d8be..21cd5d48056af08441d3bb5aa8547edd97a874d7 100644 --- a/crates/extension_api/wit/since_v0.8.0/github.wit +++ b/crates/extension_api/wit/since_v0.8.0/github.wit @@ -13,8 +13,6 @@ interface github { name: string, /// The download URL for the asset. download-url: string, - /// The SHA-256 of the release asset if provided by the GitHub API. - digest: option, } /// The options used to filter down GitHub releases. diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs index b32ab97983642d68aba041ee3afb902a0c5d2455..a2776f9f3b5b055d00787fb59c9bbca582352b1f 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -783,7 +783,6 @@ impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAs Self { name: value.name, download_url: value.browser_download_url, - digest: value.digest, } } } diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 62937476b8eea4b30f02637b3501ea2b56db81a1..a2cc57beae9702e4d5b495a135e7c357c638c17a 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1495,7 +1495,7 @@ impl ExternalAgentServer for LocalCodex { let digest = asset .digest .as_deref() - .map(|d| d.strip_prefix("sha256:").unwrap_or(d)); + .and_then(|d| d.strip_prefix("sha256:").or(Some(d))); match ::http_client::github_download::download_server_binary( &*http, &asset.browser_download_url, @@ -1727,10 +1727,10 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { release.assets.iter().find(|a| a.name == filename) { // Strip "sha256:" prefix if present - asset.digest.as_ref().map(|d| { + asset.digest.as_ref().and_then(|d| { d.strip_prefix("sha256:") .map(|s| s.to_string()) - .unwrap_or_else(|| d.clone()) + .or_else(|| Some(d.clone())) }) } else { None From 7889aaf3fb74bee4cab6f1ea1715eb08163e6afd Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:44:01 +0100 Subject: [PATCH 54/67] lsp: Support on-type formatting request with newlines (#44882) We called out to `request_on_type_formatting` only in handle_input function, but newlines are actually handled by editor::Newline action. Closes #12383 Release Notes: - Added support for on-type formatting with newlines. --- crates/editor/src/editor.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 5149c01ebeb5e52c4eb093de0c1d10690b2a7035..20a8c75be5dc4f966b0b1002e2979273435fd71c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5018,6 +5018,9 @@ impl Editor { this.change_selections(Default::default(), window, cx, |s| s.select(new_selections)); this.refresh_edit_prediction(true, false, window, cx); + if let Some(task) = this.trigger_on_type_formatting("\n".to_owned(), window, cx) { + task.detach_and_log_err(cx); + } }); } @@ -5082,6 +5085,9 @@ impl Editor { } } editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } }); } @@ -5144,6 +5150,9 @@ impl Editor { } } editor.edit(indent_edits, cx); + if let Some(format) = editor.trigger_on_type_formatting("\n".to_owned(), window, cx) { + format.detach_and_log_err(cx); + } }); } @@ -5454,7 +5463,7 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option>> { - if input.len() != 1 { + if input.chars().count() != 1 { return None; } From a6b7af3cbdff0ed8a06aa423428d68d78e75379e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yara=20=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7=EF=B8=8F?= Date: Mon, 15 Dec 2025 14:58:38 +0100 Subject: [PATCH 55/67] Make LiveKit source use audio priority (#44881) Release Notes: - N/A --- .../src/livekit_client/playback/source.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/livekit_client/src/livekit_client/playback/source.rs b/crates/livekit_client/src/livekit_client/playback/source.rs index cde4b19fda2e053346ad535e7c75b2abda60431a..a258c585285d8adafb1b0039400e6b6e787a509e 100644 --- a/crates/livekit_client/src/livekit_client/playback/source.rs +++ b/crates/livekit_client/src/livekit_client/playback/source.rs @@ -47,14 +47,17 @@ impl LiveKitStream { ); let (queue_input, queue_output) = rodio::queue::queue(true); // spawn rtc stream - let receiver_task = executor.spawn({ - async move { - while let Some(frame) = stream.next().await { - let samples = frame_to_samplesbuffer(frame); - queue_input.append(samples); + let receiver_task = executor.spawn_with_priority( + gpui::Priority::Realtime(gpui::RealtimePriority::Audio), + { + async move { + while let Some(frame) = stream.next().await { + let samples = frame_to_samplesbuffer(frame); + queue_input.append(samples); + } } - } - }); + }, + ); LiveKitStream { _receiver_task: receiver_task, From 07bf685feee40cc541b2764300815a600356188c Mon Sep 17 00:00:00 2001 From: Aaro Luomanen <71641519+aarol@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:03:42 +0200 Subject: [PATCH 56/67] gpui: Support Force Touch go-to-definition on macOS (#40399) Closes #4644 Release Notes: - Adds `MousePressureEvent`, an event that is sent anytime the touchpad pressure changes, into `gpui`. MacOS only. - Triggers go-to-defintion on force clicks in the editor. This is my first contribution, let me know if I've missed something here. --------- Co-authored-by: Anthony Eid Co-authored-by: Antonio Scandurra --- crates/editor/src/editor.rs | 11 ++-- crates/editor/src/element.rs | 45 ++++++++++++++-- crates/editor/src/hover_links.rs | 75 +++++++++++++++++++++++++- crates/gpui/Cargo.toml | 4 ++ crates/gpui/examples/mouse_pressure.rs | 66 +++++++++++++++++++++++ crates/gpui/src/elements/div.rs | 72 +++++++++++++++++++++++-- crates/gpui/src/interactive.rs | 38 +++++++++++++ crates/gpui/src/platform/mac/events.rs | 25 ++++++++- crates/gpui/src/platform/mac/window.rs | 4 ++ crates/gpui/src/window.rs | 3 ++ 10 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 crates/gpui/examples/mouse_pressure.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 20a8c75be5dc4f966b0b1002e2979273435fd71c..afa62e5ff31436ef178a94dc0ff8bedfc2691e60 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -107,10 +107,11 @@ use gpui::{ AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, - MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, Render, - ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun, TextStyle, - TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity, - WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, size, + MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, PressureStage, + Render, ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun, + TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, + WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, + size, }; use hover_links::{HoverLink, HoveredLinkState, find_file}; use hover_popover::{HoverState, hide_hover}; @@ -1121,6 +1122,7 @@ pub struct Editor { remote_id: Option, pub hover_state: HoverState, pending_mouse_down: Option>>>, + prev_pressure_stage: Option, gutter_hovered: bool, hovered_link_state: Option, edit_prediction_provider: Option, @@ -2300,6 +2302,7 @@ impl Editor { remote_id: None, hover_state: HoverState::default(), pending_mouse_down: None, + prev_pressure_stage: None, hovered_link_state: None, edit_prediction_provider: None, active_edit_prediction: None, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index ea619140dca36405f35521e316361942c72f644c..3b16fa1be173ab1a5edbc9bbaad20a3d6b1493e7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -48,11 +48,11 @@ use gpui::{ DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, - ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, - Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, - linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, - transparent_black, + MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement, + Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, + Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity, + Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, + quad, relative, size, solid_background, transparent_black, }; use itertools::Itertools; use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting}; @@ -1035,6 +1035,28 @@ impl EditorElement { } } + fn pressure_click( + editor: &mut Editor, + event: &MousePressureEvent, + position_map: &PositionMap, + window: &mut Window, + cx: &mut Context, + ) { + let text_hitbox = &position_map.text_hitbox; + let force_click_possible = + matches!(editor.prev_pressure_stage, Some(PressureStage::Normal)) + && event.stage == PressureStage::Force; + + editor.prev_pressure_stage = Some(event.stage); + + if force_click_possible && text_hitbox.is_hovered(window) { + let point = position_map.point_for_position(event.position); + editor.handle_click_hovered_link(point, event.modifiers, window, cx); + editor.selection_drag_state = SelectionDragState::None; + cx.stop_propagation(); + } + } + fn mouse_dragged( editor: &mut Editor, event: &MouseMoveEvent, @@ -7769,6 +7791,19 @@ impl EditorElement { } }); + window.on_mouse_event({ + let position_map = layout.position_map.clone(); + let editor = self.editor.clone(); + + move |event: &MousePressureEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + editor.update(cx, |editor, cx| { + Self::pressure_click(editor, &event, &position_map, window, cx); + }) + } + } + }); + window.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 9d0261f00f8f7258023b092d4f55d40ac8abcf40..ba361aa04dee3bfa3a819c8afb7061c238681b77 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -735,7 +735,7 @@ mod tests { test::editor_lsp_test_context::EditorLspTestContext, }; use futures::StreamExt; - use gpui::Modifiers; + use gpui::{Modifiers, MousePressureEvent, PressureStage}; use indoc::indoc; use lsp::request::{GotoDefinition, GotoTypeDefinition}; use multi_buffer::MultiBufferOffset; @@ -1706,4 +1706,77 @@ mod tests { cx.simulate_click(screen_coord, Modifiers::secondary_key()); cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1)); } + + #[gpui::test] + async fn test_pressure_links(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + definition_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn ˇtest() { do_work(); } + fn do_work() { test(); } + "}); + + // Position the mouse over a symbol that has a definition + let hover_point = cx.pixel_position(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let symbol_range = cx.lsp_range(indoc! {" + fn test() { «do_work»(); } + fn do_work() { test(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn «do_work»() { test(); } + "}); + + let mut requests = + cx.set_request_handler::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: Some(symbol_range), + target_uri: url.clone(), + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + cx.simulate_mouse_move(hover_point, None, Modifiers::none()); + + // First simulate Normal pressure to set up the previous stage + cx.simulate_event(MousePressureEvent { + pressure: 0.5, + stage: PressureStage::Normal, + position: hover_point, + modifiers: Modifiers::none(), + }); + cx.background_executor.run_until_parked(); + + // Now simulate Force pressure to trigger the force click and go-to definition + cx.simulate_event(MousePressureEvent { + pressure: 1.0, + stage: PressureStage::Force, + position: hover_point, + modifiers: Modifiers::none(), + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + // Assert that we navigated to the definition + cx.assert_editor_state(indoc! {" + fn test() { do_work(); } + fn «do_workˇ»() { test(); } + "}); + } } diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 8fc37978683357e53ed9f9c3cf587fcd704431e2..da7e660a0171f38b8dd61de1c9323773ded2589b 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -330,3 +330,7 @@ path = "examples/window_shadow.rs" [[example]] name = "grid_layout" path = "examples/grid_layout.rs" + +[[example]] +name = "mouse_pressure" +path = "examples/mouse_pressure.rs" diff --git a/crates/gpui/examples/mouse_pressure.rs b/crates/gpui/examples/mouse_pressure.rs new file mode 100644 index 0000000000000000000000000000000000000000..12790f988eedac3009ae619cadbc6f40c4af2e4b --- /dev/null +++ b/crates/gpui/examples/mouse_pressure.rs @@ -0,0 +1,66 @@ +use gpui::{ + App, Application, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds, + WindowOptions, div, prelude::*, px, rgb, size, +}; + +struct MousePressureExample { + pressure_stage: PressureStage, + pressure_amount: f32, +} + +impl Render for MousePressureExample { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_3() + .bg(rgb(0x505050)) + .size(px(500.0)) + .justify_center() + .items_center() + .shadow_lg() + .border_1() + .border_color(rgb(0x0000ff)) + .text_xl() + .text_color(rgb(0xffffff)) + .child(format!("Pressure stage: {:?}", &self.pressure_stage)) + .child(format!("Pressure amount: {:.2}", &self.pressure_amount)) + .on_mouse_pressure(cx.listener(Self::on_mouse_pressure)) + } +} + +impl MousePressureExample { + fn on_mouse_pressure( + &mut self, + pressure_event: &MousePressureEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.pressure_amount = pressure_event.pressure; + self.pressure_stage = pressure_event.stage; + + cx.notify(); + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| { + cx.new(|_| MousePressureExample { + pressure_stage: PressureStage::Zero, + pressure_amount: 0.0, + }) + }, + ) + .unwrap(); + + cx.activate(true); + }); +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index c80acacce3d714c56dca0cdb65a4477b4c3b3b0e..374fd2c55a8e1cd5280d6ea9378a64c265a5c508 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -20,8 +20,8 @@ use crate::{ DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, - MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, - ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, + MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, size, }; @@ -166,6 +166,38 @@ impl Interactivity { })); } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn capture_mouse_pressure( + &mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_pressure_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Capture && hitbox.is_hovered(window) { + (listener)(event, window, cx) + } + })); + } + /// Bind the given callback to the mouse up event for the given button, during the bubble phase. /// The imperative API equivalent to [`InteractiveElement::on_mouse_up`]. /// @@ -769,6 +801,30 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse pressure event, during the bubble phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_pressure(listener); + self + } + + /// Bind the given callback to the mouse pressure event, during the capture phase + /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`] + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn capture_mouse_pressure( + mut self, + listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_mouse_pressure(listener); + self + } + /// Bind the given callback to the mouse down event, on any button, during the capture phase, /// when the mouse is outside of the bounds of this element. /// The fluent API equivalent to [`Interactivity::on_mouse_down_out`]. @@ -1197,7 +1253,8 @@ pub(crate) type MouseDownListener = Box; pub(crate) type MouseUpListener = Box; - +pub(crate) type MousePressureListener = + Box; pub(crate) type MouseMoveListener = Box; @@ -1521,6 +1578,7 @@ pub struct Interactivity { pub(crate) group_drag_over_styles: Vec<(TypeId, GroupStyle)>, pub(crate) mouse_down_listeners: Vec, pub(crate) mouse_up_listeners: Vec, + pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) key_down_listeners: Vec, @@ -1714,6 +1772,7 @@ impl Interactivity { || self.group_hover_style.is_some() || self.hover_listener.is_some() || !self.mouse_up_listeners.is_empty() + || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() || !self.click_listeners.is_empty() @@ -2064,6 +2123,13 @@ impl Interactivity { }) } + for listener in self.mouse_pressure_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MousePressureEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.mouse_move_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| { diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 03acf81addaad1ae9800ef476a2dc7d13e690cf7..6852b9596a3f74e1d533fc2a7e9a7b7eeab71cda 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -174,6 +174,40 @@ pub struct MouseClickEvent { pub up: MouseUpEvent, } +/// The stage of a pressure click event. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum PressureStage { + /// No pressure. + #[default] + Zero, + /// Normal click pressure. + Normal, + /// High pressure, enough to trigger a force click. + Force, +} + +/// A mouse pressure event from the platform. Generated when a force-sensitive trackpad is pressed hard. +/// Currently only implemented for macOS trackpads. +#[derive(Debug, Clone, Default)] +pub struct MousePressureEvent { + /// Pressure of the current stage as a float between 0 and 1 + pub pressure: f32, + /// The pressure stage of the event. + pub stage: PressureStage, + /// The position of the mouse on the window. + pub position: Point, + /// The modifiers that were held down when the mouse pressure changed. + pub modifiers: Modifiers, +} + +impl Sealed for MousePressureEvent {} +impl InputEvent for MousePressureEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::MousePressure(self) + } +} +impl MouseEvent for MousePressureEvent {} + /// A click event that was generated by a keyboard button being pressed and released. #[derive(Clone, Debug, Default)] pub struct KeyboardClickEvent { @@ -571,6 +605,8 @@ pub enum PlatformInput { MouseDown(MouseDownEvent), /// The mouse was released. MouseUp(MouseUpEvent), + /// Mouse pressure. + MousePressure(MousePressureEvent), /// The mouse was moved. MouseMove(MouseMoveEvent), /// The mouse exited the window. @@ -590,6 +626,7 @@ impl PlatformInput { PlatformInput::MouseDown(event) => Some(event), PlatformInput::MouseUp(event) => Some(event), PlatformInput::MouseMove(event) => Some(event), + PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), @@ -604,6 +641,7 @@ impl PlatformInput { PlatformInput::MouseDown(_) => None, PlatformInput::MouseUp(_) => None, PlatformInput::MouseMove(_) => None, + PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, PlatformInput::FileDrop(_) => None, diff --git a/crates/gpui/src/platform/mac/events.rs b/crates/gpui/src/platform/mac/events.rs index acc392a5f3429f20931455ea06733376ea0f587a..7a12e8d3d7ccb2e8a2f7b32b81c24a29f650e6e2 100644 --- a/crates/gpui/src/platform/mac/events.rs +++ b/crates/gpui/src/platform/mac/events.rs @@ -1,7 +1,8 @@ use crate::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, - PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase, + MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, + NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, + TouchPhase, platform::mac::{ LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData, @@ -187,6 +188,26 @@ impl PlatformInput { }) }) } + NSEventType::NSEventTypePressure => { + let stage = native_event.stage(); + let pressure = native_event.pressure(); + + window_height.map(|window_height| { + Self::MousePressure(MousePressureEvent { + stage: match stage { + 1 => PressureStage::Normal, + 2 => PressureStage::Force, + _ => PressureStage::Zero, + }, + pressure, + modifiers: read_modifiers(native_event), + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + }) + }) + } // Some mice (like Logitech MX Master) send navigation buttons as swipe events NSEventType::NSEventTypeSwipe => { let navigation_direction = match native_event.phase() { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 23752fc53edbc1062db19caf13c5c65fc282ca87..53207fb77d16f2e1956f6914889b29ae3ea7bb35 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -153,6 +153,10 @@ unsafe fn build_classes() { sel!(mouseMoved:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(pressureChangeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 54fe99c2634f5afa2e1f1e224e969c21d4c38e34..36e46f6961ae8a1e8581b3c01987f4641377d677 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3705,6 +3705,9 @@ impl Window { self.modifiers = mouse_up.modifiers; PlatformInput::MouseUp(mouse_up) } + PlatformInput::MousePressure(mouse_pressure) => { + PlatformInput::MousePressure(mouse_pressure) + } PlatformInput::MouseExited(mouse_exited) => { self.modifiers = mouse_exited.modifiers; PlatformInput::MouseExited(mouse_exited) From 6eb198cabf825071d69ae4bde4de5e0dc487a1d2 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:08:56 +0100 Subject: [PATCH 57/67] Revert "Add Doxygen injection into C and C++ comments" (#44883) Reverts zed-industries/zed#43581 Release notes: - Fixed comment injections not working with C and C++. --- crates/languages/src/c/injections.scm | 5 ++--- crates/languages/src/cpp/injections.scm | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/languages/src/c/injections.scm b/crates/languages/src/c/injections.scm index d7df76b118672e77e3e2a6eacb320aade84c05fa..447897340cc735ed77099b20fd6fc8c52ac19ec8 100644 --- a/crates/languages/src/c/injections.scm +++ b/crates/languages/src/c/injections.scm @@ -1,7 +1,6 @@ ((comment) @injection.content - (#match? @injection.content "^(///|//!|/\\*\\*|/\\*!)(.*)") - (#set! injection.language "doxygen") - (#set! injection.include-children)) + (#set! injection.language "comment") +) (preproc_def value: (preproc_arg) @injection.content diff --git a/crates/languages/src/cpp/injections.scm b/crates/languages/src/cpp/injections.scm index a115a3bffdbe4c522b611f3786ffc95dcecc5cff..160770f3cc1d69f5cb3d1679c8a48726d8d437ed 100644 --- a/crates/languages/src/cpp/injections.scm +++ b/crates/languages/src/cpp/injections.scm @@ -1,7 +1,6 @@ ((comment) @injection.content - (#match? @injection.content "^(///|//!|/\\*\\*|/\\*!)(.*)") - (#set! injection.language "doxygen") - (#set! injection.include-children)) + (#set! injection.language "comment") +) (preproc_def value: (preproc_arg) @injection.content From f4c3a6c23690e0931fffbdca69a1ecada971d737 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 15 Dec 2025 15:19:33 +0100 Subject: [PATCH 58/67] wsl: Fix folder picker adding wrong slashes (#44886) Closes https://github.com/zed-industries/zed/issues/44508 Release Notes: - Fixed folder picker inserting wrong slashes when remoting from windows to wsl --- crates/file_finder/src/open_path_prompt.rs | 6 +- .../file_finder/src/open_path_prompt_tests.rs | 63 +--------- crates/project/src/project.rs | 8 ++ crates/recent_projects/src/remote_servers.rs | 6 +- .../src/toolchain_selector.rs | 114 +++++++++--------- 5 files changed, 72 insertions(+), 125 deletions(-) diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 2ae0c47776acb5c58b7d0919aa7522fb64d923d0..f75d0ee99dc32bc1a1ab812328bba3d36fcb2953 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -44,8 +44,9 @@ impl OpenPathDelegate { tx: oneshot::Sender>>, lister: DirectoryLister, creating_path: bool, - path_style: PathStyle, + cx: &App, ) -> Self { + let path_style = lister.path_style(cx); Self { tx: Some(tx), lister, @@ -216,8 +217,7 @@ impl OpenPathPrompt { cx: &mut Context, ) { workspace.toggle_modal(window, cx, |window, cx| { - let delegate = - OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::local()); + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); let query = lister.default_query(cx); picker.set_query(query, window, cx); diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs index dea188034bfa7ae46f5b17c50424b40331fadb75..9af18c8a6bd82b389d4d18a997c3b5fe4a088730 100644 --- a/crates/file_finder/src/open_path_prompt_tests.rs +++ b/crates/file_finder/src/open_path_prompt_tests.rs @@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate}; use project::Project; use serde_json::json; use ui::rems; -use util::{path, paths::PathStyle}; +use util::path; use workspace::{AppState, Workspace}; use crate::OpenPathDelegate; @@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); insert_query(path!("sadjaoislkdjasldj"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), Vec::::new()); @@ -119,7 +119,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. let query = path!("/root"); @@ -227,7 +227,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, false, cx); // Support both forward and backward slashes. let query = "C:/root/"; @@ -295,56 +295,6 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { ); } -#[gpui::test] -#[cfg_attr(not(target_os = "windows"), ignore)] -async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) { - let app_state = init_test(cx); - app_state - .fs - .as_fake() - .insert_tree( - "/root", - json!({ - "a": "A", - "dir1": {}, - "dir2": {} - }), - ) - .await; - - let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - - let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx); - - let query = "/root/"; - insert_query(query, &picker, cx).await; - assert_eq!( - collect_match_candidates(&picker, cx), - vec!["./", "a", "dir1", "dir2"] - ); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/a" - ); - - // Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 1, &picker, cx).unwrap(), - "/root/dir2/" - ); - - let query = "/root/d"; - insert_query(query, &picker, cx).await; - assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); - assert_eq!( - confirm_completion(query, 0, &picker, cx).unwrap(), - "/root/dir1/" - ); -} - #[gpui::test] async fn test_new_path_prompt(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -372,7 +322,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, true, PathStyle::local(), cx); + let (picker, cx) = build_open_path_prompt(project, true, cx); insert_query(path!("/root"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); @@ -406,16 +356,15 @@ fn init_test(cx: &mut TestAppContext) -> Arc { fn build_open_path_prompt( project: Entity, creating_path: bool, - path_style: PathStyle, cx: &mut TestAppContext, ) -> (Entity>, &mut VisualTestContext) { let (tx, _) = futures::channel::oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); ( workspace.update_in(cx, |_, window, cx| { + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .width(rems(34.)) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ec44a60d71e4b0c1f10ad698f727357e60aa3b85..7e7c1ecb67d2f463cb5b728cbb2a7f1ea2b072e0 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -966,6 +966,14 @@ impl DirectoryLister { } } } + + pub fn path_style(&self, cx: &App) -> PathStyle { + match self { + Self::Local(project, ..) | Self::Project(project, ..) => { + project.read(cx).path_style(cx) + } + } + } } #[cfg(any(test, feature = "test-support"))] diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 32a4ef1a81a06a8b5968f7941edb4ab8ea0a5111..1df3abbeaee41532abcf12f5939db050429c73da 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -217,14 +217,13 @@ impl ProjectPicker { connection: RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, - path_style: PathStyle, workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Entity { let (tx, rx) = oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style); + let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, cx); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) @@ -719,7 +718,6 @@ impl RemoteServerProjects { connection_options: remote::RemoteConnectionOptions, project: Entity, home_dir: RemotePathBuf, - path_style: PathStyle, window: &mut Window, cx: &mut Context, workspace: WeakEntity, @@ -732,7 +730,6 @@ impl RemoteServerProjects { connection_options, project, home_dir, - path_style, workspace, window, cx, @@ -1030,7 +1027,6 @@ impl RemoteServerProjects { connection_options, project, home_dir, - path_style, window, cx, weak, diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 138f99066f0a80188837de49f6afc67d91d9eeb5..b58b2f8d699f59c15525c452543cf5bdf071ad2c 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -128,67 +128,61 @@ impl AddToolchainState { ) -> (OpenPathDelegate, oneshot::Receiver>>) { let (tx, rx) = oneshot::channel(); let weak = cx.weak_entity(); - let path_style = project.read(cx).path_style(cx); - let lister = - OpenPathDelegate::new(tx, DirectoryLister::Project(project), false, path_style) - .show_hidden() - .with_footer(Arc::new(move |_, cx| { - let error = weak - .read_with(cx, |this, _| { - if let AddState::Path { error, .. } = &this.state { - error.clone() - } else { - None + let lister = OpenPathDelegate::new(tx, DirectoryLister::Project(project), false, cx) + .show_hidden() + .with_footer(Arc::new(move |_, cx| { + let error = weak + .read_with(cx, |this, _| { + if let AddState::Path { error, .. } = &this.state { + error.clone() + } else { + None + } + }) + .ok() + .flatten(); + let is_loading = weak + .read_with(cx, |this, _| { + matches!( + this.state, + AddState::Path { + input_state: PathInputState::Resolving(_), + .. } - }) - .ok() - .flatten(); - let is_loading = weak - .read_with(cx, |this, _| { - matches!( - this.state, - AddState::Path { - input_state: PathInputState::Resolving(_), - .. - } - ) - }) - .unwrap_or_default(); - Some( - v_flex() - .child(Divider::horizontal()) - .child( - h_flex() - .p_1() - .justify_between() - .gap_2() - .child( - Label::new("Select Toolchain Path") - .color(Color::Muted) - .map(|this| { - if is_loading { - this.with_animation( - "select-toolchain-label", - Animation::new(Duration::from_secs(2)) - .repeat() - .with_easing(pulsating_between( - 0.4, 0.8, - )), - |label, delta| label.alpha(delta), - ) - .into_any() - } else { - this.into_any_element() - } - }), - ) - .when_some(error, |this, error| { - this.child(Label::new(error).color(Color::Error)) - }), - ) - .into_any(), - ) - })); + ) + }) + .unwrap_or_default(); + Some( + v_flex() + .child(Divider::horizontal()) + .child( + h_flex() + .p_1() + .justify_between() + .gap_2() + .child(Label::new("Select Toolchain Path").color(Color::Muted).map( + |this| { + if is_loading { + this.with_animation( + "select-toolchain-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.4, 0.8)), + |label, delta| label.alpha(delta), + ) + .into_any() + } else { + this.into_any_element() + } + }, + )) + .when_some(error, |this, error| { + this.child(Label::new(error).color(Color::Error)) + }), + ) + .into_any(), + ) + })); (lister, rx) } From 158ebdc5803f9f69dfbfddcd216f04e5bc6001d9 Mon Sep 17 00:00:00 2001 From: William Whittaker Date: Mon, 15 Dec 2025 08:09:10 -0700 Subject: [PATCH 59/67] Allow external handles to be provided to gpui_tokio (#42795) This PR allows for a handle to an existing Tokio runtime to be passed to gpui_tokio's initialization function, which means that Tokio runtimes created externally can be used. Mikayla suggested that the function simply take the runtime from whatever context the initialization function is called from but I think there could reasonably be situations where that isn't the case and this shouldn't have a meaningful impact to code complexity. If you want to use the current context's runtime you can just do `gpui_tokio::init_from_handle(cx, Handle::current());`. This doesn't have an impact on the current users of the crate - the existing `init()` function is functionally unchanged. Release Notes: - N/A --- crates/gpui_tokio/src/gpui_tokio.rs | 47 +++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/crates/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs index 61dcfc48efb1dfecc04c4a131ddc32691e01e255..9cfa1493af49ee95210edb9669a6ca89095f42cd 100644 --- a/crates/gpui_tokio/src/gpui_tokio.rs +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -5,25 +5,48 @@ use util::defer; pub use tokio::task::JoinError; +/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads. +/// +/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime +/// yourself and pass a Handle to `init_from_handle`. pub fn init(cx: &mut App) { - cx.set_global(GlobalTokio::new()); + let runtime = tokio::runtime::Builder::new_multi_thread() + // Since we now have two executors, let's try to keep our footprint small + .worker_threads(2) + .enable_all() + .build() + .expect("Failed to initialize Tokio"); + + cx.set_global(GlobalTokio::new(RuntimeHolder::Owned(runtime))); +} + +/// Initializes the Tokio wrapper using a Tokio runtime handle. +pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) { + cx.set_global(GlobalTokio::new(RuntimeHolder::Shared(handle))); +} + +enum RuntimeHolder { + Owned(tokio::runtime::Runtime), + Shared(tokio::runtime::Handle), +} + +impl RuntimeHolder { + pub fn handle(&self) -> &tokio::runtime::Handle { + match self { + RuntimeHolder::Owned(runtime) => runtime.handle(), + RuntimeHolder::Shared(handle) => handle, + } + } } struct GlobalTokio { - runtime: tokio::runtime::Runtime, + runtime: RuntimeHolder, } impl Global for GlobalTokio {} impl GlobalTokio { - fn new() -> Self { - let runtime = tokio::runtime::Builder::new_multi_thread() - // Since we now have two executors, let's try to keep our footprint small - .worker_threads(2) - .enable_all() - .build() - .expect("Failed to initialize Tokio"); - + fn new(runtime: RuntimeHolder) -> Self { Self { runtime } } } @@ -40,7 +63,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); @@ -62,7 +85,7 @@ impl Tokio { R: Send + 'static, { cx.read_global(|tokio: &GlobalTokio, cx| { - let join_handle = tokio.runtime.spawn(f); + let join_handle = tokio.runtime.handle().spawn(f); let abort_handle = join_handle.abort_handle(); let cancel = defer(move || { abort_handle.abort(); From b71ef540fc0289cb59cdf25fe9733b36ae71c8cf Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 15 Dec 2025 07:09:52 -0800 Subject: [PATCH 60/67] Add trailing commas to all asset jsonc files following #43854 (#44891) Closes #ISSUE Post #43854, we are advertising trailing comma support for our asset `jsonc` files to the JSON LSP. This results in it adding trailing commas on format of these files. This PR batch updates the formatting for these files, so they are not spuriously added as part of other PRs that happen to modify these files Release Notes: - N/A *or* Added/Fixed/Improved ... --- assets/keymaps/default-linux.json | 426 +++++++++--------- assets/keymaps/default-macos.json | 438 +++++++++---------- assets/keymaps/default-windows.json | 424 +++++++++--------- assets/keymaps/initial.json | 6 +- assets/keymaps/linux/atom.json | 34 +- assets/keymaps/linux/cursor.json | 30 +- assets/keymaps/linux/emacs.json | 42 +- assets/keymaps/linux/jetbrains.json | 50 +-- assets/keymaps/linux/sublime_text.json | 26 +- assets/keymaps/macos/atom.json | 34 +- assets/keymaps/macos/cursor.json | 30 +- assets/keymaps/macos/emacs.json | 42 +- assets/keymaps/macos/jetbrains.json | 50 +-- assets/keymaps/macos/sublime_text.json | 26 +- assets/keymaps/macos/textmate.json | 32 +- assets/keymaps/storybook.json | 6 +- assets/settings/initial_debug_tasks.json | 8 +- assets/settings/initial_server_settings.json | 2 +- assets/settings/initial_tasks.json | 4 +- assets/settings/initial_user_settings.json | 4 +- 20 files changed, 857 insertions(+), 857 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 872544cdff0bc03bbafd6b711fa7adb2f5e2d008..342c4b0b7cb9608c13bed2899dd67b3ac0378db5 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -44,15 +44,15 @@ "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RatePredictions", "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", - "ctrl-alt-l": "lsp_tool::ToggleMenu" - } + "ctrl-alt-l": "lsp_tool::ToggleMenu", + }, }, { "context": "Picker || menu", "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -124,8 +124,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -144,44 +144,44 @@ "ctrl-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "bindings": { "copy": "markdown::Copy", "ctrl-insert": "markdown::Copy", - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -189,8 +189,8 @@ "ctrl-k ctrl-r": "git::Restore", "ctrl-alt-y": "git::ToggleStaged", "alt-y": "git::StageAndNext", - "alt-shift-y": "git::UnstageAndNext" - } + "alt-shift-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -199,8 +199,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,8 +208,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -225,8 +225,8 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + }, }, { "context": "AgentPanel", @@ -250,37 +250,37 @@ "alt-enter": "agent::ContinueWithBurnMode", "ctrl-y": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "ctrl-alt-z": "agent::RejectOnce" - } + "ctrl-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "bindings": { "copy": "markdown::CopyAsMarkdown", "ctrl-insert": "markdown::CopyAsMarkdown", - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -290,8 +290,8 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -301,30 +301,30 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "EditMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -333,8 +333,8 @@ "enter": "agent::Chat", "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -344,14 +344,14 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "ThreadHistory", "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -359,8 +359,8 @@ "new": "rules_library::NewRule", "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -373,22 +373,22 @@ "find": "search::FocusSearch", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -399,22 +399,22 @@ "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -422,8 +422,8 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", "alt-ctrl-g": "search::ToggleRegex", - "alt-ctrl-x": "search::ToggleRegex" - } + "alt-ctrl-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -472,8 +472,8 @@ "ctrl-alt-shift-r": "search::ToggleRegex", "ctrl-alt-shift-x": "search::ToggleRegex", "alt-r": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -537,31 +537,31 @@ "ctrl-\\": "pane::SplitRight", "ctrl-alt-shift-c": "editor::DisplayCursorNames", "alt-.": "editor::GoToHunk", - "alt-,": "editor::GoToPreviousHunk" - } + "alt-,": "editor::GoToPreviousHunk", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -655,28 +655,28 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -694,8 +694,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -704,37 +704,37 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -746,22 +746,22 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -771,29 +771,29 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { "bindings": { "ctrl-alt-shift-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "ctrl-alt-i": "dev::ToggleInspector" - } + "ctrl-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -805,8 +805,8 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", @@ -814,8 +814,8 @@ "ctrl-[": "agent::CyclePreviousInlineAssist", "ctrl-]": "agent::CycleNextInlineAssist", "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", - "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult" - } + "ctrl-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -823,14 +823,14 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -847,8 +847,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -886,14 +886,14 @@ "ctrl-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", @@ -914,15 +914,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -931,8 +931,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -948,8 +948,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -957,14 +957,14 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -976,16 +976,16 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -997,8 +997,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1006,35 +1006,35 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1043,29 +1043,29 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1074,8 +1074,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1083,15 +1083,15 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1136,58 +1136,58 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "ZedPredictModal", "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", @@ -1197,8 +1197,8 @@ "up": "markdown::ScrollUp", "down": "markdown::ScrollDown", "alt-up": "markdown::ScrollUpByItem", - "alt-down": "markdown::ScrollDownByItem" - } + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1212,8 +1212,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1221,24 +1221,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1250,8 +1250,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1260,23 +1260,23 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1301,16 +1301,16 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1325,22 +1325,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { "context": "EditPredictionContext > Editor", "bindings": { "alt-left": "dev::EditPredictionContextGoBack", - "alt-right": "dev::EditPredictionContextGoForward" - } + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes" - } - } + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 65ac280ba7f782cef417aef220dacd7f32f9e6ff..50fc0c7222b76c9e5218c47a481442534debe2b0 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -50,8 +50,8 @@ "ctrl-cmd-z": "edit_prediction::RatePredictions", "ctrl-cmd-i": "edit_prediction::ToggleMenu", "ctrl-cmd-l": "lsp_tool::ToggleMenu", - "ctrl-cmd-c": "editor::DisplayCursorNames" - } + "ctrl-cmd-c": "editor::DisplayCursorNames", + }, }, { "context": "Editor", @@ -148,8 +148,8 @@ "shift-f9": "editor::EditLogBreakpoint", "ctrl-f12": "editor::GoToDeclaration", "alt-ctrl-f12": "editor::GoToDeclarationSplit", - "ctrl-cmd-e": "editor::ToggleEditPrediction" - } + "ctrl-cmd-e": "editor::ToggleEditPrediction", + }, }, { "context": "Editor && mode == full", @@ -167,8 +167,8 @@ "cmd->": "agent::AddSelectionToThread", "cmd-<": "assistant::InsertIntoEditor", "cmd-alt-e": "editor::SelectEnclosingSymbol", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && multibuffer", @@ -177,23 +177,23 @@ "cmd-up": "editor::MoveToStartOfExcerpt", "cmd-down": "editor::MoveToStartOfNextExcerpt", "cmd-shift-up": "editor::SelectToStartOfExcerpt", - "cmd-shift-down": "editor::SelectToStartOfNextExcerpt" - } + "cmd-shift-down": "editor::SelectToStartOfNextExcerpt", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::NextEditPrediction", - "alt-shift-tab": "editor::PreviousEditPrediction" - } + "alt-shift-tab": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-tab": "editor::ShowEditPrediction" - } + "alt-tab": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -201,23 +201,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::Copy" - } + "cmd-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff && !AgentPanel", @@ -226,8 +226,8 @@ "cmd-alt-z": "git::Restore", "cmd-alt-y": "git::ToggleStaged", "cmd-y": "git::StageAndNext", - "cmd-shift-y": "git::UnstageAndNext" - } + "cmd-shift-y": "git::UnstageAndNext", + }, }, { "context": "AgentDiff", @@ -236,8 +236,8 @@ "cmd-y": "agent::Keep", "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "Editor && editor_agent_diff", @@ -247,8 +247,8 @@ "cmd-n": "agent::Reject", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-ctrl-r": "agent::OpenAgentDiff" - } + "shift-ctrl-r": "agent::OpenAgentDiff", + }, }, { "context": "ContextEditor > Editor", @@ -264,8 +264,8 @@ "cmd-k c": "assistant::CopyCode", "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPreviousMatch", - "cmd-k l": "agent::OpenRulesLibrary" - } + "cmd-k l": "agent::OpenRulesLibrary", + }, }, { "context": "AgentPanel", @@ -290,37 +290,37 @@ "alt-enter": "agent::ContinueWithBurnMode", "cmd-y": "agent::AllowOnce", "cmd-alt-y": "agent::AllowAlways", - "cmd-alt-z": "agent::RejectOnce" - } + "cmd-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "cmd-c": "markdown::CopyAsMarkdown" - } + "cmd-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewTextThread", - "cmd-alt-n": "agent::NewExternalAgentThread" - } + "cmd-alt-n": "agent::NewExternalAgentThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "cmd-n": "agent::NewExternalAgentThread", - "cmd-alt-t": "agent::NewThread" - } + "cmd-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -331,8 +331,8 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -343,8 +343,8 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", - "cmd-shift-n": "agent::RejectAll" - } + "cmd-shift-n": "agent::RejectAll", + }, }, { "context": "EditMessageEditor > Editor", @@ -352,8 +352,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -361,20 +361,20 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentConfiguration", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -384,8 +384,8 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -395,20 +395,20 @@ "shift-ctrl-r": "agent::OpenAgentDiff", "cmd-shift-y": "agent::KeepAll", "cmd-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "ThreadHistory", "bindings": { - "ctrl--": "pane::GoBack" - } + "ctrl--": "pane::GoBack", + }, }, { "context": "ThreadHistory > Editor", "bindings": { - "shift-backspace": "agent::RemoveSelectedThread" - } + "shift-backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -416,8 +416,8 @@ "bindings": { "cmd-n": "rules_library::NewRule", "cmd-shift-s": "rules_library::ToggleDefaultRule", - "cmd-w": "workspace::CloseWindow" - } + "cmd-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -431,24 +431,24 @@ "cmd-f": "search::FocusSearch", "cmd-alt-f": "search::ToggleReplace", "cmd-alt-l": "search::ToggleSelection", - "cmd-shift-o": "outline::Toggle" - } + "cmd-shift-o": "outline::Toggle", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -460,24 +460,24 @@ "cmd-shift-f": "search::FocusSearch", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "cmd-enter": "search::ReplaceAll" - } + "cmd-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -488,8 +488,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "cmd-shift-h": "search::ToggleReplace", "alt-cmd-g": "search::ToggleRegex", - "alt-cmd-x": "search::ToggleRegex" - } + "alt-cmd-x": "search::ToggleRegex", + }, }, { "context": "Pane", @@ -519,8 +519,8 @@ "alt-cmd-w": "search::ToggleWholeWord", "alt-cmd-f": "project_search::ToggleFilters", "alt-cmd-x": "search::ToggleRegex", - "cmd-k shift-enter": "pane::TogglePinTab" - } + "cmd-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -590,24 +590,24 @@ "cmd-.": "editor::ToggleCodeActions", "cmd-k r": "editor::RevealInFileManager", "cmd-k p": "editor::CopyPath", - "cmd-\\": "pane::SplitRight" - } + "cmd-\\": "pane::SplitRight", + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "cmd-k v": "markdown::OpenPreviewToTheSide", - "cmd-shift-v": "markdown::OpenPreview" - } + "cmd-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "cmd-k v": "svg::OpenPreviewToTheSide", - "cmd-shift-v": "svg::OpenPreview" - } + "cmd-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", @@ -616,8 +616,8 @@ "cmd-shift-o": "outline::Toggle", "ctrl-g": "go_to_line::Toggle", "cmd-shift-backspace": "editor::GoToPreviousChange", - "cmd-shift-alt-backspace": "editor::GoToNextChange" - } + "cmd-shift-alt-backspace": "editor::GoToNextChange", + }, }, { "context": "Pane", @@ -635,8 +635,8 @@ "ctrl-0": "pane::ActivateLastItem", "ctrl--": "pane::GoBack", "ctrl-_": "pane::GoForward", - "cmd-shift-f": "pane::DeploySearch" - } + "cmd-shift-f": "pane::DeploySearch", + }, }, { "context": "Workspace", @@ -707,8 +707,8 @@ "cmd-k shift-down": "workspace::SwapPaneDown", "cmd-shift-x": "zed::Extensions", "f5": "debugger::Rerun", - "cmd-w": "workspace::CloseActiveDock" - } + "cmd-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && !Terminal", @@ -719,27 +719,27 @@ // All task parameters are captured and unchanged between reruns by default. // Use the `"reevaluate_context"` parameter to control this. "cmd-alt-r": ["task::Rerun", { "reevaluate_context": false }], - "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }] + "ctrl-alt-shift-r": ["task::Spawn", { "reveal_target": "center" }], // also possible to spawn tasks by name: // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] // or by tag: // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], - } + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { "f5": "zed::NoAction", - "f11": "debugger::StepInto" - } + "f11": "debugger::StepInto", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, // Bindings from Sublime Text { @@ -760,8 +760,8 @@ "ctrl-alt-shift-left": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-b": "editor::SelectToPreviousSubwordStart", "ctrl-alt-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd" - } + "ctrl-alt-shift-f": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -771,16 +771,16 @@ "cmd-k up": "pane::SplitUp", "cmd-k down": "pane::SplitDown", "cmd-k left": "pane::SplitLeft", - "cmd-k right": "pane::SplitRight" - } + "cmd-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -788,45 +788,45 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, { "context": "Editor && edit_prediction", "bindings": { "alt-tab": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", "use_key_equivalents": true, "bindings": { "alt-tab": "editor::AcceptEditPrediction", - "ctrl-cmd-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-cmd-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -837,15 +837,15 @@ "down": "editor::ContextMenuNext", "ctrl-n": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -855,8 +855,8 @@ // TODO: Move this to a dock open action "cmd-shift-c": "collab_panel::ToggleFocus", // Only available in debug builds: opens an element inspector for development. - "cmd-alt-i": "dev::ToggleInspector" - } + "cmd-alt-i": "dev::ToggleInspector", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -869,8 +869,8 @@ "cmd-f8": "editor::GoToHunk", "cmd-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-:": "editor::ToggleInlayHints" - } + "ctrl-:": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", @@ -880,8 +880,8 @@ "ctrl-[": "agent::CyclePreviousInlineAssist", "ctrl-]": "agent::CycleNextInlineAssist", "cmd-shift-enter": "inline_assistant::ThumbsUpResult", - "cmd-shift-backspace": "inline_assistant::ThumbsDownResult" - } + "cmd-shift-backspace": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -890,15 +890,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "cmd-enter": "project_search::SearchInNew" - } + "cmd-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -914,8 +914,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "cmd-alt-enter": "editor::OpenExcerptsSplit" - } + "cmd-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -945,15 +945,15 @@ "cmd-alt-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "VariableList", @@ -966,8 +966,8 @@ "cmd-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "GitPanel && ChangesList", @@ -990,15 +990,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "delete": ["git::RestoreFile", { "skip_prompt": false }], "cmd-backspace": ["git::RestoreFile", { "skip_prompt": true }], - "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }] - } + "cmd-delete": ["git::RestoreFile", { "skip_prompt": true }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitDiff > Editor", @@ -1007,8 +1007,8 @@ "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", "cmd-ctrl-y": "git::StageAll", - "cmd-ctrl-shift-y": "git::UnstageAll" - } + "cmd-ctrl-shift-y": "git::UnstageAll", + }, }, { "context": "CommitEditor > Editor", @@ -1021,8 +1021,8 @@ "shift-tab": "git_panel::FocusChanges", "alt-up": "git_panel::FocusChanges", "shift-escape": "git::ExpandCommitEditor", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -1039,8 +1039,8 @@ "cmd-ctrl-y": "git::StageAll", "cmd-ctrl-shift-y": "git::UnstageAll", "cmd-enter": "git::Commit", - "cmd-shift-enter": "git::Amend" - } + "cmd-shift-enter": "git::Amend", + }, }, { "context": "GitCommit > Editor", @@ -1050,16 +1050,16 @@ "escape": "menu::Cancel", "cmd-enter": "git::Commit", "cmd-shift-enter": "git::Amend", - "alt-tab": "git::GenerateCommitMessage" - } + "alt-tab": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", "bindings": { "cmd-t": "debugger::ToggleThreadPicker", "cmd-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "BreakpointList", @@ -1067,16 +1067,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1084,22 +1084,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1110,30 +1110,30 @@ "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], - "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }] - } + "cmd-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "cmd-shift-a": "toolchain::AddToolchain" - } + "cmd-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu" - } + "cmd-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1143,8 +1143,8 @@ "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", - "cmd-l": "pane::SplitRight" - } + "cmd-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1153,16 +1153,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1217,8 +1217,8 @@ "ctrl-alt-left": "pane::SplitLeft", "ctrl-alt-right": "pane::SplitRight", "cmd-d": "pane::SplitRight", - "cmd-alt-r": "terminal::RerunTask" - } + "cmd-alt-r": "terminal::RerunTask", + }, }, { "context": "RatePredictionsModal", @@ -1228,8 +1228,8 @@ "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", "shift-down": "zeta::NextEdit", "shift-up": "zeta::PreviousEdit", - "right": "zeta::PreviewPrediction" - } + "right": "zeta::PreviewPrediction", + }, }, { "context": "RatePredictionsModal > Editor", @@ -1237,15 +1237,15 @@ "bindings": { "escape": "zeta::FocusPredictions", "cmd-shift-enter": "zeta::ThumbsUpActivePrediction", - "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction" - } + "cmd-shift-backspace": "zeta::ThumbsDownActivePrediction", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1253,45 +1253,45 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "cmd-enter": "menu::Confirm" - } + "cmd-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", @@ -1301,8 +1301,8 @@ "up": "markdown::ScrollUp", "down": "markdown::ScrollDown", "alt-up": "markdown::ScrollUpByItem", - "alt-down": "markdown::ScrollDownByItem" - } + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1315,8 +1315,8 @@ "alt-enter": "keymap_editor::CreateBinding", "cmd-c": "keymap_editor::CopyAction", "cmd-shift-c": "keymap_editor::CopyContext", - "cmd-t": "keymap_editor::ShowMatchingKeybinds" - } + "cmd-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1324,24 +1324,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "cmd-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1353,8 +1353,8 @@ "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], "cmd-enter": "onboarding::Finish", "alt-tab": "onboarding::SignIn", - "alt-shift-a": "onboarding::OpenAccount" - } + "alt-shift-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1363,23 +1363,23 @@ "cmd-=": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd-+": ["zed::IncreaseUiFontSize", { "persist": false }], "cmd--": ["zed::DecreaseUiFontSize", { "persist": false }], - "cmd-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "cmd-0": ["zed::ResetUiFontSize", { "persist": false }], + }, }, { "context": "InvalidBuffer", "use_key_equivalents": true, "bindings": { - "ctrl-shift-enter": "workspace::OpenWithSystem" - } + "ctrl-shift-enter": "workspace::OpenWithSystem", + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1404,8 +1404,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "cmd-{": "settings_editor::FocusPreviousFile", - "cmd-}": "settings_editor::FocusNextFile" - } + "cmd-}": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1413,8 +1413,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1429,22 +1429,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { "context": "EditPredictionContext > Editor", "bindings": { "alt-left": "dev::EditPredictionContextGoBack", - "alt-right": "dev::EditPredictionContextGoForward" - } + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "branch_picker::DeleteBranch", - "cmd-shift-i": "branch_picker::FilterRemotes" - } - } + "cmd-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ae051f233e344cc6b961612c690ae1b5107fb2c0..61793d2158d35ed25f71da3606534d64b523de9f 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -42,16 +42,16 @@ "f11": "zed::ToggleFullScreen", "ctrl-shift-i": "edit_prediction::ToggleMenu", "shift-alt-l": "lsp_tool::ToggleMenu", - "ctrl-shift-alt-c": "editor::DisplayCursorNames" - } + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + }, }, { "context": "Picker || menu", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Editor", @@ -119,8 +119,8 @@ "shift-f10": "editor::OpenContextMenu", "ctrl-alt-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", - "shift-f9": "editor::EditLogBreakpoint" - } + "shift-f9": "editor::EditLogBreakpoint", + }, }, { "context": "Editor && mode == full", @@ -139,23 +139,23 @@ "shift-alt-e": "editor::SelectEnclosingSymbol", "ctrl-shift-backspace": "editor::GoToPreviousChange", "ctrl-shift-alt-backspace": "editor::GoToNextChange", - "alt-enter": "editor::OpenSelectionsInMultibuffer" - } + "alt-enter": "editor::OpenSelectionsInMultibuffer", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { "alt-]": "editor::NextEditPrediction", - "alt-[": "editor::PreviousEditPrediction" - } + "alt-[": "editor::PreviousEditPrediction", + }, }, { "context": "Editor && !edit_prediction", "use_key_equivalents": true, "bindings": { - "alt-\\": "editor::ShowEditPrediction" - } + "alt-\\": "editor::ShowEditPrediction", + }, }, { "context": "Editor && mode == auto_height", @@ -163,23 +163,23 @@ "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", - "ctrl-shift-enter": "editor::NewlineBelow" - } + "ctrl-shift-enter": "editor::NewlineBelow", + }, }, { "context": "Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::Copy" - } + "ctrl-c": "markdown::Copy", + }, }, { "context": "Editor && jupyter && !ContextEditor", "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", - "ctrl-alt-enter": "repl::RunInPlace" - } + "ctrl-alt-enter": "repl::RunInPlace", + }, }, { "context": "Editor && !agent_diff", @@ -187,8 +187,8 @@ "bindings": { "ctrl-k ctrl-r": "git::Restore", "alt-y": "git::StageAndNext", - "shift-alt-y": "git::UnstageAndNext" - } + "shift-alt-y": "git::UnstageAndNext", + }, }, { "context": "Editor && editor_agent_diff", @@ -198,8 +198,8 @@ "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "ctrl-shift-r": "agent::OpenAgentDiff" - } + "ctrl-shift-r": "agent::OpenAgentDiff", + }, }, { "context": "AgentDiff", @@ -208,8 +208,8 @@ "ctrl-y": "agent::Keep", "ctrl-n": "agent::Reject", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "ContextEditor > Editor", @@ -225,8 +225,8 @@ "ctrl-k c": "assistant::CopyCode", "ctrl-g": "search::SelectNextMatch", "ctrl-shift-g": "search::SelectPreviousMatch", - "ctrl-k l": "agent::OpenRulesLibrary" - } + "ctrl-k l": "agent::OpenRulesLibrary", + }, }, { "context": "AgentPanel", @@ -251,38 +251,38 @@ "alt-enter": "agent::ContinueWithBurnMode", "shift-alt-a": "agent::AllowOnce", "ctrl-alt-y": "agent::AllowAlways", - "shift-alt-z": "agent::RejectOnce" - } + "shift-alt-z": "agent::RejectOnce", + }, }, { "context": "AgentPanel > NavigationMenu", "use_key_equivalents": true, "bindings": { - "shift-backspace": "agent::DeleteRecentlyOpenThread" - } + "shift-backspace": "agent::DeleteRecentlyOpenThread", + }, }, { "context": "AgentPanel > Markdown", "use_key_equivalents": true, "bindings": { - "ctrl-c": "markdown::CopyAsMarkdown" - } + "ctrl-c": "markdown::CopyAsMarkdown", + }, }, { "context": "AgentPanel && text_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewTextThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "AgentPanel && acp_thread", "use_key_equivalents": true, "bindings": { "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread" - } + "ctrl-alt-t": "agent::NewThread", + }, }, { "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", @@ -293,8 +293,8 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", @@ -305,8 +305,8 @@ "ctrl-i": "agent::ToggleProfileSelector", "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", - "ctrl-shift-n": "agent::RejectAll" - } + "ctrl-shift-n": "agent::RejectAll", + }, }, { "context": "EditMessageEditor > Editor", @@ -314,8 +314,8 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AgentFeedbackMessageEditor > Editor", @@ -323,14 +323,14 @@ "bindings": { "escape": "menu::Cancel", "enter": "menu::Confirm", - "alt-enter": "editor::Newline" - } + "alt-enter": "editor::Newline", + }, }, { "context": "AcpThread > ModeSelector", "bindings": { - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "AcpThread > Editor && !use_modifier_to_send", @@ -340,8 +340,8 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "AcpThread > Editor && use_modifier_to_send", @@ -351,15 +351,15 @@ "ctrl-shift-r": "agent::OpenAgentDiff", "ctrl-shift-y": "agent::KeepAll", "ctrl-shift-n": "agent::RejectAll", - "shift-tab": "agent::CycleModeSelector" - } + "shift-tab": "agent::CycleModeSelector", + }, }, { "context": "ThreadHistory", "use_key_equivalents": true, "bindings": { - "backspace": "agent::RemoveSelectedThread" - } + "backspace": "agent::RemoveSelectedThread", + }, }, { "context": "RulesLibrary", @@ -367,8 +367,8 @@ "bindings": { "ctrl-n": "rules_library::NewRule", "ctrl-shift-s": "rules_library::ToggleDefaultRule", - "ctrl-w": "workspace::CloseWindow" - } + "ctrl-w": "workspace::CloseWindow", + }, }, { "context": "BufferSearchBar", @@ -381,24 +381,24 @@ "alt-enter": "search::SelectAllMatches", "ctrl-f": "search::FocusSearch", "ctrl-h": "search::ToggleReplace", - "ctrl-l": "search::ToggleSelection" - } + "ctrl-l": "search::ToggleSelection", + }, }, { "context": "BufferSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-enter": "search::ReplaceAll" - } + "ctrl-enter": "search::ReplaceAll", + }, }, { "context": "BufferSearchBar && !in_replace > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar", @@ -407,24 +407,24 @@ "escape": "project_search::ToggleFocus", "ctrl-shift-f": "search::FocusSearch", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "ProjectSearchBar > Editor", "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", - "down": "search::NextHistoryQuery" - } + "down": "search::NextHistoryQuery", + }, }, { "context": "ProjectSearchBar && in_replace > Editor", "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", - "ctrl-alt-enter": "search::ReplaceAll" - } + "ctrl-alt-enter": "search::ReplaceAll", + }, }, { "context": "ProjectSearchView", @@ -432,8 +432,8 @@ "bindings": { "escape": "project_search::ToggleFocus", "ctrl-shift-h": "search::ToggleReplace", - "alt-r": "search::ToggleRegex" // vscode - } + "alt-r": "search::ToggleRegex", // vscode + }, }, { "context": "Pane", @@ -480,8 +480,8 @@ "shift-enter": "project_search::ToggleAllSearchResults", "alt-r": "search::ToggleRegex", // "ctrl-shift-alt-x": "search::ToggleRegex", - "ctrl-k shift-enter": "pane::TogglePinTab" - } + "ctrl-k shift-enter": "pane::TogglePinTab", + }, }, // Bindings from VS Code { @@ -542,31 +542,31 @@ "ctrl-\\": "pane::SplitRight", "alt-.": "editor::GoToHunk", "alt-,": "editor::GoToPreviousHunk", - } + }, }, { "context": "Editor && extension == md", "use_key_equivalents": true, "bindings": { "ctrl-k v": "markdown::OpenPreviewToTheSide", - "ctrl-shift-v": "markdown::OpenPreview" - } + "ctrl-shift-v": "markdown::OpenPreview", + }, }, { "context": "Editor && extension == svg", "use_key_equivalents": true, "bindings": { "ctrl-k v": "svg::OpenPreviewToTheSide", - "ctrl-shift-v": "svg::OpenPreview" - } + "ctrl-shift-v": "svg::OpenPreview", + }, }, { "context": "Editor && mode == full", "use_key_equivalents": true, "bindings": { "ctrl-shift-o": "outline::Toggle", - "ctrl-g": "go_to_line::Toggle" - } + "ctrl-g": "go_to_line::Toggle", + }, }, { "context": "Workspace", @@ -650,22 +650,22 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], "f5": "debugger::Rerun", "ctrl-f4": "workspace::CloseActiveDock", - "ctrl-w": "workspace::CloseActiveDock" - } + "ctrl-w": "workspace::CloseActiveDock", + }, }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, "bindings": { - "f5": "zed::NoAction" - } + "f5": "zed::NoAction", + }, }, { "context": "Workspace && debugger_stopped", "use_key_equivalents": true, "bindings": { - "f5": "debugger::Continue" - } + "f5": "debugger::Continue", + }, }, { "context": "ApplicationMenu", @@ -673,8 +673,8 @@ "bindings": { "f10": "menu::Cancel", "left": "app_menu::ActivateMenuLeft", - "right": "app_menu::ActivateMenuRight" - } + "right": "app_menu::ActivateMenuRight", + }, }, // Bindings from Sublime Text { @@ -691,8 +691,8 @@ "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", "ctrl-alt-right": "editor::MoveToNextSubwordEnd", "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", - "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" - } + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd", + }, }, // Bindings from Atom { @@ -702,16 +702,16 @@ "ctrl-k up": "pane::SplitUp", "ctrl-k down": "pane::SplitDown", "ctrl-k left": "pane::SplitLeft", - "ctrl-k right": "pane::SplitRight" - } + "ctrl-k right": "pane::SplitRight", + }, }, // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmRename" - } + "enter": "editor::ConfirmRename", + }, }, { "context": "Editor && showing_completions", @@ -719,22 +719,22 @@ "bindings": { "enter": "editor::ConfirmCompletion", "shift-enter": "editor::ConfirmCompletionReplace", - "tab": "editor::ComposeCompletion" - } + "tab": "editor::ComposeCompletion", + }, }, { "context": "Editor && in_snippet && has_next_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "tab": "editor::NextSnippetTabstop" - } + "tab": "editor::NextSnippetTabstop", + }, }, { "context": "Editor && in_snippet && has_previous_tabstop && !showing_completions", "use_key_equivalents": true, "bindings": { - "shift-tab": "editor::PreviousSnippetTabstop" - } + "shift-tab": "editor::PreviousSnippetTabstop", + }, }, // Bindings for accepting edit predictions // @@ -747,8 +747,8 @@ "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", "tab": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Editor && edit_prediction_conflict", @@ -756,15 +756,15 @@ "bindings": { "alt-tab": "editor::AcceptEditPrediction", "alt-l": "editor::AcceptEditPrediction", - "alt-right": "editor::AcceptPartialEditPrediction" - } + "alt-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Editor && showing_code_actions", "use_key_equivalents": true, "bindings": { - "enter": "editor::ConfirmCodeAction" - } + "enter": "editor::ConfirmCodeAction", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", @@ -775,16 +775,16 @@ "ctrl-n": "editor::ContextMenuNext", "down": "editor::ContextMenuNext", "pageup": "editor::ContextMenuFirst", - "pagedown": "editor::ContextMenuLast" - } + "pagedown": "editor::ContextMenuLast", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "use_key_equivalents": true, "bindings": { "up": "editor::SignatureHelpPrevious", - "down": "editor::SignatureHelpNext" - } + "down": "editor::SignatureHelpNext", + }, }, // Custom bindings { @@ -792,15 +792,15 @@ "bindings": { "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", // Only available in debug builds: opens an element inspector for development. - "shift-alt-i": "dev::ToggleInspector" - } + "shift-alt-i": "dev::ToggleInspector", + }, }, { "context": "!Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-shift-c": "collab_panel::ToggleFocus" - } + "ctrl-shift-c": "collab_panel::ToggleFocus", + }, }, { "context": "!ContextEditor > Editor && mode == full", @@ -813,8 +813,8 @@ "ctrl-f8": "editor::GoToHunk", "ctrl-shift-f8": "editor::GoToPreviousHunk", "ctrl-enter": "assistant::InlineAssist", - "ctrl-shift-;": "editor::ToggleInlayHints" - } + "ctrl-shift-;": "editor::ToggleInlayHints", + }, }, { "context": "PromptEditor", @@ -823,8 +823,8 @@ "ctrl-[": "agent::CyclePreviousInlineAssist", "ctrl-]": "agent::CycleNextInlineAssist", "ctrl-shift-enter": "inline_assistant::ThumbsUpResult", - "ctrl-shift-delete": "inline_assistant::ThumbsDownResult" - } + "ctrl-shift-delete": "inline_assistant::ThumbsDownResult", + }, }, { "context": "Prompt", @@ -833,15 +833,15 @@ "left": "menu::SelectPrevious", "right": "menu::SelectNext", "h": "menu::SelectPrevious", - "l": "menu::SelectNext" - } + "l": "menu::SelectNext", + }, }, { "context": "ProjectSearchBar && !in_replace", "use_key_equivalents": true, "bindings": { - "ctrl-enter": "project_search::SearchInNew" - } + "ctrl-enter": "project_search::SearchInNew", + }, }, { "context": "OutlinePanel && not_editing", @@ -856,8 +856,8 @@ "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", "alt-enter": "editor::OpenExcerpts", - "ctrl-alt-enter": "editor::OpenExcerptsSplit" - } + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + }, }, { "context": "ProjectPanel", @@ -888,15 +888,15 @@ "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrevious", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ProjectPanel && not_editing", "use_key_equivalents": true, "bindings": { - "space": "project_panel::Open" - } + "space": "project_panel::Open", + }, }, { "context": "GitPanel && ChangesList", @@ -917,15 +917,15 @@ "backspace": ["git::RestoreFile", { "skip_prompt": false }], "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], - "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] - } + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }], + }, }, { "context": "GitPanel && CommitEditor", "use_key_equivalents": true, "bindings": { - "escape": "git::Cancel" - } + "escape": "git::Cancel", + }, }, { "context": "GitCommit > Editor", @@ -935,8 +935,8 @@ "enter": "editor::Newline", "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "GitPanel", @@ -953,8 +953,8 @@ "ctrl-space": "git::StageAll", "ctrl-shift-space": "git::UnstageAll", "ctrl-enter": "git::Commit", - "ctrl-shift-enter": "git::Amend" - } + "ctrl-shift-enter": "git::Amend", + }, }, { "context": "GitDiff > Editor", @@ -963,15 +963,15 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "ctrl-space": "git::StageAll", - "ctrl-shift-space": "git::UnstageAll" - } + "ctrl-shift-space": "git::UnstageAll", + }, }, { "context": "AskPass > Editor", "use_key_equivalents": true, "bindings": { - "enter": "menu::Confirm" - } + "enter": "menu::Confirm", + }, }, { "context": "CommitEditor > Editor", @@ -984,8 +984,8 @@ "ctrl-enter": "git::Commit", "ctrl-shift-enter": "git::Amend", "alt-up": "git_panel::FocusChanges", - "alt-l": "git::GenerateCommitMessage" - } + "alt-l": "git::GenerateCommitMessage", + }, }, { "context": "DebugPanel", @@ -993,8 +993,8 @@ "bindings": { "ctrl-t": "debugger::ToggleThreadPicker", "ctrl-i": "debugger::ToggleSessionPicker", - "shift-alt-escape": "debugger::ToggleExpandItem" - } + "shift-alt-escape": "debugger::ToggleExpandItem", + }, }, { "context": "VariableList", @@ -1007,8 +1007,8 @@ "ctrl-alt-c": "variable_list::CopyVariableName", "delete": "variable_list::RemoveWatch", "backspace": "variable_list::RemoveWatch", - "alt-enter": "variable_list::AddWatch" - } + "alt-enter": "variable_list::AddWatch", + }, }, { "context": "BreakpointList", @@ -1017,16 +1017,16 @@ "space": "debugger::ToggleEnableBreakpoint", "backspace": "debugger::UnsetBreakpoint", "left": "debugger::PreviousBreakpointProperty", - "right": "debugger::NextBreakpointProperty" - } + "right": "debugger::NextBreakpointProperty", + }, }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", - "space": "menu::Confirm" - } + "space": "menu::Confirm", + }, }, { "context": "CollabPanel", @@ -1034,22 +1034,22 @@ "bindings": { "alt-up": "collab_panel::MoveChannelUp", "alt-down": "collab_panel::MoveChannelDown", - "alt-enter": "collab_panel::OpenSelectedChannelNotes" - } + "alt-enter": "collab_panel::OpenSelectedChannelNotes", + }, }, { "context": "(CollabPanel && editing) > Editor", "use_key_equivalents": true, "bindings": { - "space": "collab_panel::InsertSpace" - } + "space": "collab_panel::InsertSpace", + }, }, { "context": "ChannelModal", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "Picker > Editor", @@ -1059,22 +1059,22 @@ "up": "menu::SelectPrevious", "down": "menu::SelectNext", "tab": "picker::ConfirmCompletion", - "alt-enter": ["picker::ConfirmInput", { "secondary": false }] - } + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + }, }, { "context": "ChannelModal > Picker > Editor", "use_key_equivalents": true, "bindings": { - "tab": "channel_modal::ToggleMode" - } + "tab": "channel_modal::ToggleMode", + }, }, { "context": "ToolchainSelector", "use_key_equivalents": true, "bindings": { - "ctrl-shift-a": "toolchain::AddToolchain" - } + "ctrl-shift-a": "toolchain::AddToolchain", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor)", @@ -1082,8 +1082,8 @@ "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" - } + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + }, }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", @@ -1093,8 +1093,8 @@ "ctrl-j": "pane::SplitDown", "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", - "ctrl-l": "pane::SplitRight" - } + "ctrl-l": "pane::SplitRight", + }, }, { "context": "TabSwitcher", @@ -1103,16 +1103,16 @@ "ctrl-shift-tab": "menu::SelectPrevious", "ctrl-up": "menu::SelectPrevious", "ctrl-down": "menu::SelectNext", - "ctrl-backspace": "tab_switcher::CloseSelectedItem" - } + "ctrl-backspace": "tab_switcher::CloseSelectedItem", + }, }, { "context": "StashList || (StashList > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "stash_picker::DropStashItem", - "ctrl-shift-v": "stash_picker::ShowStashItem" - } + "ctrl-shift-v": "stash_picker::ShowStashItem", + }, }, { "context": "Terminal", @@ -1159,21 +1159,21 @@ "ctrl-shift-r": "terminal::RerunTask", "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", - "ctrl-shift-5": "pane::SplitRight" - } + "ctrl-shift-5": "pane::SplitRight", + }, }, { "context": "Terminal && selection", "bindings": { - "ctrl-c": "terminal::Copy" - } + "ctrl-c": "terminal::Copy", + }, }, { "context": "ZedPredictModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "ConfigureContextServerModal > Editor", @@ -1181,45 +1181,45 @@ "bindings": { "escape": "menu::Cancel", "enter": "editor::Newline", - "ctrl-enter": "menu::Confirm" - } + "ctrl-enter": "menu::Confirm", + }, }, { "context": "ContextServerToolsModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "OnboardingAiConfigurationModal", "use_key_equivalents": true, "bindings": { - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "Diagnostics", "use_key_equivalents": true, "bindings": { - "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" - } + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh", + }, }, { "context": "DebugConsole > Editor", "use_key_equivalents": true, "bindings": { "enter": "menu::Confirm", - "alt-enter": "console::WatchExpression" - } + "alt-enter": "console::WatchExpression", + }, }, { "context": "RunModal", "use_key_equivalents": true, "bindings": { "ctrl-tab": "pane::ActivateNextItem", - "ctrl-shift-tab": "pane::ActivatePreviousItem" - } + "ctrl-shift-tab": "pane::ActivatePreviousItem", + }, }, { "context": "MarkdownPreview", @@ -1230,8 +1230,8 @@ "up": "markdown::ScrollUp", "down": "markdown::ScrollDown", "alt-up": "markdown::ScrollUpByItem", - "alt-down": "markdown::ScrollDownByItem" - } + "alt-down": "markdown::ScrollDownByItem", + }, }, { "context": "KeymapEditor", @@ -1244,8 +1244,8 @@ "alt-enter": "keymap_editor::CreateBinding", "ctrl-c": "keymap_editor::CopyAction", "ctrl-shift-c": "keymap_editor::CopyContext", - "ctrl-t": "keymap_editor::ShowMatchingKeybinds" - } + "ctrl-t": "keymap_editor::ShowMatchingKeybinds", + }, }, { "context": "KeystrokeInput", @@ -1253,24 +1253,24 @@ "bindings": { "enter": "keystroke_input::StartRecording", "escape escape escape": "keystroke_input::StopRecording", - "delete": "keystroke_input::ClearKeystrokes" - } + "delete": "keystroke_input::ClearKeystrokes", + }, }, { "context": "KeybindEditorModal", "use_key_equivalents": true, "bindings": { "ctrl-enter": "menu::Confirm", - "escape": "menu::Cancel" - } + "escape": "menu::Cancel", + }, }, { "context": "KeybindEditorModal > Editor", "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrevious", - "down": "menu::SelectNext" - } + "down": "menu::SelectNext", + }, }, { "context": "Onboarding", @@ -1282,8 +1282,8 @@ "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], "ctrl-enter": "onboarding::Finish", "alt-shift-l": "onboarding::SignIn", - "shift-alt-a": "onboarding::OpenAccount" - } + "shift-alt-a": "onboarding::OpenAccount", + }, }, { "context": "Welcome", @@ -1292,16 +1292,16 @@ "ctrl-=": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl-+": ["zed::IncreaseUiFontSize", { "persist": false }], "ctrl--": ["zed::DecreaseUiFontSize", { "persist": false }], - "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }] - } + "ctrl-0": ["zed::ResetUiFontSize", { "persist": false }], + }, }, { "context": "GitWorktreeSelector || (GitWorktreeSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-space": "git::WorktreeFromDefaultOnWindow", - "ctrl-space": "git::WorktreeFromDefault" - } + "ctrl-space": "git::WorktreeFromDefault", + }, }, { "context": "SettingsWindow", @@ -1326,8 +1326,8 @@ "ctrl-9": ["settings_editor::FocusFile", 8], "ctrl-0": ["settings_editor::FocusFile", 9], "ctrl-pageup": "settings_editor::FocusPreviousFile", - "ctrl-pagedown": "settings_editor::FocusNextFile" - } + "ctrl-pagedown": "settings_editor::FocusNextFile", + }, }, { "context": "StashDiff > Editor", @@ -1335,8 +1335,8 @@ "bindings": { "ctrl-space": "git::ApplyCurrentStash", "ctrl-shift-space": "git::PopCurrentStash", - "ctrl-shift-backspace": "git::DropCurrentStash" - } + "ctrl-shift-backspace": "git::DropCurrentStash", + }, }, { "context": "SettingsWindow > NavigationMenu", @@ -1351,22 +1351,22 @@ "pageup": "settings_editor::FocusPreviousRootNavEntry", "pagedown": "settings_editor::FocusNextRootNavEntry", "home": "settings_editor::FocusFirstNavEntry", - "end": "settings_editor::FocusLastNavEntry" - } + "end": "settings_editor::FocusLastNavEntry", + }, }, { "context": "EditPredictionContext > Editor", "bindings": { "alt-left": "dev::EditPredictionContextGoBack", - "alt-right": "dev::EditPredictionContextGoForward" - } + "alt-right": "dev::EditPredictionContextGoForward", + }, }, { "context": "GitBranchSelector || (GitBranchSelector > Picker > Editor)", "use_key_equivalents": true, "bindings": { "ctrl-shift-backspace": "branch_picker::DeleteBranch", - "ctrl-shift-i": "branch_picker::FilterRemotes" - } - } + "ctrl-shift-i": "branch_picker::FilterRemotes", + }, + }, ] diff --git a/assets/keymaps/initial.json b/assets/keymaps/initial.json index 8e4fe59f44ea7346a51e1c064ffa0553315da3b9..3a8d7f382aa57b39efc22845a17a4ef1bfd240ef 100644 --- a/assets/keymaps/initial.json +++ b/assets/keymaps/initial.json @@ -10,12 +10,12 @@ "context": "Workspace", "bindings": { // "shift shift": "file_finder::Toggle" - } + }, }, { "context": "Editor && vim_mode == insert", "bindings": { // "j k": "vim::NormalBefore" - } - } + }, + }, ] diff --git a/assets/keymaps/linux/atom.json b/assets/keymaps/linux/atom.json index 98992b19fac72055807063edae8b7b23652062d3..a15d4877aab79ac2e570697137ba89e3572d074e 100644 --- a/assets/keymaps/linux/atom.json +++ b/assets/keymaps/linux/atom.json @@ -4,15 +4,15 @@ "bindings": { "ctrl-shift-f5": "workspace::Reload", // window:reload "ctrl-k ctrl-n": "workspace::ActivatePreviousPane", // window:focus-next-pane - "ctrl-k ctrl-p": "workspace::ActivateNextPane" // window:focus-previous-pane - } + "ctrl-k ctrl-p": "workspace::ActivateNextPane", // window:focus-previous-pane + }, }, { "context": "Editor", "bindings": { "ctrl-k ctrl-u": "editor::ConvertToUpperCase", // editor:upper-case - "ctrl-k ctrl-l": "editor::ConvertToLowerCase" // editor:lower-case - } + "ctrl-k ctrl-l": "editor::ConvertToLowerCase", // editor:lower-case + }, }, { "context": "Editor && mode == full", @@ -32,8 +32,8 @@ "ctrl-down": "editor::MoveLineDown", // editor:move-line-down "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-shift-m": "markdown::OpenPreviewToTheSide", // markdown-preview:toggle - "ctrl-r": "outline::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "outline::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "BufferSearchBar", @@ -41,8 +41,8 @@ "f3": ["editor::SelectNext", { "replace_newest": true }], // find-and-replace:find-next "shift-f3": ["editor::SelectPrevious", { "replace_newest": true }], //find-and-replace:find-previous "ctrl-f3": "search::SelectNextMatch", // find-and-replace:find-next-selected - "ctrl-shift-f3": "search::SelectPreviousMatch" // find-and-replace:find-previous-selected - } + "ctrl-shift-f3": "search::SelectPreviousMatch", // find-and-replace:find-previous-selected + }, }, { "context": "Workspace", @@ -50,8 +50,8 @@ "ctrl-\\": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // tree-view:toggle "ctrl-t": "file_finder::Toggle", // fuzzy-finder:toggle-file-finder - "ctrl-r": "project_symbols::Toggle" // symbols-view:toggle-project-symbols - } + "ctrl-r": "project_symbols::Toggle", // symbols-view:toggle-project-symbols + }, }, { "context": "Pane", @@ -65,8 +65,8 @@ "ctrl-6": ["pane::ActivateItem", 5], // tree-view:open-selected-entry-in-pane-6 "ctrl-7": ["pane::ActivateItem", 6], // tree-view:open-selected-entry-in-pane-7 "ctrl-8": ["pane::ActivateItem", 7], // tree-view:open-selected-entry-in-pane-8 - "ctrl-9": ["pane::ActivateItem", 8] // tree-view:open-selected-entry-in-pane-9 - } + "ctrl-9": ["pane::ActivateItem", 8], // tree-view:open-selected-entry-in-pane-9 + }, }, { "context": "ProjectPanel", @@ -75,8 +75,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "ctrl-x": "project_panel::Cut", // tree-view:cut "ctrl-c": "project_panel::Copy", // tree-view:copy - "ctrl-v": "project_panel::Paste" // tree-view:paste - } + "ctrl-v": "project_panel::Paste", // tree-view:paste + }, }, { "context": "ProjectPanel && not_editing", @@ -90,7 +90,7 @@ "d": "project_panel::Duplicate", // tree-view:duplicate "home": "menu::SelectFirst", // core:move-to-top "end": "menu::SelectLast", // core:move-to-bottom - "shift-a": "project_panel::NewDirectory" // tree-view:add-folder - } - } + "shift-a": "project_panel::NewDirectory", // tree-view:add-folder + }, + }, ] diff --git a/assets/keymaps/linux/cursor.json b/assets/keymaps/linux/cursor.json index 4d2d13a90d96c31f72b1bb0ccc74608f81004eda..53f38234bb47a0f7c4412bf767e3eedf0465ba2a 100644 --- a/assets/keymaps/linux/cursor.json +++ b/assets/keymaps/linux/cursor.json @@ -8,8 +8,8 @@ "ctrl-shift-i": "agent::ToggleFocus", "ctrl-l": "agent::ToggleFocus", "ctrl-shift-l": "agent::ToggleFocus", - "ctrl-shift-j": "agent::OpenSettings" - } + "ctrl-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,18 +20,18 @@ "ctrl-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "ctrl-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "ctrl-k": "assistant::InlineAssist", - "ctrl-shift-k": "assistant::InsertIntoEditor" - } + "ctrl-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { - "ctrl-shift-backspace": "editor::Cancel" + "ctrl-shift-backspace": "editor::Cancel", // "alt-enter": // Quick Question // "ctrl-shift-enter": // Full File Context // "ctrl-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -47,7 +47,7 @@ "ctrl-shift-backspace": "editor::Cancel", "ctrl-r": "agent::NewThread", "ctrl-shift-v": "editor::Paste", - "ctrl-shift-k": "assistant::InsertIntoEditor" + "ctrl-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "ctrl-t": // new thread tab @@ -56,28 +56,28 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "ctrl-enter": "agent::KeepAll", - "ctrl-backspace": "agent::RejectAll" - } + "ctrl-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "ctrl-right": "editor::AcceptPartialEditPrediction" - } + "ctrl-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "ctrl-k": "assistant::InlineAssist" - } - } + "ctrl-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index c5cf22c81220bf286187252394f8fde26bdd6509..5b6f841de07ac2f9bd45c73e032dea0ede409007 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -5,8 +5,8 @@ [ { "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -18,8 +18,8 @@ "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel "ctrl-x": null, // currently activates `editor::Cut` if no following key is pressed for 1 second "ctrl-p": null, // currently activates `file_finder::Toggle` when the cursor is on the first character of the buffer - "ctrl-n": null // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer - } + "ctrl-n": null, // currently activates `workspace::NewFile` when the cursor is on the last character of the buffer + }, }, { "context": "Editor", @@ -82,8 +82,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -119,22 +119,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -164,8 +164,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -185,22 +185,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index a0314c5bc1dd59c17f3f132db804891ef1df0d4e..3a54c92bf33decd968ee8d711fb1a929534ded21 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -62,8 +62,8 @@ "ctrl-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "ctrl-shift-u": "editor::ToggleCase" - } + "ctrl-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", @@ -76,14 +76,14 @@ "ctrl-space": "editor::ShowCompletions", "ctrl-q": "editor::Hover", "ctrl-p": "editor::ShowSignatureHelp", - "ctrl-\\": "assistant::InlineAssist" - } + "ctrl-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -91,8 +91,8 @@ "alt-c": "search::ToggleCaseSensitive", "alt-e": "search::ToggleSelection", "alt-x": "search::ToggleRegex", - "alt-w": "search::ToggleWholeWord" - } + "alt-w": "search::ToggleWholeWord", + }, }, { "context": "Workspace", @@ -114,8 +114,8 @@ "alt-1": "project_panel::ToggleFocus", "alt-5": "debug_panel::ToggleFocus", "alt-6": "diagnostics::Deploy", - "alt-7": "outline_panel::ToggleFocus" - } + "alt-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -129,15 +129,15 @@ "alt-7": "outline_panel::ToggleFocus", "alt-8": null, // Services (bottom dock) "alt-9": null, // Git History (bottom dock) - "alt-0": "git_panel::ToggleFocus" - } + "alt-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "ctrl-shift-k": "git::Push" - } + "ctrl-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -145,8 +145,8 @@ "ctrl-alt-left": "pane::GoBack", "ctrl-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -156,8 +156,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -167,8 +167,8 @@ "ctrl-up": "terminal::ScrollLineUp", "ctrl-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "alt-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "alt-1": "workspace::CloseActiveDock" } }, @@ -179,7 +179,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/linux/sublime_text.json b/assets/keymaps/linux/sublime_text.json index eefd59e5bd1aa48125d0c6e3d662f3cb4e270be7..1d689a6f5841a011768113257afbed2c447669ed 100644 --- a/assets/keymaps/linux/sublime_text.json +++ b/assets/keymaps/linux/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -55,20 +55,20 @@ "alt-right": "editor::MoveToNextSubwordEnd", "alt-left": "editor::MoveToPreviousSubwordStart", "alt-shift-right": "editor::SelectToNextSubwordEnd", - "alt-shift-left": "editor::SelectToPreviousSubwordStart" - } + "alt-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "ctrl-r": "outline::Toggle" - } + "ctrl-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "ctrl-k ctrl-z": "git::Restore" - } + "ctrl-k ctrl-z": "git::Restore", + }, }, { "context": "Pane", @@ -83,15 +83,15 @@ "alt-6": ["pane::ActivateItem", 5], "alt-7": ["pane::ActivateItem", 6], "alt-8": ["pane::ActivateItem", 7], - "alt-9": "pane::ActivateLastItem" - } + "alt-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", "bindings": { "ctrl-k ctrl-b": "workspace::ToggleLeftDock", // "ctrl-0": "project_panel::ToggleFocus", // normally resets zoom - "shift-ctrl-r": "project_symbols::Toggle" - } - } + "shift-ctrl-r": "project_symbols::Toggle", + }, + }, ] diff --git a/assets/keymaps/macos/atom.json b/assets/keymaps/macos/atom.json index ca015b667faa05db53d8fdc3bd82352d9bcc62aa..bf049fd3cb3eca8fe8049fa4e0810f82b10a5bbc 100644 --- a/assets/keymaps/macos/atom.json +++ b/assets/keymaps/macos/atom.json @@ -4,16 +4,16 @@ "bindings": { "ctrl-alt-cmd-l": "workspace::Reload", "cmd-k cmd-p": "workspace::ActivatePreviousPane", - "cmd-k cmd-n": "workspace::ActivateNextPane" - } + "cmd-k cmd-n": "workspace::ActivateNextPane", + }, }, { "context": "Editor", "bindings": { "cmd-shift-backspace": "editor::DeleteToBeginningOfLine", "cmd-k cmd-u": "editor::ConvertToUpperCase", - "cmd-k cmd-l": "editor::ConvertToLowerCase" - } + "cmd-k cmd-l": "editor::ConvertToLowerCase", + }, }, { "context": "Editor && mode == full", @@ -33,8 +33,8 @@ "ctrl-cmd-down": "editor::MoveLineDown", "cmd-\\": "workspace::ToggleLeftDock", "ctrl-shift-m": "markdown::OpenPreviewToTheSide", - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "BufferSearchBar", @@ -42,8 +42,8 @@ "cmd-g": ["editor::SelectNext", { "replace_newest": true }], "cmd-shift-g": ["editor::SelectPrevious", { "replace_newest": true }], "cmd-f3": "search::SelectNextMatch", - "cmd-shift-f3": "search::SelectPreviousMatch" - } + "cmd-shift-f3": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", @@ -51,8 +51,8 @@ "cmd-\\": "workspace::ToggleLeftDock", "cmd-k cmd-b": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-r": "project_symbols::Toggle" - } + "cmd-shift-r": "project_symbols::Toggle", + }, }, { "context": "Pane", @@ -67,8 +67,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "ProjectPanel", @@ -77,8 +77,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "cmd-x": "project_panel::Cut", "cmd-c": "project_panel::Copy", - "cmd-v": "project_panel::Paste" - } + "cmd-v": "project_panel::Paste", + }, }, { "context": "ProjectPanel && not_editing", @@ -92,7 +92,7 @@ "d": "project_panel::Duplicate", "home": "menu::SelectFirst", "end": "menu::SelectLast", - "shift-a": "project_panel::NewDirectory" - } - } + "shift-a": "project_panel::NewDirectory", + }, + }, ] diff --git a/assets/keymaps/macos/cursor.json b/assets/keymaps/macos/cursor.json index 97abc7dd819485850107eca6762fc1ed60ec0515..6a2f46e0ce6d037de6de2d801d80671c63a3e3cd 100644 --- a/assets/keymaps/macos/cursor.json +++ b/assets/keymaps/macos/cursor.json @@ -8,8 +8,8 @@ "cmd-shift-i": "agent::ToggleFocus", "cmd-l": "agent::ToggleFocus", "cmd-shift-l": "agent::ToggleFocus", - "cmd-shift-j": "agent::OpenSettings" - } + "cmd-shift-j": "agent::OpenSettings", + }, }, { "context": "Editor && mode == full", @@ -20,19 +20,19 @@ "cmd-shift-l": "agent::AddSelectionToThread", // In cursor uses "Ask" mode "cmd-l": "agent::AddSelectionToThread", // In cursor uses "Agent" mode "cmd-k": "assistant::InlineAssist", - "cmd-shift-k": "assistant::InsertIntoEditor" - } + "cmd-shift-k": "assistant::InsertIntoEditor", + }, }, { "context": "InlineAssistEditor", "use_key_equivalents": true, "bindings": { "cmd-shift-backspace": "editor::Cancel", - "cmd-enter": "menu::Confirm" + "cmd-enter": "menu::Confirm", // "alt-enter": // Quick Question // "cmd-shift-enter": // Full File Context // "cmd-shift-k": // Toggle input focus (editor <> inline assist) - } + }, }, { "context": "AgentPanel || ContextEditor || (MessageEditor > Editor)", @@ -48,7 +48,7 @@ "cmd-shift-backspace": "editor::Cancel", "cmd-r": "agent::NewThread", "cmd-shift-v": "editor::Paste", - "cmd-shift-k": "assistant::InsertIntoEditor" + "cmd-shift-k": "assistant::InsertIntoEditor", // "escape": "agent::ToggleFocus" ///// Enable when Zed supports multiple thread tabs // "cmd-t": // new thread tab @@ -57,28 +57,28 @@ ///// Enable if Zed adds support for keyboard navigation of thread elements // "tab": // cycle to next message // "shift-tab": // cycle to previous message - } + }, }, { "context": "Editor && editor_agent_diff", "use_key_equivalents": true, "bindings": { "cmd-enter": "agent::KeepAll", - "cmd-backspace": "agent::RejectAll" - } + "cmd-backspace": "agent::RejectAll", + }, }, { "context": "Editor && mode == full && edit_prediction", "use_key_equivalents": true, "bindings": { - "cmd-right": "editor::AcceptPartialEditPrediction" - } + "cmd-right": "editor::AcceptPartialEditPrediction", + }, }, { "context": "Terminal", "use_key_equivalents": true, "bindings": { - "cmd-k": "assistant::InlineAssist" - } - } + "cmd-k": "assistant::InlineAssist", + }, + }, ] diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index ea831c0c059ea082d002f3af01b8d97be9e86616..2f11e2ce00e8b60a0f1c85b5aeb204e866491a45 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -6,8 +6,8 @@ { "context": "!GitPanel", "bindings": { - "ctrl-g": "menu::Cancel" - } + "ctrl-g": "menu::Cancel", + }, }, { // Workaround to avoid falling back to default bindings. @@ -15,8 +15,8 @@ // NOTE: must be declared before the `Editor` override. "context": "Editor", "bindings": { - "ctrl-g": null // currently activates `go_to_line::Toggle` when there is nothing to cancel - } + "ctrl-g": null, // currently activates `go_to_line::Toggle` when there is nothing to cancel + }, }, { "context": "Editor", @@ -79,8 +79,8 @@ "ctrl-s": "buffer_search::Deploy", // isearch-forward "ctrl-r": "buffer_search::Deploy", // isearch-backward "alt-^": "editor::JoinLines", // join-line - "alt-q": "editor::Rewrap" // fill-paragraph - } + "alt-q": "editor::Rewrap", // fill-paragraph + }, }, { "context": "Editor && selection_mode", // region selection @@ -116,22 +116,22 @@ "alt->": "editor::SelectToEnd", "ctrl-home": "editor::SelectToBeginning", "ctrl-end": "editor::SelectToEnd", - "ctrl-g": "editor::Cancel" - } + "ctrl-g": "editor::Cancel", + }, }, { "context": "Editor && (showing_code_actions || showing_completions)", "bindings": { "ctrl-p": "editor::ContextMenuPrevious", - "ctrl-n": "editor::ContextMenuNext" - } + "ctrl-n": "editor::ContextMenuNext", + }, }, { "context": "Editor && showing_signature_help && !showing_completions", "bindings": { "ctrl-p": "editor::SignatureHelpPrevious", - "ctrl-n": "editor::SignatureHelpNext" - } + "ctrl-n": "editor::SignatureHelpNext", + }, }, // Example setting for using emacs-style tab // (i.e. indent the current line / selection or perform symbol completion depending on context) @@ -161,8 +161,8 @@ "ctrl-x ctrl-f": "file_finder::Toggle", // find-file "ctrl-x ctrl-s": "workspace::Save", // save-buffer "ctrl-x ctrl-w": "workspace::SaveAs", // write-file - "ctrl-x s": "workspace::SaveAll" // save-some-buffers - } + "ctrl-x s": "workspace::SaveAll", // save-some-buffers + }, }, { // Workaround to enable using native emacs from the Zed terminal. @@ -182,22 +182,22 @@ "ctrl-x ctrl-f": null, // find-file "ctrl-x ctrl-s": null, // save-buffer "ctrl-x ctrl-w": null, // write-file - "ctrl-x s": null // save-some-buffers - } + "ctrl-x s": null, // save-some-buffers + }, }, { "context": "BufferSearchBar > Editor", "bindings": { "ctrl-s": "search::SelectNextMatch", "ctrl-r": "search::SelectPreviousMatch", - "ctrl-g": "buffer_search::Dismiss" - } + "ctrl-g": "buffer_search::Dismiss", + }, }, { "context": "Pane", "bindings": { "ctrl-alt-left": "pane::GoBack", - "ctrl-alt-right": "pane::GoForward" - } - } + "ctrl-alt-right": "pane::GoForward", + }, + }, ] diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 364f489167f5abb9af21d9f005586bde08439850..1721a9d743a67abddbc55a4b505be497920d15aa 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -13,8 +13,8 @@ "shift-f8": "debugger::StepOut", "f9": "debugger::Continue", "shift-f9": "debugger::Start", - "alt-shift-f9": "debugger::Start" - } + "alt-shift-f9": "debugger::Start", + }, }, { "context": "Editor", @@ -60,8 +60,8 @@ "cmd-shift-end": "editor::SelectToEnd", "ctrl-f8": "editor::ToggleBreakpoint", "ctrl-shift-f8": "editor::EditLogBreakpoint", - "cmd-shift-u": "editor::ToggleCase" - } + "cmd-shift-u": "editor::ToggleCase", + }, }, { "context": "Editor && mode == full", @@ -74,14 +74,14 @@ "ctrl-space": "editor::ShowCompletions", "cmd-j": "editor::Hover", "cmd-p": "editor::ShowSignatureHelp", - "cmd-\\": "assistant::InlineAssist" - } + "cmd-\\": "assistant::InlineAssist", + }, }, { "context": "BufferSearchBar", "bindings": { - "shift-enter": "search::SelectPreviousMatch" - } + "shift-enter": "search::SelectPreviousMatch", + }, }, { "context": "BufferSearchBar || ProjectSearchBar", @@ -93,8 +93,8 @@ "ctrl-alt-c": "search::ToggleCaseSensitive", "ctrl-alt-e": "search::ToggleSelection", "ctrl-alt-w": "search::ToggleWholeWord", - "ctrl-alt-x": "search::ToggleRegex" - } + "ctrl-alt-x": "search::ToggleRegex", + }, }, { "context": "Workspace", @@ -116,8 +116,8 @@ "cmd-1": "project_panel::ToggleFocus", "cmd-5": "debug_panel::ToggleFocus", "cmd-6": "diagnostics::Deploy", - "cmd-7": "outline_panel::ToggleFocus" - } + "cmd-7": "outline_panel::ToggleFocus", + }, }, { "context": "Pane", // this is to override the default Pane mappings to switch tabs @@ -131,15 +131,15 @@ "cmd-7": "outline_panel::ToggleFocus", "cmd-8": null, // Services (bottom dock) "cmd-9": null, // Git History (bottom dock) - "cmd-0": "git_panel::ToggleFocus" - } + "cmd-0": "git_panel::ToggleFocus", + }, }, { "context": "Workspace || Editor", "bindings": { "alt-f12": "terminal_panel::Toggle", - "cmd-shift-k": "git::Push" - } + "cmd-shift-k": "git::Push", + }, }, { "context": "Pane", @@ -147,8 +147,8 @@ "cmd-alt-left": "pane::GoBack", "cmd-alt-right": "pane::GoForward", "alt-left": "pane::ActivatePreviousItem", - "alt-right": "pane::ActivateNextItem" - } + "alt-right": "pane::ActivateNextItem", + }, }, { "context": "ProjectPanel", @@ -159,8 +159,8 @@ "backspace": ["project_panel::Trash", { "skip_prompt": false }], "delete": ["project_panel::Trash", { "skip_prompt": false }], "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], - "shift-f6": "project_panel::Rename" - } + "shift-f6": "project_panel::Rename", + }, }, { "context": "Terminal", @@ -170,8 +170,8 @@ "cmd-up": "terminal::ScrollLineUp", "cmd-down": "terminal::ScrollLineDown", "shift-pageup": "terminal::ScrollPageUp", - "shift-pagedown": "terminal::ScrollPageDown" - } + "shift-pagedown": "terminal::ScrollPageDown", + }, }, { "context": "GitPanel", "bindings": { "cmd-0": "workspace::CloseActiveDock" } }, { "context": "ProjectPanel", "bindings": { "cmd-1": "workspace::CloseActiveDock" } }, @@ -182,7 +182,7 @@ "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", - "shift-escape": "workspace::CloseActiveDock" - } - } + "shift-escape": "workspace::CloseActiveDock", + }, + }, ] diff --git a/assets/keymaps/macos/sublime_text.json b/assets/keymaps/macos/sublime_text.json index d1bffca755b611d9046d4b7e794d2303835227a2..f4ae1ce5dda4e2c0dd21e97bd3a411dd4a4f3663 100644 --- a/assets/keymaps/macos/sublime_text.json +++ b/assets/keymaps/macos/sublime_text.json @@ -22,8 +22,8 @@ "ctrl-^": ["workspace::MoveItemToPane", { "destination": 5 }], "ctrl-&": ["workspace::MoveItemToPane", { "destination": 6 }], "ctrl-*": ["workspace::MoveItemToPane", { "destination": 7 }], - "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }] - } + "ctrl-(": ["workspace::MoveItemToPane", { "destination": 8 }], + }, }, { "context": "Editor", @@ -57,20 +57,20 @@ "ctrl-right": "editor::MoveToNextSubwordEnd", "ctrl-left": "editor::MoveToPreviousSubwordStart", "ctrl-shift-right": "editor::SelectToNextSubwordEnd", - "ctrl-shift-left": "editor::SelectToPreviousSubwordStart" - } + "ctrl-shift-left": "editor::SelectToPreviousSubwordStart", + }, }, { "context": "Editor && mode == full", "bindings": { - "cmd-r": "outline::Toggle" - } + "cmd-r": "outline::Toggle", + }, }, { "context": "Editor && !agent_diff", "bindings": { - "cmd-k cmd-z": "git::Restore" - } + "cmd-k cmd-z": "git::Restore", + }, }, { "context": "Pane", @@ -85,8 +85,8 @@ "cmd-6": ["pane::ActivateItem", 5], "cmd-7": ["pane::ActivateItem", 6], "cmd-8": ["pane::ActivateItem", 7], - "cmd-9": "pane::ActivateLastItem" - } + "cmd-9": "pane::ActivateLastItem", + }, }, { "context": "Workspace", @@ -95,7 +95,7 @@ "cmd-t": "file_finder::Toggle", "shift-cmd-r": "project_symbols::Toggle", // Currently busted: https://github.com/zed-industries/feedback/issues/898 - "ctrl-0": "project_panel::ToggleFocus" - } - } + "ctrl-0": "project_panel::ToggleFocus", + }, + }, ] diff --git a/assets/keymaps/macos/textmate.json b/assets/keymaps/macos/textmate.json index f91f39b7f5c079f81b5fcf8e28e2092a33ff1aa4..90450e60af7147f1394eb6cb4c1efc389edad2d0 100644 --- a/assets/keymaps/macos/textmate.json +++ b/assets/keymaps/macos/textmate.json @@ -2,8 +2,8 @@ { "bindings": { "cmd-shift-o": "projects::OpenRecent", - "cmd-alt-tab": "project_panel::ToggleFocus" - } + "cmd-alt-tab": "project_panel::ToggleFocus", + }, }, { "context": "Editor && mode == full", @@ -15,8 +15,8 @@ "cmd-enter": "editor::NewlineBelow", "cmd-alt-enter": "editor::NewlineAbove", "cmd-shift-l": "editor::SelectLine", - "cmd-shift-t": "outline::Toggle" - } + "cmd-shift-t": "outline::Toggle", + }, }, { "context": "Editor", @@ -41,30 +41,30 @@ "ctrl-u": "editor::ConvertToUpperCase", "ctrl-shift-u": "editor::ConvertToLowerCase", "ctrl-alt-u": "editor::ConvertToUpperCamelCase", - "ctrl-_": "editor::ConvertToSnakeCase" - } + "ctrl-_": "editor::ConvertToSnakeCase", + }, }, { "context": "BufferSearchBar", "bindings": { "ctrl-s": "search::SelectNextMatch", - "ctrl-shift-s": "search::SelectPreviousMatch" - } + "ctrl-shift-s": "search::SelectPreviousMatch", + }, }, { "context": "Workspace", "bindings": { "cmd-alt-ctrl-d": "workspace::ToggleLeftDock", "cmd-t": "file_finder::Toggle", - "cmd-shift-t": "project_symbols::Toggle" - } + "cmd-shift-t": "project_symbols::Toggle", + }, }, { "context": "Pane", "bindings": { "alt-cmd-r": "search::ToggleRegex", - "ctrl-tab": "project_panel::ToggleFocus" - } + "ctrl-tab": "project_panel::ToggleFocus", + }, }, { "context": "ProjectPanel", @@ -75,11 +75,11 @@ "return": "project_panel::Rename", "cmd-c": "project_panel::Copy", "cmd-v": "project_panel::Paste", - "cmd-alt-c": "project_panel::CopyPath" - } + "cmd-alt-c": "project_panel::CopyPath", + }, }, { "context": "Dock", - "bindings": {} - } + "bindings": {}, + }, ] diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json index 9b92fbe1a3844043e379647d1dd6c57e082fdf77..432bdc7004a4c66b52e20282aba924611b204aa1 100644 --- a/assets/keymaps/storybook.json +++ b/assets/keymaps/storybook.json @@ -27,7 +27,7 @@ "backspace": "editor::Backspace", "delete": "editor::Delete", "left": "editor::MoveLeft", - "right": "editor::MoveRight" - } - } + "right": "editor::MoveRight", + }, + }, ] diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json index af4512bd51aa82d57ce62e605b45ee61e8f98030..851289392a65aecfca17e00d4c123823ac9e21cb 100644 --- a/assets/settings/initial_debug_tasks.json +++ b/assets/settings/initial_debug_tasks.json @@ -8,7 +8,7 @@ "adapter": "Debugpy", "program": "$ZED_FILE", "request": "launch", - "cwd": "$ZED_WORKTREE_ROOT" + "cwd": "$ZED_WORKTREE_ROOT", }, { "label": "Debug active JavaScript file", @@ -16,7 +16,7 @@ "program": "$ZED_FILE", "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", - "type": "pwa-node" + "type": "pwa-node", }, { "label": "JavaScript debug terminal", @@ -24,6 +24,6 @@ "request": "launch", "cwd": "$ZED_WORKTREE_ROOT", "console": "integratedTerminal", - "type": "pwa-node" - } + "type": "pwa-node", + }, ] diff --git a/assets/settings/initial_server_settings.json b/assets/settings/initial_server_settings.json index d6ec33e60128380378610a273a1bbdff1ecdbaa8..29aa569b105157df7ec48164e2066fdac72c7b41 100644 --- a/assets/settings/initial_server_settings.json +++ b/assets/settings/initial_server_settings.json @@ -3,5 +3,5 @@ // For a full list of overridable settings, and general information on settings, // see the documentation: https://zed.dev/docs/configuring-zed#settings-files { - "lsp": {} + "lsp": {}, } diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79e98063237ca297a89b0d151bd48149061b7bb..5bedafbd3a1e75a755598e37cd673742e146fdcc 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -47,8 +47,8 @@ // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_command": true + "show_command": true, // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. // "tags": [] - } + }, ] diff --git a/assets/settings/initial_user_settings.json b/assets/settings/initial_user_settings.json index 5ac2063bdb481e057a2d124c1e72f998390b066b..8b573854895a03243803a71a91a35af647f45ca2 100644 --- a/assets/settings/initial_user_settings.json +++ b/assets/settings/initial_user_settings.json @@ -12,6 +12,6 @@ "theme": { "mode": "system", "light": "One Light", - "dark": "One Dark" - } + "dark": "One Dark", + }, } From 632bd378ba711022953e9960c0fdb1501ef7e2d7 Mon Sep 17 00:00:00 2001 From: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:31:15 +0100 Subject: [PATCH 61/67] git_ui: Reset the project diff at the start when it is deployed again (#43579) Closes #26920 Release Notes: - Clicking the 'changes' button now resets the diff at the beginning --------- Signed-off-by: Marco Mihai Condrache <52580954+marcocondrache@users.noreply.github.com> --- crates/git_ui/src/project_diff.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 3f689567327e280f7e9911699e10159340ddb8d5..4d7a27354b1b4b6e972579e73c48bcd4c2448a5c 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -156,6 +156,10 @@ impl ProjectDiff { .items_of_type::(cx) .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); let project_diff = if let Some(existing) = existing { + existing.update(cx, |project_diff, cx| { + project_diff.move_to_beginning(window, cx); + }); + workspace.activate_item(&existing, true, true, window, cx); existing } else { @@ -365,6 +369,14 @@ impl ProjectDiff { }) } + fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |editor, cx| { + editor.primary_editor().update(cx, |editor, cx| { + editor.move_to_beginning(&Default::default(), window, cx); + }); + }); + } + fn move_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context) { if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) { self.editor.update(cx, |editor, cx| { From 03216c9800d4155d0642a63641800e36572ae7a2 Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Mon, 15 Dec 2025 21:02:13 +0530 Subject: [PATCH 62/67] git_ui: Display correct provider for view on remote button (#44738) Closes #44729 Release Notes: - Fixed incorrect provider shown in "view on remote" button --- crates/git_ui/src/commit_view.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index b83ad6d8a6ddab467eb32c31cbc67810b9f74247..8cb9d82826086371950d2c51fd06381dd013251f 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -391,14 +391,16 @@ impl CommitView { time_format::TimestampFormat::MediumAbsolute, ); - let github_url = self.remote.as_ref().map(|remote| { - format!( + let remote_info = self.remote.as_ref().map(|remote| { + let provider = remote.host.name(); + let url = format!( "{}/{}/{}/commit/{}", remote.host.base_url(), remote.owner, remote.repo, commit.sha - ) + ); + (provider, url) }); let (additions, deletions) = self.calculate_changed_lines(cx); @@ -472,9 +474,14 @@ impl CommitView { .children(commit_diff_stat), ), ) - .children(github_url.map(|url| { - Button::new("view_on_github", "View on GitHub") - .icon(IconName::Github) + .children(remote_info.map(|(provider_name, url)| { + let icon = match provider_name.as_str() { + "GitHub" => IconName::Github, + _ => IconName::Link, + }; + + Button::new("view_on_provider", format!("View on {}", provider_name)) + .icon(icon) .icon_color(Color::Muted) .icon_size(IconSize::Small) .icon_position(IconPosition::Start) From 79a8985a8e6ee98f1783a39496adf9f6dbc24701 Mon Sep 17 00:00:00 2001 From: 0x2CA <2478557459@qq.com> Date: Mon, 15 Dec 2025 23:40:37 +0800 Subject: [PATCH 63/67] vim: Add scroll keybindings for the OutlinePanel (#42438) Closes #ISSUE ```json { "context": "OutlinePanel && not_editing", "bindings": { "enter": "editor::ToggleFocus", "/": "menu::Cancel", "ctrl-u": "outline_panel::ScrollUp", "ctrl-d": "outline_panel::ScrollDown", "z t": "outline_panel::ScrollCursorTop", "z z": "outline_panel::ScrollCursorCenter", "z b": "outline_panel::ScrollCursorBottom" } }, { "context": "OutlinePanel && editing", "bindings": { "enter": "menu::Cancel" } }, ``` Release Notes: - Added scroll keybindings for the OutlinePanel --------- Co-authored-by: Ben Kunkle --- Cargo.lock | 1 + assets/keymaps/vim.json | 32 ++++++++- crates/outline_panel/src/outline_panel.rs | 82 +++++++++++++++++++++++ crates/vim/Cargo.toml | 1 + crates/vim/src/test/vim_test_context.rs | 1 + 5 files changed, 115 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dfcabfb552e128dfa6b0b47ebb5f33bfa2aa4a6..8c1ade1714c9dd7f609582cc8bdf5184678afcd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18138,6 +18138,7 @@ dependencies = [ "menu", "multi_buffer", "nvim-rs", + "outline_panel", "parking_lot", "perf", "picker", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index bbae6e2f4d738ef60b3a1a5ba33a26a9ab68f497..0097480e2775a1048452b2a5e8ec826525da3f2e 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -970,10 +970,38 @@ { "context": "OutlinePanel && not_editing", "bindings": { - "j": "menu::SelectNext", - "k": "menu::SelectPrevious", + "h": "outline_panel::CollapseSelectedEntry", + "j": "vim::MenuSelectNext", + "k": "vim::MenuSelectPrevious", + "down": "vim::MenuSelectNext", + "up": "vim::MenuSelectPrevious", + "l": "outline_panel::ExpandSelectedEntry", "shift-g": "menu::SelectLast", "g g": "menu::SelectFirst", + "-": "outline_panel::SelectParent", + "enter": "editor::ToggleFocus", + "/": "menu::Cancel", + "ctrl-u": "outline_panel::ScrollUp", + "ctrl-d": "outline_panel::ScrollDown", + "z t": "outline_panel::ScrollCursorTop", + "z z": "outline_panel::ScrollCursorCenter", + "z b": "outline_panel::ScrollCursorBottom", + "0": ["vim::Number", 0], + "1": ["vim::Number", 1], + "2": ["vim::Number", 2], + "3": ["vim::Number", 3], + "4": ["vim::Number", 4], + "5": ["vim::Number", 5], + "6": ["vim::Number", 6], + "7": ["vim::Number", 7], + "8": ["vim::Number", 8], + "9": ["vim::Number", 9], + }, + }, + { + "context": "OutlinePanel && editing", + "bindings": { + "enter": "menu::Cancel", }, }, { diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 943025b1d0a96692f34f2ebcefff83a0ad2ddaee..8dbf7b681d9be45bda0fd9803cbb8e2cd434e921 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -75,6 +75,16 @@ actions!( OpenSelectedEntry, /// Reveals the selected item in the system file manager. RevealInFileManager, + /// Scroll half a page upwards + ScrollUp, + /// Scroll half a page downwards + ScrollDown, + /// Scroll until the cursor displays at the center + ScrollCursorCenter, + /// Scroll until the cursor displays at the top + ScrollCursorTop, + /// Scroll until the cursor displays at the bottom + ScrollCursorBottom, /// Selects the parent of the current entry. SelectParent, /// Toggles the pin status of the active editor. @@ -100,6 +110,7 @@ pub struct OutlinePanel { active: bool, pinned: bool, scroll_handle: UniformListScrollHandle, + rendered_entries_len: usize, context_menu: Option<(Entity, Point, Subscription)>, focus_handle: FocusHandle, pending_serialization: Task>, @@ -839,6 +850,7 @@ impl OutlinePanel { fs: workspace.app_state().fs.clone(), max_width_item_index: None, scroll_handle, + rendered_entries_len: 0, focus_handle, filter_editor, fs_entries: Vec::new(), @@ -1149,6 +1161,70 @@ impl OutlinePanel { } } + fn scroll_up(&mut self, _: &ScrollUp, window: &mut Window, cx: &mut Context) { + for _ in 0..self.rendered_entries_len / 2 { + window.dispatch_action(SelectPrevious.boxed_clone(), cx); + } + } + + fn scroll_down(&mut self, _: &ScrollDown, window: &mut Window, cx: &mut Context) { + for _ in 0..self.rendered_entries_len / 2 { + window.dispatch_action(SelectNext.boxed_clone(), cx); + } + } + + fn scroll_cursor_center( + &mut self, + _: &ScrollCursorCenter, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(selected_entry) = self.selected_entry() { + let index = self + .cached_entries + .iter() + .position(|cached_entry| &cached_entry.entry == selected_entry); + if let Some(index) = index { + self.scroll_handle + .scroll_to_item_strict(index, ScrollStrategy::Center); + cx.notify(); + } + } + } + + fn scroll_cursor_top(&mut self, _: &ScrollCursorTop, _: &mut Window, cx: &mut Context) { + if let Some(selected_entry) = self.selected_entry() { + let index = self + .cached_entries + .iter() + .position(|cached_entry| &cached_entry.entry == selected_entry); + if let Some(index) = index { + self.scroll_handle + .scroll_to_item_strict(index, ScrollStrategy::Top); + cx.notify(); + } + } + } + + fn scroll_cursor_bottom( + &mut self, + _: &ScrollCursorBottom, + _: &mut Window, + cx: &mut Context, + ) { + if let Some(selected_entry) = self.selected_entry() { + let index = self + .cached_entries + .iter() + .position(|cached_entry| &cached_entry.entry == selected_entry); + if let Some(index) = index { + self.scroll_handle + .scroll_to_item_strict(index, ScrollStrategy::Bottom); + cx.notify(); + } + } + } + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { if let Some(entry_to_select) = self.selected_entry().and_then(|selected_entry| { self.cached_entries @@ -4578,6 +4654,7 @@ impl OutlinePanel { "entries", items_len, cx.processor(move |outline_panel, range: Range, window, cx| { + outline_panel.rendered_entries_len = range.end - range.start; let entries = outline_panel.cached_entries.get(range); entries .map(|entries| entries.to_vec()) @@ -4970,7 +5047,12 @@ impl Render for OutlinePanel { .key_context(self.dispatch_context(window, cx)) .on_action(cx.listener(Self::open_selected_entry)) .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::scroll_up)) + .on_action(cx.listener(Self::scroll_down)) .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::scroll_cursor_center)) + .on_action(cx.listener(Self::scroll_cursor_top)) + .on_action(cx.listener(Self::scroll_cursor_bottom)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 74409a6c255645378b0b2829f4d0045776bfa019..2db1b51e72fcd862ccb1c35ff920fec7dbd47995 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -66,6 +66,7 @@ lsp = { workspace = true, features = ["test-support"] } markdown_preview.workspace = true parking_lot.workspace = true project_panel.workspace = true +outline_panel.workspace = true release_channel.workspace = true semver.workspace = true settings_ui.workspace = true diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index acd77839f2d8cc09ed72993638ff4ec66f79d3fc..2d5ed4227dcc263f56cfa0bcb337f5673df8ef3c 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -23,6 +23,7 @@ impl VimTestContext { release_channel::init(Version::new(0, 0, 0), cx); command_palette::init(cx); project_panel::init(cx); + outline_panel::init(cx); git_ui::init(cx); crate::init(cx); search::init(cx); From d52defe35a6e5fde07257a67e049383437f538f1 Mon Sep 17 00:00:00 2001 From: Freddy Fallon Date: Mon, 15 Dec 2025 15:55:54 +0000 Subject: [PATCH 64/67] Fix vitest test running and debugging for v4 with backwards compatibility (#43241) ## Summary This PR updates the vitest test runner integration to use the modern `--no-file-parallelism` flag instead of the deprecated `--poolOptions.forks.minForks=0` and `--poolOptions.forks.maxForks=1` flags. ## Changes - Replaced verbose pool options with `--no-file-parallelism` flag in both file-level and symbol-level vitest test tasks - This change works with vitest v4 while maintaining backwards compatibility with earlier versions (or 3 at least!) ## Testing - Added test `test_vitest_uses_no_file_parallelism_flag` that verifies: - The `--no-file-parallelism` flag is present in generated test tasks - The deprecated `poolOptions` flags are not present - Manually tested with both vitest v4 and older versions to confirm backwards compatibility - All existing tests pass ## Impact This allows Zed users to run and debug vitest tests in projects using vitest v4 while maintaining support for earlier versions. Release Notes: - Fixed vitest test running and debugging for projects using vitest v4 --------- Co-authored-by: Cole Miller --- crates/languages/src/typescript.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index a7aa1bc49c0132b01d0fe45d94a29af4efac6602..7daf178d37229a5b051461e199c3dbf8d830cf22 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -111,8 +111,7 @@ impl PackageJsonData { "--".to_owned(), "vitest".to_owned(), "run".to_owned(), - "--poolOptions.forks.minForks=0".to_owned(), - "--poolOptions.forks.maxForks=1".to_owned(), + "--no-file-parallelism".to_owned(), VariableName::File.template_value(), ], cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()), @@ -130,8 +129,7 @@ impl PackageJsonData { "--".to_owned(), "vitest".to_owned(), "run".to_owned(), - "--poolOptions.forks.minForks=0".to_owned(), - "--poolOptions.forks.maxForks=1".to_owned(), + "--no-file-parallelism".to_owned(), "--testNamePattern".to_owned(), format!( "\"{}\"", From b92201922123faf0ac627998229afc3a0437970e Mon Sep 17 00:00:00 2001 From: feeiyu <158308373+feeiyu@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:01:07 +0800 Subject: [PATCH 65/67] git_ui: Make the file history view keyboard navigable (#44328) ![file_history_view_navigation](https://github.com/user-attachments/assets/1435fdae-806e-48d1-a031-2c0fec28725f) Release Notes: - git: Made the file history view keyboard navigable --- crates/git_ui/src/file_history_view.rs | 117 +++++++++++++++++++++---- 1 file changed, 99 insertions(+), 18 deletions(-) diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs index 5b3588d29678ec406749ec45be3de154fd71c5f8..4e91fe7e06a5823caac5bf00be8f48cc98dc8da4 100644 --- a/crates/git_ui/src/file_history_view.rs +++ b/crates/git_ui/src/file_history_view.rs @@ -4,7 +4,8 @@ use git::repository::{FileHistory, FileHistoryEntry, RepoPath}; use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, Render, Task, UniformListScrollHandle, WeakEntity, Window, actions, uniform_list, + IntoElement, Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window, + actions, uniform_list, }; use project::{ Project, ProjectPath, @@ -191,6 +192,93 @@ impl FileHistoryView { task.detach(); } + fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(0), + Some(ix) => { + if ix == entry_count - 1 { + Some(0) + } else { + Some(ix + 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _: &mut Window, + cx: &mut Context, + ) { + let entry_count = self.history.entries.len(); + let ix = match self.selected_entry { + _ if entry_count == 0 => None, + None => Some(entry_count - 1), + Some(ix) => { + if ix == 0 { + Some(entry_count - 1) + } else { + Some(ix - 1) + } + } + }; + self.select_ix(ix, cx); + } + + fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { Some(0) } else { None }; + self.select_ix(ix, cx); + } + + fn select_last(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { + let entry_count = self.history.entries.len(); + let ix = if entry_count != 0 { + Some(entry_count - 1) + } else { + None + }; + self.select_ix(ix, cx); + } + + fn select_ix(&mut self, ix: Option, cx: &mut Context) { + self.selected_entry = ix; + if let Some(ix) = ix { + self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top); + } + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + self.open_commit_view(window, cx); + } + + fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context) { + let Some(entry) = self + .selected_entry + .and_then(|ix| self.history.entries.get(ix)) + else { + return; + }; + + if let Some(repo) = self.repository.upgrade() { + let sha_str = entry.sha.to_string(); + CommitView::open( + sha_str, + repo.downgrade(), + self.workspace.clone(), + None, + Some(self.history.path.clone()), + window, + cx, + ); + } + } + fn render_commit_avatar( &self, sha: &SharedString, @@ -245,12 +333,8 @@ impl FileHistoryView { time_format::TimestampFormat::Relative, ); - let sha = entry.sha.clone(); - let repo = self.repository.clone(); - let workspace = self.workspace.clone(); - let file_path = self.history.path.clone(); - ListItem::new(("commit", ix)) + .toggle_state(Some(ix) == self.selected_entry) .child( h_flex() .h_8() @@ -301,18 +385,7 @@ impl FileHistoryView { this.selected_entry = Some(ix); cx.notify(); - if let Some(repo) = repo.upgrade() { - let sha_str = sha.to_string(); - CommitView::open( - sha_str, - repo.downgrade(), - workspace.clone(), - None, - Some(file_path.clone()), - window, - cx, - ); - } + this.open_commit_view(window, cx); })) .into_any_element() } @@ -380,6 +453,14 @@ impl Render for FileHistoryView { let entry_count = self.history.entries.len(); v_flex() + .id("file_history_view") + .key_context("FileHistoryView") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::confirm)) .size_full() .bg(cx.theme().colors().editor_background) .child( From f2f3d9faf6f22aa4995f2df045286068b693d5c2 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:02:01 -0300 Subject: [PATCH 66/67] Add adjustments to agent v2 pane changes (#44885) Follow-up to https://github.com/zed-industries/zed/pull/44190. Release Notes: - N/A --- assets/icons/zed_agent_two.svg | 5 ++ crates/agent_ui_v2/src/agent_thread_pane.rs | 99 ++++++++++----------- crates/agent_ui_v2/src/agents_panel.rs | 2 +- crates/icons/src/icons.rs | 1 + crates/ui/src/components/tab_bar.rs | 1 + crates/workspace/src/pane.rs | 74 ++++++++++----- 6 files changed, 107 insertions(+), 75 deletions(-) create mode 100644 assets/icons/zed_agent_two.svg diff --git a/assets/icons/zed_agent_two.svg b/assets/icons/zed_agent_two.svg new file mode 100644 index 0000000000000000000000000000000000000000..c352be84d2f1bea6da1f6a5be70b9420f019b6d6 --- /dev/null +++ b/assets/icons/zed_agent_two.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/agent_ui_v2/src/agent_thread_pane.rs b/crates/agent_ui_v2/src/agent_thread_pane.rs index cfe861ef09c51af511554b3d15a1c810a793ed15..72886f87eca38c630ec29b9b410930f1d3936b50 100644 --- a/crates/agent_ui_v2/src/agent_thread_pane.rs +++ b/crates/agent_ui_v2/src/agent_thread_pane.rs @@ -13,13 +13,12 @@ use settings::DockSide; use settings::Settings as _; use std::rc::Rc; use std::sync::Arc; -use ui::{ - App, Clickable as _, Context, DynamicSpacing, IconButton, IconName, IconSize, IntoElement, - Label, LabelCommon as _, LabelSize, Render, Tab, Window, div, +use ui::{Tab, Tooltip, prelude::*}; +use workspace::{ + Workspace, + dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}, + utility_pane::UtilityPaneSlot, }; -use workspace::Workspace; -use workspace::dock::{ClosePane, MinimizePane, UtilityPane, UtilityPanePosition}; -use workspace::utility_pane::UtilityPaneSlot; pub const DEFAULT_UTILITY_PANE_WIDTH: Pixels = gpui::px(400.0); @@ -169,58 +168,56 @@ impl AgentThreadPane { let toggle_icon = self.toggle_icon(cx); let title = self.title(cx); - let make_toggle_button = |workspace: WeakEntity, cx: &App| { - div().px(DynamicSpacing::Base06.rems(cx)).child( - IconButton::new("toggle_utility_pane", toggle_icon) - .icon_size(IconSize::Small) - .on_click(move |_, window, cx| { - workspace - .update(cx, |workspace, cx| { - workspace.toggle_utility_pane(slot, window, cx) - }) - .ok(); - }), - ) - }; - - let make_close_button = |id: &'static str, cx: &mut Context| { - let on_click = cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { - cx.emit(ClosePane); - this.thread_view = None; - cx.notify(); - }); - div().px(DynamicSpacing::Base06.rems(cx)).child( - IconButton::new(id, IconName::Close) - .icon_size(IconSize::Small) - .on_click(on_click), - ) - }; - - let make_title_label = |title: SharedString, cx: &App| { - div() - .px(DynamicSpacing::Base06.rems(cx)) - .child(Label::new(title).size(LabelSize::Small)) + let pane_toggle_button = |workspace: WeakEntity| { + IconButton::new("toggle_utility_pane", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) + .on_click(move |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane(slot, window, cx) + }) + .ok(); + }) }; - div() + h_flex() .id("utility-pane-header") - .flex() - .flex_none() - .items_center() .w_full() .h(Tab::container_height(cx)) - .when(slot == UtilityPaneSlot::Left, |this| { - this.child(make_toggle_button(workspace.clone(), cx)) - .child(make_title_label(title.clone(), cx)) - .child(div().flex_grow()) - .child(make_close_button("close_utility_pane_left", cx)) - }) + .px_1p5() + .gap(DynamicSpacing::Base06.rems(cx)) .when(slot == UtilityPaneSlot::Right, |this| { - this.child(make_close_button("close_utility_pane_right", cx)) - .child(make_title_label(title.clone(), cx)) - .child(div().flex_grow()) - .child(make_toggle_button(workspace.clone(), cx)) + this.flex_row_reverse() }) + .flex_none() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(pane_toggle_button(workspace)) + .child( + h_flex() + .size_full() + .min_w_0() + .gap_1() + .map(|this| { + if slot == UtilityPaneSlot::Right { + this.flex_row_reverse().justify_start() + } else { + this.justify_between() + } + }) + .child(Label::new(title).truncate()) + .child( + IconButton::new("close_btn", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Close Agent Pane")) + .on_click(cx.listener(|this, _: &gpui::ClickEvent, _window, cx| { + cx.emit(ClosePane); + this.thread_view = None; + cx.notify() + })), + ), + ) } } diff --git a/crates/agent_ui_v2/src/agents_panel.rs b/crates/agent_ui_v2/src/agents_panel.rs index ace5e73f56b9eff4292f34263bfe08a94e2d6050..a7afdddda43514ade40b7fd9dfd8bcd8ace33dc7 100644 --- a/crates/agent_ui_v2/src/agents_panel.rs +++ b/crates/agent_ui_v2/src/agents_panel.rs @@ -411,7 +411,7 @@ impl Panel for AgentsPanel { } fn icon(&self, _window: &Window, cx: &App) -> Option { - (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgent) + (self.enabled(cx) && AgentSettings::get_global(cx).button).then_some(IconName::ZedAgentTwo) } fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index bf4c74f984ff4aa8f06d6408957eddabcf5f94ed..23ae7a6d928d98aafe48d28cfe5626bbf76d29b8 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -260,6 +260,7 @@ pub enum IconName { XCircle, XCircleFilled, ZedAgent, + ZedAgentTwo, ZedAssistant, ZedBurnMode, ZedBurnModeOn, diff --git a/crates/ui/src/components/tab_bar.rs b/crates/ui/src/components/tab_bar.rs index 681f9a726e0d5f4796325a4533fca909617f1e08..86598b8c6f1ab3a479313c7775405863e9e3b49b 100644 --- a/crates/ui/src/components/tab_bar.rs +++ b/crates/ui/src/components/tab_bar.rs @@ -162,6 +162,7 @@ impl RenderOnce for TabBar { .when(!self.end_children.is_empty(), |div| { div.child( h_flex() + .h_full() .flex_none() .pl(DynamicSpacing::Base04.rems(cx)) .gap(DynamicSpacing::Base04.rems(cx)) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index ee57f06937ee2781e8d1b965b5e498f5a31ad80d..50ba58926ece8818ac5a4f44103c3b86eb2b672d 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -3047,6 +3047,8 @@ impl Pane { }; let focus_handle = self.focus_handle.clone(); + let is_pane_focused = self.has_focus(window, cx); + let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft) .icon_size(IconSize::Small) .on_click({ @@ -3076,15 +3078,27 @@ impl Pane { let toggle_icon = pane.toggle_icon(cx); let workspace_handle = self.workspace.clone(); - IconButton::new("open_aside_left", toggle_icon) - .icon_size(IconSize::Small) - .on_click(move |_, window, cx| { - workspace_handle - .update(cx, |workspace, cx| { - workspace.toggle_utility_pane(UtilityPaneSlot::Left, window, cx) - }) - .ok(); - }) + h_flex() + .h_full() + .pr_1p5() + .border_r_1() + .border_color(cx.theme().colors().border) + .child( + IconButton::new("open_aside_left", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Left, + window, + cx, + ) + }) + .ok(); + }), + ) .into_any_element() }) }; @@ -3095,15 +3109,29 @@ impl Pane { let toggle_icon = pane.toggle_icon(cx); let workspace_handle = self.workspace.clone(); - IconButton::new("open_aside_right", toggle_icon) - .icon_size(IconSize::Small) - .on_click(move |_, window, cx| { - workspace_handle - .update(cx, |workspace, cx| { - workspace.toggle_utility_pane(UtilityPaneSlot::Right, window, cx) - }) - .ok(); + h_flex() + .h_full() + .when(is_pane_focused, |this| { + this.pl(DynamicSpacing::Base04.rems(cx)) + .border_l_1() + .border_color(cx.theme().colors().border) }) + .child( + IconButton::new("open_aside_right", toggle_icon) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Toggle Agent Pane")) // TODO: Probably want to make this generic + .on_click(move |_, window, cx| { + workspace_handle + .update(cx, |workspace, cx| { + workspace.toggle_utility_pane( + UtilityPaneSlot::Right, + window, + cx, + ) + }) + .ok(); + }), + ) .into_any_element() }) }; @@ -3196,8 +3224,8 @@ impl Pane { self.display_nav_history_buttons.unwrap_or_default(), |tab_bar| { tab_bar - .pre_end_child(navigate_backward) - .pre_end_child(navigate_forward) + .start_child(navigate_backward) + .start_child(navigate_forward) }, ) .map(|tab_bar| { @@ -6756,13 +6784,13 @@ mod tests { let tab_bar_scroll_handle = pane.update_in(cx, |pane, _window, _cx| pane.tab_bar_scroll_handle.clone()); assert_eq!(tab_bar_scroll_handle.children_count(), 6); - let tab_bounds = cx.debug_bounds("TAB-3").unwrap(); + let tab_bounds = cx.debug_bounds("TAB-4").unwrap(); let new_tab_button_bounds = cx.debug_bounds("ICON-Plus").unwrap(); let scroll_bounds = tab_bar_scroll_handle.bounds(); let scroll_offset = tab_bar_scroll_handle.offset(); - assert!(tab_bounds.right() <= scroll_bounds.right() + scroll_offset.x); - // -35.0 is the magic number for this setup - assert_eq!(scroll_offset.x, px(-35.0)); + assert!(tab_bounds.right() <= scroll_bounds.right()); + // -43.0 is the magic number for this setup + assert_eq!(scroll_offset.x, px(-43.0)); assert!( !tab_bounds.intersects(&new_tab_button_bounds), "Tab should not overlap with the new tab button, if this is failing check if there's been a redesign!" From c20cbba0ebb1caf3763d55893c4a11dcc06a2cae Mon Sep 17 00:00:00 2001 From: Jeff Brennan <42007840+jeffbrennan@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:11:07 -0500 Subject: [PATCH 67/67] python: Add SQL syntax highlighting (#43756) Release Notes: - Added support for SQL syntax highlighting in Python files ## Summary I am a data engineer who spends a lot of time writing SQL in Python files using Zed. This PR adds support for SQL syntax highlighting with common libraries (like pyspark, polars, pandas) and string variables (prefixed with a `# sql` comment). I referenced [#37605](https://github.com/zed-industries/zed/pull/37605) for this implementation to keep the comment prefix consistent. ## Examples image --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/languages/src/python/injections.scm | 31 ++++++++++++++++++++++ docs/src/languages/python.md | 19 +++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/languages/src/python/injections.scm b/crates/languages/src/python/injections.scm index 9117c713b98fdd2896b13e4949a77c6489b9ee36..d8470140e999f3dc649c0a498987cfae7df6bf59 100644 --- a/crates/languages/src/python/injections.scm +++ b/crates/languages/src/python/injections.scm @@ -1,3 +1,34 @@ ((comment) @injection.content (#set! injection.language "comment") ) + +; SQL ----------------------------------------------------------------------------- +( + [ + ; function calls + (call + [ + (attribute attribute: (identifier) @function_name) + (identifier) @function_name + ] + arguments: (argument_list + (comment) @comment + (string + (string_content) @injection.content + ) + )) + + ; string variables + ((comment) @comment + . + (expression_statement + (assignment + right: (string + (string_content) @injection.content + ) + ) + )) + ] + (#match? @comment "^(#|#\\s+)(?i:sql)\\s*$") + (#set! injection.language "sql") +) diff --git a/docs/src/languages/python.md b/docs/src/languages/python.md index 5051a72209121176e05d41f57cb8d341db2ca351..2323fe2f9560cf03c586eced0052627705addcc3 100644 --- a/docs/src/languages/python.md +++ b/docs/src/languages/python.md @@ -258,6 +258,25 @@ quote-style = "single" For more details, refer to the Ruff documentation about [configuration files](https://docs.astral.sh/ruff/configuration/) and [language server settings](https://docs.astral.sh/ruff/editors/settings/), and the [list of options](https://docs.astral.sh/ruff/settings/). +### Embedded Language Highlighting + +Zed supports syntax highlighting for code embedded in Python strings by adding a comment with the language name. + +```python +# sql +query = "SELECT * FROM users" + +#sql +query = """ + SELECT * + FROM users +""" + +result = func( #sql + "SELECT * FROM users" +) +``` + ## Debugging Zed supports Python debugging through the `debugpy` adapter. You can start with no configuration or define custom launch profiles in `.zed/debug.json`.