Merge branch 'main' into fix-worktree-drag-reorder

This commit is contained in:
Elliot Thomas 2026-05-07 09:20:10 +01:00 committed by GitHub
commit 9a4b56edaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 8333 additions and 4277 deletions

5
Cargo.lock generated
View file

@ -3571,7 +3571,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel 2.5.0", "async-channel 2.5.0",
"async-process",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"collections", "collections",
@ -5397,8 +5396,8 @@ dependencies = [
name = "edit_prediction_metrics" name = "edit_prediction_metrics"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"imara-diff",
"indoc", "indoc",
"language",
"pretty_assertions", "pretty_assertions",
"serde", "serde",
"serde_json", "serde_json",
@ -22395,7 +22394,7 @@ dependencies = [
[[package]] [[package]]
name = "zed" name = "zed"
version = "1.2.0" version = "1.3.0"
dependencies = [ dependencies = [
"acp_thread", "acp_thread",
"acp_tools", "acp_tools",

View file

@ -1499,6 +1499,7 @@
"use_key_equivalents": true, "use_key_equivalents": true,
"bindings": { "bindings": {
"ctrl-shift-backspace": "branch_picker::DeleteBranch", "ctrl-shift-backspace": "branch_picker::DeleteBranch",
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"ctrl-shift-i": "branch_picker::FilterRemotes", "ctrl-shift-i": "branch_picker::FilterRemotes",
}, },
}, },

View file

@ -1552,6 +1552,7 @@
"use_key_equivalents": true, "use_key_equivalents": true,
"bindings": { "bindings": {
"cmd-shift-backspace": "branch_picker::DeleteBranch", "cmd-shift-backspace": "branch_picker::DeleteBranch",
"cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"cmd-shift-i": "branch_picker::FilterRemotes", "cmd-shift-i": "branch_picker::FilterRemotes",
}, },
}, },

View file

@ -1479,6 +1479,7 @@
"use_key_equivalents": true, "use_key_equivalents": true,
"bindings": { "bindings": {
"ctrl-shift-backspace": "branch_picker::DeleteBranch", "ctrl-shift-backspace": "branch_picker::DeleteBranch",
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"ctrl-shift-i": "branch_picker::FilterRemotes", "ctrl-shift-i": "branch_picker::FilterRemotes",
}, },
}, },

View file

@ -527,6 +527,7 @@
"space w d": "pane::SplitDown", // not a helix default "space w d": "pane::SplitDown", // not a helix default
// Space mode // Space mode
"space b": "tab_switcher::ToggleAll",
"space f": "file_finder::Toggle", "space f": "file_finder::Toggle",
"space k": "editor::Hover", "space k": "editor::Hover",
"space s": "outline::Toggle", "space s": "outline::Toggle",

View file

@ -1110,6 +1110,7 @@
"diagnostics": true, "diagnostics": true,
"apply_code_action": true, "apply_code_action": true,
"edit_file": true, "edit_file": true,
"write_file": true,
"fetch": true, "fetch": true,
"find_path": true, "find_path": true,
"find_references": true, "find_references": true,

View file

@ -2294,10 +2294,6 @@ impl AcpThread {
this.project this.project
.update(cx, |project, cx| project.set_agent_location(None, cx)); .update(cx, |project, cx| project.set_agent_location(None, cx));
} }
let Ok(response) = response else {
// tx dropped, just return
return Ok(None);
};
let is_same_turn = this let is_same_turn = this
.running_turn .running_turn
@ -2306,11 +2302,18 @@ impl AcpThread {
// If the user submitted a follow up message, running_turn might // If the user submitted a follow up message, running_turn might
// already point to a different turn. Therefore we only want to // already point to a different turn. Therefore we only want to
// take the task if it's the same turn. // take the task if it's the same turn. We do this before the
// dropped-tx guard below so the panel exits its generating
// state even when the send_task is cancelled before tx.send().
if is_same_turn { if is_same_turn {
this.running_turn.take(); this.running_turn.take();
} }
let Ok(response) = response else {
// tx dropped, just return
return Ok(None);
};
match response { match response {
Ok(r) => { Ok(r) => {
Self::flush_streaming_text(&mut this.streaming_text_buffer, cx); Self::flush_streaming_text(&mut this.streaming_text_buffer, cx);
@ -5517,4 +5520,63 @@ mod tests {
); );
}); });
} }
/// Regression test: if the inner send_task is cancelled before it can
/// fire `tx.send(...)` (e.g. because the underlying future was dropped),
/// the outer task observes `rx.await` returning `Err(Cancelled)` and
/// must still clear `running_turn` so the panel transitions out of
/// `Generating`. Without this, the agent thread is wedged in the
/// loading state until Zed restarts.
#[gpui::test]
async fn test_running_turn_cleared_when_send_task_dropped(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
// Handler hangs forever so the spawn at run_turn is parked inside
// `f(this, cx).await` with `tx` still alive but unsent.
let connection = Rc::new(FakeAgentConnection::new().on_user_message(
|_params, _thread, _cx| {
async move { futures::future::pending::<Result<acp::PromptResponse>>().await }
.boxed_local()
},
));
let thread = cx
.update(|cx| {
connection.new_session(project, PathList::new(&[Path::new(path!("/test"))]), cx)
})
.await
.unwrap();
let request = thread.update(cx, |thread, cx| thread.send_raw("hello", cx));
cx.run_until_parked();
assert_eq!(
thread.read_with(cx, |t, _| t.status()),
ThreadStatus::Generating,
"thread should be generating while the handler is parked"
);
// Replace the in-flight send_task with a no-op. Dropping the original
// Task cancels its inner future, which drops `tx` without ever calling
// `tx.send(...)`. This mirrors the production scenario where the
// send_task future is cancelled before completion.
thread.update(cx, |thread, _| {
thread.running_turn.as_mut().unwrap().send_task = Task::ready(());
});
let result = request.await;
assert!(
matches!(result, Ok(None)),
"outer task should resolve to Ok(None) on dropped tx, got {result:?}"
);
assert_eq!(
thread.read_with(cx, |t, _| t.status()),
ThreadStatus::Idle,
"running_turn must be cleared even when tx was dropped without send"
);
}
} }

View file

@ -49,6 +49,10 @@ pub trait AgentConnection {
fn telemetry_id(&self) -> SharedString; fn telemetry_id(&self) -> SharedString;
fn agent_version(&self) -> Option<SharedString> {
None
}
fn new_session( fn new_session(
self: Rc<Self>, self: Rc<Self>,
project: Entity<Project>, project: Entity<Project>,

View file

@ -6062,9 +6062,7 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
tool.run( tool.run(
ToolInput::resolved(crate::EditFileToolInput { ToolInput::resolved(crate::EditFileToolInput {
path: "root/sensitive_config.txt".into(), path: "root/sensitive_config.txt".into(),
mode: crate::EditFileMode::Edit, edits: vec![],
content: None,
edits: Some(vec![]),
}), }),
event_stream, event_stream,
cx, cx,
@ -6496,9 +6494,7 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
tool.run( tool.run(
ToolInput::resolved(crate::EditFileToolInput { ToolInput::resolved(crate::EditFileToolInput {
path: "root/README.md".into(), path: "root/README.md".into(),
mode: crate::EditFileMode::Edit, edits: vec![],
content: None,
edits: Some(vec![]),
}), }),
event_stream, event_stream,
cx, cx,
@ -6568,9 +6564,7 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes
tool.run( tool.run(
ToolInput::resolved(crate::EditFileToolInput { ToolInput::resolved(crate::EditFileToolInput {
path: "root/.zed/settings.json".into(), path: "root/.zed/settings.json".into(),
mode: crate::EditFileMode::Edit, edits: vec![],
content: None,
edits: Some(vec![]),
}), }),
event_stream, event_stream,
cx, cx,

View file

@ -4,7 +4,7 @@ use crate::{
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool,
RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template, RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template,
Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool,
decide_permission_from_settings, decide_permission_from_settings,
}; };
use acp_thread::{MentionUri, UserMessageId}; use acp_thread::{MentionUri, UserMessageId};
@ -822,6 +822,7 @@ impl ToolPermissionContext {
} else if tool_name == CopyPathTool::NAME } else if tool_name == CopyPathTool::NAME
|| tool_name == MovePathTool::NAME || tool_name == MovePathTool::NAME
|| tool_name == EditFileTool::NAME || tool_name == EditFileTool::NAME
|| tool_name == WriteFileTool::NAME
|| tool_name == DeletePathTool::NAME || tool_name == DeletePathTool::NAME
|| tool_name == CreateDirectoryTool::NAME || tool_name == CreateDirectoryTool::NAME
|| tool_name == SaveFileTool::NAME || tool_name == SaveFileTool::NAME
@ -1544,6 +1545,12 @@ impl Thread {
self.action_log.clone(), self.action_log.clone(),
)); ));
self.add_tool(EditFileTool::new( self.add_tool(EditFileTool::new(
self.project.clone(),
cx.weak_entity(),
self.action_log.clone(),
language_registry.clone(),
));
self.add_tool(WriteFileTool::new(
self.project.clone(), self.project.clone(),
cx.weak_entity(), cx.weak_entity(),
self.action_log.clone(), self.action_log.clone(),

View file

@ -5,6 +5,7 @@ mod create_directory_tool;
mod delete_path_tool; mod delete_path_tool;
mod diagnostics_tool; mod diagnostics_tool;
mod edit_file_tool; mod edit_file_tool;
mod edit_session;
#[cfg(all(test, feature = "unit-eval"))] #[cfg(all(test, feature = "unit-eval"))]
mod evals; mod evals;
mod fetch_tool; mod fetch_tool;
@ -27,6 +28,7 @@ mod terminal_tool;
mod tool_permissions; mod tool_permissions;
mod update_plan_tool; mod update_plan_tool;
mod web_search_tool; mod web_search_tool;
mod write_file_tool;
use crate::AgentTool; use crate::AgentTool;
use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat}; use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat};
@ -85,6 +87,7 @@ pub use terminal_tool::*;
pub use tool_permissions::*; pub use tool_permissions::*;
pub use update_plan_tool::*; pub use update_plan_tool::*;
pub use web_search_tool::*; pub use web_search_tool::*;
pub use write_file_tool::*;
macro_rules! tools { macro_rules! tools {
($($tool:ty),* $(,)?) => { ($($tool:ty),* $(,)?) => {
@ -179,4 +182,5 @@ tools! {
TerminalTool, TerminalTool,
UpdatePlanTool, UpdatePlanTool,
WebSearchTool, WebSearchTool,
WriteFileTool,
} }

View file

@ -54,7 +54,7 @@ impl AgentTool for CreateDirectoryTool {
const NAME: &'static str = "create_directory"; const NAME: &'static str = "create_directory";
fn kind() -> acp::ToolKind { fn kind() -> acp::ToolKind {
acp::ToolKind::Read acp::ToolKind::Edit
} }
fn initial_title( fn initial_title(

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
use smallvec::SmallVec; use smallvec::SmallVec;
use crate::{Edit, PartialEdit}; use super::{Edit, PartialEdit};
/// Events emitted by `StreamingParser` for edit-mode input. /// Events emitted by `StreamingParser` for edit-mode input.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]

View file

@ -2,3 +2,5 @@
mod edit_file; mod edit_file;
#[cfg(all(test, feature = "unit-eval"))] #[cfg(all(test, feature = "unit-eval"))]
mod terminal_tool; mod terminal_tool;
#[cfg(all(test, feature = "unit-eval"))]
mod write_file;

View file

@ -1,8 +1,7 @@
use crate::tools::edit_file_tool::*; use crate::tools::edit_file_tool::*;
use crate::{ use crate::{
AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ListDirectoryTool, AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ReadFileTool,
ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, Template, Templates, Thread, ReadFileToolInput, Template, Templates, Thread, ToolCallEventStream, ToolInput,
ToolCallEventStream, ToolInput,
}; };
use Role::*; use Role::*;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
@ -124,20 +123,6 @@ impl EvalAssertion {
EvalAssertion(Arc::new(f)) EvalAssertion(Arc::new(f))
} }
fn assert_eq(expected: impl Into<String>) -> Self {
let expected = expected.into();
Self::new(async move |sample, _judge, _cx| {
Ok(EvalAssertionOutcome {
score: if strip_empty_lines(&sample.text_after) == strip_empty_lines(&expected) {
100
} else {
0
},
message: None,
})
})
}
fn assert_diff_any(expected_diffs: Vec<impl Into<String>>) -> Self { fn assert_diff_any(expected_diffs: Vec<impl Into<String>>) -> Self {
let expected_diffs: Vec<String> = expected_diffs.into_iter().map(Into::into).collect(); let expected_diffs: Vec<String> = expected_diffs.into_iter().map(Into::into).collect();
Self::new(async move |sample, _judge, _cx| { Self::new(async move |sample, _judge, _cx| {
@ -1499,46 +1484,3 @@ fn eval_add_overwrite_test() {
)) ))
}); });
} }
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_create_empty_file() {
let input_file_path = "root/TODO3";
let input_file_content = None;
let expected_output_content = String::new();
eval_utils::eval(100, 0.99, eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![
message(User, [text("Create a second empty todo file ")]),
message(
Assistant,
[
text(indoc::formatdoc! {"
I'll help you create a second empty todo file.
First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one.
"}),
tool_use(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
ListDirectoryToolInput {
path: "root".to_string(),
},
),
],
),
message(
User,
[tool_result(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
"root/TODO\nroot/TODO2\nroot/new.txt\n",
)],
),
],
input_file_path,
input_file_content.clone(),
EvalAssertion::assert_eq(expected_output_content.clone()),
))
});
}

View file

@ -0,0 +1,561 @@
use crate::{
AgentTool, ContextServerRegistry, ListDirectoryTool, ListDirectoryToolInput, Template,
Templates, Thread, ToolCallEventStream, ToolInput, WriteFileTool, WriteFileToolInput,
};
use Role::*;
use anyhow::{Context as _, Result};
use client::{Client, RefreshLlmTokenListener, UserStore};
use fs::FakeFs;
use futures::StreamExt;
use gpui::{AppContext as _, AsyncApp, Entity, TestAppContext, UpdateGlobal as _};
use http_client::StatusCode;
use language::language_settings::FormatOnSave;
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse,
LanguageModelToolUseId, MessageContent, Role, SelectedModel,
};
use project::Project;
use prompt_store::{ProjectContext, WorktreeContext};
use rand::prelude::*;
use reqwest_client::ReqwestClient;
use serde::Serialize;
use settings::SettingsStore;
use std::{
fmt::{self, Display},
path::{Path, PathBuf},
str::FromStr,
sync::Arc,
time::Duration,
};
use util::path;
#[derive(Clone)]
struct EvalInput {
conversation: Vec<LanguageModelRequestMessage>,
input_file_path: PathBuf,
input_content: Option<String>,
expected_output_content: String,
}
impl EvalInput {
fn new(
conversation: Vec<LanguageModelRequestMessage>,
input_file_path: impl Into<PathBuf>,
input_content: Option<String>,
expected_output_content: String,
) -> Self {
Self {
conversation,
input_file_path: input_file_path.into(),
input_content,
expected_output_content,
}
}
}
struct WriteEvalOutput {
tool_input: WriteFileToolInput,
text_after: String,
}
impl Display for WriteEvalOutput {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "Tool Input:\n{:#?}", self.tool_input)?;
writeln!(f, "Text After:\n{}", self.text_after)?;
Ok(())
}
}
struct WriteToolTest {
fs: Arc<FakeFs>,
project: Entity<Project>,
model: Arc<dyn LanguageModel>,
model_thinking_effort: Option<String>,
}
impl WriteToolTest {
async fn new(cx: &mut TestAppContext) -> Self {
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
store.update_user_settings(cx, |settings| {
settings
.project
.all_languages
.defaults
.ensure_final_newline_on_save = Some(false);
settings.project.all_languages.defaults.format_on_save =
Some(FormatOnSave::Off);
});
});
gpui_tokio::init(cx);
let http_client = Arc::new(ReqwestClient::user_agent("agent tests").unwrap());
cx.set_http_client(http_client);
let client = Client::production(cx);
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
language_model::init(cx);
RefreshLlmTokenListener::register(client.clone(), user_store.clone(), cx);
language_models::init(user_store, client, cx);
});
fs.insert_tree("/root", serde_json::json!({})).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let agent_model = SelectedModel::from_str(
&std::env::var("ZED_AGENT_MODEL")
.unwrap_or("anthropic/claude-sonnet-4-6-latest".into()),
)
.unwrap();
let authenticate_provider_tasks = cx.update(|cx| {
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
registry
.providers()
.iter()
.map(|p| p.authenticate(cx))
.collect::<Vec<_>>()
})
});
let model = cx
.update(|cx| {
cx.spawn(async move |cx| {
futures::future::join_all(authenticate_provider_tasks).await;
Self::load_model(&agent_model, cx).await.unwrap()
})
})
.await;
let model_thinking_effort = model
.default_effort_level()
.map(|effort_level| effort_level.value.to_string());
Self {
fs,
project,
model,
model_thinking_effort,
}
}
async fn load_model(
selected_model: &SelectedModel,
cx: &mut AsyncApp,
) -> Result<Arc<dyn LanguageModel>> {
cx.update(|cx| {
let registry = LanguageModelRegistry::read_global(cx);
let provider = registry
.provider(&selected_model.provider)
.expect("Provider not found");
provider.authenticate(cx)
})
.await?;
Ok(cx.update(|cx| {
let models = LanguageModelRegistry::read_global(cx);
models
.available_models(cx)
.find(|model| {
model.provider_id() == selected_model.provider
&& model.id() == selected_model.model
})
.unwrap_or_else(|| panic!("Model {} not found", selected_model.model.0))
}))
}
async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result<WriteEvalOutput> {
eval.conversation
.last_mut()
.context("Conversation must not be empty")?
.cache = true;
if let Some(input_content) = eval.input_content.as_deref() {
let abs_path = Path::new("/root").join(
eval.input_file_path
.strip_prefix("root")
.unwrap_or(&eval.input_file_path),
);
self.fs.insert_file(&abs_path, input_content.into()).await;
cx.run_until_parked();
}
let tools = crate::built_in_tools().collect::<Vec<_>>();
let system_prompt = {
let worktrees = vec![WorktreeContext {
root_name: "root".to_string(),
abs_path: Path::new("/path/to/root").into(),
rules_file: None,
}];
let project_context = ProjectContext::new(worktrees, Vec::default());
let tool_names = tools
.iter()
.map(|tool| tool.name.clone().into())
.collect::<Vec<_>>();
let template = crate::SystemPromptTemplate {
project: &project_context,
available_tools: tool_names,
model_name: None,
};
let templates = Templates::new();
template.render(&templates)?
};
let messages = [LanguageModelRequestMessage {
role: Role::System,
content: vec![MessageContent::Text(system_prompt)],
cache: true,
reasoning_details: None,
}]
.into_iter()
.chain(eval.conversation)
.collect::<Vec<_>>();
let request = LanguageModelRequest {
messages,
tools,
thinking_allowed: true,
thinking_effort: self.model_thinking_effort.clone(),
..Default::default()
};
let tool_input =
retry_on_rate_limit(async || self.extract_tool_use(request.clone(), cx).await).await?;
let language_registry = self
.project
.read_with(cx, |project, _cx| project.languages().clone());
let context_server_registry = cx
.new(|cx| ContextServerRegistry::new(self.project.read(cx).context_server_store(), cx));
let thread = cx.new(|cx| {
Thread::new(
self.project.clone(),
cx.new(|_cx| ProjectContext::default()),
context_server_registry,
Templates::new(),
Some(self.model.clone()),
cx,
)
});
let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
let tool = Arc::new(WriteFileTool::new(
self.project.clone(),
thread.downgrade(),
action_log,
language_registry,
));
let result = cx
.update(|cx| {
tool.clone().run(
ToolInput::resolved(tool_input.clone()),
ToolCallEventStream::test().0,
cx,
)
})
.await;
let output = match result {
Ok(output) => output,
Err(output) => anyhow::bail!("Tool returned error: {}", output),
};
let crate::EditFileToolOutput::Success { new_text, .. } = &output else {
anyhow::bail!("Tool returned error output: {}", output);
};
if tool_input.path != eval.input_file_path {
anyhow::bail!(
"Tool path mismatch. Expected {:?}, got {:?}",
eval.input_file_path,
tool_input.path,
);
}
if new_text != &eval.expected_output_content {
anyhow::bail!(
"Output content mismatch. Expected {:?}, got {:?}",
eval.expected_output_content,
new_text,
);
}
Ok(WriteEvalOutput {
tool_input,
text_after: new_text.clone(),
})
}
async fn extract_tool_use(
&self,
request: LanguageModelRequest,
cx: &mut TestAppContext,
) -> Result<WriteFileToolInput> {
let model = self.model.clone();
let events = cx
.update(|cx| {
let async_cx = cx.to_async();
cx.foreground_executor()
.spawn(async move { model.stream_completion(request, &async_cx).await })
})
.await
.map_err(|err| anyhow::anyhow!("completion error: {}", err))?;
let mut streamed_text = String::new();
let mut stop_reason = None;
let mut parse_errors = Vec::new();
let mut events = events.fuse();
while let Some(event) = events.next().await {
match event {
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
if tool_use.is_input_complete
&& tool_use.name.as_ref() == WriteFileTool::NAME =>
{
let input: WriteFileToolInput = serde_json::from_value(tool_use.input)
.context("Failed to parse tool input as WriteFileToolInput")?;
return Ok(input);
}
Ok(LanguageModelCompletionEvent::Text(text)) => {
if streamed_text.len() < 2_000 {
streamed_text.push_str(&text);
}
}
Ok(LanguageModelCompletionEvent::Stop(reason)) => {
stop_reason = Some(reason);
}
Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
tool_name,
raw_input,
json_parse_error,
..
}) if tool_name.as_ref() == WriteFileTool::NAME => {
parse_errors.push(format!("{json_parse_error}\nRaw input:\n{raw_input:?}"));
}
Err(err) => return Err(anyhow::anyhow!("completion error: {}", err)),
_ => {}
}
}
let streamed_text = streamed_text.trim();
let streamed_text_suffix = if streamed_text.is_empty() {
String::new()
} else {
format!("\nStreamed text:\n{streamed_text}")
};
let stop_reason_suffix = stop_reason
.map(|reason| format!("\nStop reason: {reason:?}"))
.unwrap_or_default();
let parse_errors_suffix = if parse_errors.is_empty() {
String::new()
} else {
format!("\nTool parse errors:\n{}", parse_errors.join("\n"))
};
anyhow::bail!(
"Stream ended without a write_file tool use{stop_reason_suffix}{parse_errors_suffix}{streamed_text_suffix}"
)
}
}
fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput<()> {
let dispatcher = gpui::TestDispatcher::new(rand::random());
let mut cx = TestAppContext::build(dispatcher, None);
let foreground_executor = cx.foreground_executor().clone();
let result = foreground_executor.block_test(async {
let test = WriteToolTest::new(&mut cx).await;
let result = test.eval(eval, &mut cx).await;
drop(test);
cx.run_until_parked();
result
});
cx.quit();
match result {
Ok(output) => eval_utils::EvalOutput {
data: output.to_string(),
outcome: eval_utils::OutcomeKind::Passed,
metadata: (),
},
Err(err) => eval_utils::EvalOutput {
data: format!("{err:?}"),
outcome: eval_utils::OutcomeKind::Error,
metadata: (),
},
}
}
fn message(
role: Role,
content: impl IntoIterator<Item = MessageContent>,
) -> LanguageModelRequestMessage {
LanguageModelRequestMessage {
role,
content: content.into_iter().collect(),
cache: false,
reasoning_details: None,
}
}
fn text(text: impl Into<String>) -> MessageContent {
MessageContent::Text(text.into())
}
fn tool_use(
id: impl Into<Arc<str>>,
name: impl Into<Arc<str>>,
input: impl Serialize,
) -> MessageContent {
MessageContent::ToolUse(LanguageModelToolUse {
id: LanguageModelToolUseId::from(id.into()),
name: name.into(),
raw_input: serde_json::to_string_pretty(&input).unwrap(),
input: serde_json::to_value(input).unwrap(),
is_input_complete: true,
thought_signature: None,
})
}
fn tool_result(
id: impl Into<Arc<str>>,
name: impl Into<Arc<str>>,
result: impl Into<Arc<str>>,
) -> MessageContent {
MessageContent::ToolResult(LanguageModelToolResult {
tool_use_id: LanguageModelToolUseId::from(id.into()),
tool_name: name.into(),
is_error: false,
content: vec![LanguageModelToolResultContent::Text(result.into())],
output: None,
})
}
async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) -> Result<R> {
const MAX_RETRIES: usize = 20;
let mut attempt = 0;
loop {
attempt += 1;
let response = request().await;
if attempt >= MAX_RETRIES {
return response;
}
let retry_delay = match &response {
Ok(_) => None,
Err(err) => match err.downcast_ref::<LanguageModelCompletionError>() {
Some(err) => match &err {
LanguageModelCompletionError::RateLimitExceeded { retry_after, .. }
| LanguageModelCompletionError::ServerOverloaded { retry_after, .. } => {
Some(retry_after.unwrap_or(Duration::from_secs(5)))
}
LanguageModelCompletionError::UpstreamProviderError {
status,
retry_after,
..
} => {
let should_retry = matches!(
*status,
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
) || status.as_u16() == 529;
if should_retry {
Some(retry_after.unwrap_or(Duration::from_secs(5)))
} else {
None
}
}
LanguageModelCompletionError::ApiReadResponseError { .. }
| LanguageModelCompletionError::ApiInternalServerError { .. }
| LanguageModelCompletionError::HttpSend { .. } => {
Some(Duration::from_secs(2_u64.pow((attempt - 1) as u32).min(30)))
}
_ => None,
},
_ => None,
},
};
if let Some(retry_after) = retry_delay {
let jitter = retry_after.mul_f64(rand::rng().random_range(0.0..1.0));
eprintln!("Attempt #{attempt}: Retry after {retry_after:?} + jitter of {jitter:?}");
#[allow(clippy::disallowed_methods)]
async_io::Timer::after(retry_after + jitter).await;
} else {
return response;
}
}
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_create_file() {
let input_file_path = "root/TODO3";
let expected_output_content = "todo".to_string();
eval_utils::eval(100, 1., eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![
message(
User,
[text("Create a third todo file. Write 'todo' inside it.")],
),
message(
Assistant,
[
text(indoc::formatdoc! {"
I'll help you create a third empty todo file.
First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one.
"}),
tool_use(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
ListDirectoryToolInput {
path: "root".to_string(),
},
),
],
),
message(
User,
[tool_result(
"toolu_01GAF8TtsgpjKxCr8fgQLDgR",
ListDirectoryTool::NAME,
"root/TODO\nroot/TODO2\nroot/new.txt\n",
)],
),
],
input_file_path,
None,
expected_output_content.clone(),
))
});
}
#[test]
#[cfg_attr(not(feature = "unit-eval"), ignore)]
fn eval_overwrite_file() {
let input_file_path = "root/notes.txt";
let input_file_content = "old notes\nkeep nothing\n".to_string();
let expected_output_content = "new notes".to_string();
eval_utils::eval(100, 1., eval_utils::NoProcessor, move || {
run_eval(EvalInput::new(
vec![message(
User,
[text(indoc::formatdoc! {"
Overwrite `{input_file_path}` so that its complete contents are exactly: 'new notes'
"})],
)],
input_file_path,
Some(input_file_content.clone()),
expected_output_content.clone(),
))
});
}

View file

@ -184,6 +184,13 @@ impl AgentTool for ReadFileTool {
anyhow::Ok(()) anyhow::Ok(())
}).map_err(tool_content_err)?; }).map_err(tool_content_err)?;
if fs.is_dir(&abs_path).await {
return Err(tool_content_err(format!(
"{} is a directory, not a file. Use the list_directory tool to explore directory contents.",
&input.path
)));
}
if let Some(canonical_target) = &symlink_canonical_target { if let Some(canonical_target) = &symlink_canonical_target {
let authorize = cx.update(|cx| { let authorize = cx.update(|cx| {
authorize_symlink_access( authorize_symlink_access(
@ -356,6 +363,39 @@ mod test {
use std::sync::Arc; use std::sync::Arc;
use util::path; use util::path;
#[gpui::test]
async fn test_read_directory_path(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"some_dir": {}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(ReadFileTool::new(project, action_log, true));
let (event_stream, _) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
let input = ReadFileToolInput {
path: "root/some_dir".to_string(),
start_line: None,
end_line: None,
};
tool.run(ToolInput::resolved(input), event_stream, cx)
})
.await;
assert_eq!(
error_text(result.unwrap_err()),
"root/some_dir is a directory, not a file. Use the list_directory tool to explore directory contents."
);
}
#[gpui::test] #[gpui::test]
async fn test_read_nonexistent_file(cx: &mut TestAppContext) { async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);

File diff suppressed because it is too large Load diff

View file

@ -413,6 +413,7 @@ fn enqueue_notification<Notif>(
pub struct AcpConnection { pub struct AcpConnection {
id: AgentId, id: AgentId,
telemetry_id: SharedString, telemetry_id: SharedString,
agent_version: Option<SharedString>,
connection: ConnectionTo<Agent>, connection: ConnectionTo<Agent>,
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>, sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
pending_sessions: Rc<RefCell<HashMap<acp::SessionId, PendingAcpSession>>>, pending_sessions: Rc<RefCell<HashMap<acp::SessionId, PendingAcpSession>>>,
@ -900,12 +901,15 @@ impl AcpConnection {
} }
}); });
let telemetry_id = response let agent_info = response.agent_info;
.agent_info let telemetry_id = agent_info
.as_ref()
// Use the one the agent provides if we have one // Use the one the agent provides if we have one
.map(|info| info.name.into()) .map(|info| SharedString::from(info.name.clone()))
// Otherwise, just use the name // Otherwise, just use the name
.unwrap_or_else(|| agent_id.0.clone()); .unwrap_or_else(|| agent_id.0.clone());
let agent_version = agent_info
.and_then(|info| (!info.version.is_empty()).then(|| SharedString::from(info.version)));
let session_list = if response let session_list = if response
.agent_capabilities .agent_capabilities
@ -945,6 +949,7 @@ impl AcpConnection {
agent_server_store, agent_server_store,
connection, connection,
telemetry_id, telemetry_id,
agent_version,
sessions, sessions,
pending_sessions: Rc::new(RefCell::new(HashMap::default())), pending_sessions: Rc::new(RefCell::new(HashMap::default())),
agent_capabilities: response.agent_capabilities, agent_capabilities: response.agent_capabilities,
@ -978,6 +983,7 @@ impl AcpConnection {
Self { Self {
id: AgentId::new("test"), id: AgentId::new("test"),
telemetry_id: "test".into(), telemetry_id: "test".into(),
agent_version: None,
connection, connection,
sessions, sessions,
pending_sessions: Rc::new(RefCell::new(HashMap::default())), pending_sessions: Rc::new(RefCell::new(HashMap::default())),
@ -1319,6 +1325,10 @@ impl AgentConnection for AcpConnection {
self.telemetry_id.clone() self.telemetry_id.clone()
} }
fn agent_version(&self) -> Option<SharedString> {
self.agent_version.clone()
}
fn new_session( fn new_session(
self: Rc<Self>, self: Rc<Self>,
project: Entity<Project>, project: Entity<Project>,
@ -1984,6 +1994,10 @@ pub mod test_support {
self.inner.telemetry_id() self.inner.telemetry_id()
} }
fn agent_version(&self) -> Option<SharedString> {
self.inner.agent_version()
}
fn new_session( fn new_session(
self: Rc<Self>, self: Rc<Self>,
project: Entity<Project>, project: Entity<Project>,

View file

@ -1135,10 +1135,13 @@ impl AgentConfiguration {
id: agent_server_name.clone(), id: agent_server_name.clone(),
}; };
let connection_status = self let (connection_status, running_version) = {
.agent_connection_store let connection_store = self.agent_connection_store.read(cx);
.read(cx) (
.connection_status(&agent, cx); connection_store.connection_status(&agent, cx),
connection_store.agent_version(&agent, cx),
)
};
let restart_button = matches!( let restart_button = matches!(
connection_status, connection_status,
@ -1252,6 +1255,7 @@ impl AgentConfiguration {
AiSettingItem::new(id, display_name, status, source_kind) AiSettingItem::new(id, display_name, status, source_kind)
.icon(icon) .icon(icon)
.when_some(running_version, |this, version| this.detail_label(version))
.when_some(restart_button, |this, button| this.action(button)) .when_some(restart_button, |this, button| this.action(button))
.when_some(uninstall_button, |this, button| this.action(button)) .when_some(uninstall_button, |this, button| this.action(button))
} }

View file

@ -97,6 +97,13 @@ impl AgentConnectionStore {
.unwrap_or(AgentConnectionStatus::Disconnected) .unwrap_or(AgentConnectionStatus::Disconnected)
} }
pub fn agent_version(&self, key: &Agent, cx: &App) -> Option<SharedString> {
match self.entries.get(key)?.read(cx) {
AgentConnectionEntry::Connected(state) => state.connection.agent_version(),
AgentConnectionEntry::Connecting { .. } | AgentConnectionEntry::Error { .. } => None,
}
}
pub fn active_acp_connections(&self, cx: &App) -> Vec<ActiveAcpConnection> { pub fn active_acp_connections(&self, cx: &App) -> Vec<ActiveAcpConnection> {
self.entries self.entries
.values() .values()

View file

@ -677,7 +677,7 @@ impl MessageEditor {
} }
pub fn is_empty(&self, cx: &App) -> bool { pub fn is_empty(&self, cx: &App) -> bool {
self.editor.read(cx).is_empty(cx) self.editor.read(cx).text(cx).trim().is_empty()
} }
pub fn is_completions_menu_visible(&self, cx: &App) -> bool { pub fn is_completions_menu_visible(&self, cx: &App) -> bool {

View file

@ -21,8 +21,8 @@ CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"
CREATE TABLE "contacts" ( CREATE TABLE "contacts" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id_a" INTEGER REFERENCES users (id) NOT NULL, "user_id_a" INTEGER NOT NULL,
"user_id_b" INTEGER REFERENCES users (id) NOT NULL, "user_id_b" INTEGER NOT NULL,
"a_to_b" BOOLEAN NOT NULL, "a_to_b" BOOLEAN NOT NULL,
"should_notify" BOOLEAN NOT NULL, "should_notify" BOOLEAN NOT NULL,
"accepted" BOOLEAN NOT NULL "accepted" BOOLEAN NOT NULL
@ -44,7 +44,7 @@ CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
CREATE TABLE "projects" ( CREATE TABLE "projects" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE, "room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE,
"host_user_id" INTEGER REFERENCES users (id), "host_user_id" INTEGER,
"host_connection_id" INTEGER, "host_connection_id" INTEGER,
"host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE, "host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
"unregistered" BOOLEAN NOT NULL DEFAULT FALSE, "unregistered" BOOLEAN NOT NULL DEFAULT FALSE,
@ -208,14 +208,14 @@ CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and
CREATE TABLE "room_participants" ( CREATE TABLE "room_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER NOT NULL REFERENCES rooms (id), "room_id" INTEGER NOT NULL REFERENCES rooms (id),
"user_id" INTEGER NOT NULL REFERENCES users (id), "user_id" INTEGER NOT NULL,
"answering_connection_id" INTEGER, "answering_connection_id" INTEGER,
"answering_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE, "answering_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
"answering_connection_lost" BOOLEAN NOT NULL, "answering_connection_lost" BOOLEAN NOT NULL,
"location_kind" INTEGER, "location_kind" INTEGER,
"location_project_id" INTEGER, "location_project_id" INTEGER,
"initial_project_id" INTEGER, "initial_project_id" INTEGER,
"calling_user_id" INTEGER NOT NULL REFERENCES users (id), "calling_user_id" INTEGER NOT NULL,
"calling_connection_id" INTEGER NOT NULL, "calling_connection_id" INTEGER NOT NULL,
"calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL, "calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL,
"participant_index" INTEGER, "participant_index" INTEGER,
@ -279,7 +279,7 @@ CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_pa
CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL REFERENCES users (id), "user_id" INTEGER NOT NULL,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL, "connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE
@ -290,7 +290,7 @@ CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_pa
CREATE TABLE "channel_members" ( CREATE TABLE "channel_members" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "user_id" INTEGER NOT NULL,
"role" VARCHAR NOT NULL, "role" VARCHAR NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false, "accepted" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP NOT NULL DEFAULT now "updated_at" TIMESTAMP NOT NULL DEFAULT now
@ -332,7 +332,7 @@ CREATE TABLE "channel_buffer_collaborators" (
"connection_id" INTEGER NOT NULL, "connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"connection_lost" BOOLEAN NOT NULL DEFAULT false, "connection_lost" BOOLEAN NOT NULL DEFAULT false,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "user_id" INTEGER NOT NULL,
"replica_id" INTEGER NOT NULL "replica_id" INTEGER NOT NULL
); );
@ -351,7 +351,7 @@ CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection
); );
CREATE TABLE "observed_buffer_edits" ( CREATE TABLE "observed_buffer_edits" (
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "user_id" INTEGER NOT NULL,
"buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE,
"epoch" INTEGER NOT NULL, "epoch" INTEGER NOT NULL,
"lamport_timestamp" INTEGER NOT NULL, "lamport_timestamp" INTEGER NOT NULL,
@ -371,7 +371,7 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" (
CREATE TABLE "notifications" ( CREATE TABLE "notifications" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT, "id" INTEGER PRIMARY KEY AUTOINCREMENT,
"created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP, "created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
"recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "recipient_id" INTEGER NOT NULL,
"kind" INTEGER NOT NULL REFERENCES notification_kinds (id), "kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
"entity_id" INTEGER, "entity_id" INTEGER,
"content" TEXT, "content" TEXT,
@ -382,7 +382,7 @@ CREATE TABLE "notifications" (
CREATE INDEX "index_notifications_on_recipient_id_is_read_kind_entity_id" ON "notifications" ("recipient_id", "is_read", "kind", "entity_id"); CREATE INDEX "index_notifications_on_recipient_id_is_read_kind_entity_id" ON "notifications" ("recipient_id", "is_read", "kind", "entity_id");
CREATE TABLE contributors ( CREATE TABLE contributors (
user_id INTEGER REFERENCES users (id), user_id INTEGER,
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id) PRIMARY KEY (user_id)
); );
@ -437,7 +437,7 @@ CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id");
CREATE TABLE IF NOT EXISTS "shared_threads" ( CREATE TABLE IF NOT EXISTS "shared_threads" (
"id" TEXT PRIMARY KEY NOT NULL, "id" TEXT PRIMARY KEY NOT NULL,
"user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "user_id" INTEGER NOT NULL,
"title" VARCHAR(512) NOT NULL, "title" VARCHAR(512) NOT NULL,
"data" BLOB NOT NULL, "data" BLOB NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

View file

@ -684,6 +684,26 @@ impl Database {
.await .await
} }
/// Returns the channel memberships for the users with the specified IDs.
#[cfg(feature = "test-support")]
pub async fn get_channel_memberships_for_user_ids(
&self,
channel: &Channel,
ids: Vec<UserId>,
) -> Result<Vec<channel_member::Model>> {
self.transaction(|tx| async {
let tx = tx;
let members = channel_member::Entity::find()
.filter(channel_member::Column::ChannelId.eq(channel.id))
.filter(channel_member::Column::UserId.is_in(ids.iter().copied()))
.all(&*tx)
.await?;
Ok(members)
})
.await
}
/// Returns the details for the specified channel member. /// Returns the details for the specified channel member.
pub async fn get_channel_participant_details( pub async fn get_channel_participant_details(
&self, &self,

View file

@ -25,6 +25,8 @@ impl From<Model> for crate::entities::User {
crate::entities::User { crate::entities::User {
id: user.id, id: user.id,
github_login: user.github_login, github_login: user.github_login,
github_user_id: user.github_user_id,
name: user.name,
admin: user.admin, admin: user.admin,
connected_once: user.connected_once, connected_once: user.connected_once,
} }

View file

@ -4,6 +4,8 @@ use crate::db::UserId;
pub struct User { pub struct User {
pub id: UserId, pub id: UserId,
pub github_login: String, pub github_login: String,
pub github_user_id: i32,
pub name: Option<String>,
pub admin: bool, pub admin: bool,
pub connected_once: bool, pub connected_once: bool,
} }

View file

@ -6,6 +6,7 @@ pub mod env;
pub mod executor; pub mod executor;
pub mod rpc; pub mod rpc;
pub mod seed; pub mod seed;
pub mod services;
use anyhow::Context as _; use anyhow::Context as _;
use aws_config::{BehaviorVersion, Region}; use aws_config::{BehaviorVersion, Region};
@ -19,6 +20,8 @@ use serde::Deserialize;
use std::{path::PathBuf, sync::Arc}; use std::{path::PathBuf, sync::Arc};
use util::ResultExt; use util::ResultExt;
use crate::services::{DatabaseUserService, UserService};
pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA"); pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
@ -216,6 +219,7 @@ pub struct AppState {
pub blob_store_client: Option<aws_sdk_s3::Client>, pub blob_store_client: Option<aws_sdk_s3::Client>,
pub executor: Executor, pub executor: Executor,
pub kinesis_client: Option<::aws_sdk_kinesis::Client>, pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
pub user_service: Arc<dyn UserService>,
pub config: Config, pub config: Config,
} }
@ -259,6 +263,7 @@ impl AppState {
} else { } else {
None None
}, },
user_service: Arc::new(DatabaseUserService::new(db)),
config, config,
}; };
Ok(Arc::new(this)) Ok(Arc::new(this))

View file

@ -2541,10 +2541,11 @@ async fn get_users(
.map(UserId::from_proto) .map(UserId::from_proto)
.collect(); .collect();
let users = session let users = session
.db() .app_state
.await .user_service
.get_users_by_ids(user_ids) .get_users_by_ids(user_ids)
.await? .await?;
let users = users
.into_iter() .into_iter()
.map(|user| proto::User { .map(|user| proto::User {
id: user.id.to_proto(), id: user.id.to_proto(),
@ -2567,13 +2568,19 @@ async fn fuzzy_search_users(
let users = match query.len() { let users = match query.len() {
0 => vec![], 0 => vec![],
1 | 2 => session 1 | 2 => session
.db() .app_state
.await .user_service
.get_user_by_github_login(&query) .get_user_by_github_login(&query)
.await? .await?
.into_iter() .into_iter()
.collect(), .collect(),
_ => session.db().await.fuzzy_search_users(&query, 10).await?, _ => {
session
.app_state
.user_service
.fuzzy_search_users(&query, 10)
.await?
}
}; };
let users = users let users = users
.into_iter() .into_iter()
@ -3163,13 +3170,11 @@ async fn get_channel_members(
let channel = db.get_channel(channel_id, session.user_id()).await?; let channel = db.get_channel(channel_id, session.user_id()).await?;
let (members, users) = db let (members, users) = session
.get_channel_participant_details(&channel, &request.query, limit) .app_state
.user_service
.search_channel_members(&channel, &request.query, limit as u32)
.await?; .await?;
let members = members
.into_iter()
.map(proto::ChannelMember::from)
.collect();
let users = users.into_iter().map(proto::User::from).collect(); let users = users.into_iter().map(proto::User::from).collect();
response.send(proto::GetChannelMembersResponse { members, users })?; response.send(proto::GetChannelMembersResponse { members, users })?;
@ -4081,3 +4086,17 @@ where
} }
} }
} }
impl From<User> for proto::User {
fn from(user: User) -> Self {
Self {
id: user.id.to_proto(),
avatar_url: format!(
"https://avatars.githubusercontent.com/u/{}?s=128&v=4",
user.github_user_id
),
github_login: user.github_login,
name: user.name,
}
}
}

View file

@ -0,0 +1,3 @@
mod user_service;
pub use user_service::*;

View file

@ -0,0 +1,243 @@
use std::sync::Arc;
use async_trait::async_trait;
use rpc::proto;
use crate::Result;
use crate::db::{Channel, Database, UserId};
use crate::entities::User;
#[cfg(feature = "test-support")]
pub use self::fake_user_service::*;
#[async_trait]
pub trait UserService: Send + Sync + 'static {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
// NOTE: This method is only tangentially related to users, but we're putting it on the `UserService` to avoid
// introducing a separate service.
//
// We're also using the `proto::ChannelMember` representation in the return type, as we don't yet have a domain
// representation of a channel member (and doesn't seem necessary to introduce one, at this point).
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)>;
#[cfg(feature = "test-support")]
fn as_fake(&self) -> Arc<FakeUserService> {
panic!("called as_fake on a real `UserService`");
}
}
/// A [`UserService`] implementation backed by the database.
pub struct DatabaseUserService {
database: Arc<Database>,
}
impl DatabaseUserService {
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
}
#[async_trait]
impl UserService for DatabaseUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let users = self.database.get_users_by_ids(ids).await?;
Ok(users.into_iter().map(User::from).collect())
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let user = self.database.get_user_by_github_login(github_login).await?;
Ok(user.map(User::from))
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let users = self.database.fuzzy_search_users(query, limit).await?;
Ok(users.into_iter().map(User::from).collect())
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let (members, users) = self
.database
.get_channel_participant_details(channel, query, limit as u64)
.await?;
Ok((
members
.into_iter()
.map(proto::ChannelMember::from)
.collect(),
users.into_iter().map(User::from).collect(),
))
}
}
#[cfg(feature = "test-support")]
mod fake_user_service {
use std::sync::Weak;
use collections::HashMap;
use tokio::sync::Mutex;
use super::*;
#[derive(Debug)]
pub struct NewUserParams {
pub github_login: String,
pub github_user_id: i32,
}
pub struct FakeUserService {
this: Weak<Self>,
state: Arc<Mutex<FakeUserServiceState>>,
database: Arc<Database>,
}
struct FakeUserServiceState {
next_user_id: UserId,
users: HashMap<UserId, User>,
}
impl Default for FakeUserServiceState {
fn default() -> Self {
Self {
next_user_id: UserId(1),
users: HashMap::default(),
}
}
}
impl FakeUserService {
pub fn new(database: Arc<Database>) -> Arc<Self> {
Arc::new_cyclic(|this| Self {
this: this.clone(),
state: Arc::new(Mutex::default()),
database,
})
}
pub async fn create_user(
&self,
email_address: &str,
name: Option<&str>,
admin: bool,
params: NewUserParams,
) -> UserId {
let mut state = self.state.lock().await;
let user_id = state.next_user_id;
let _ = email_address;
state.users.insert(
user_id,
User {
id: user_id,
github_login: params.github_login,
github_user_id: params.github_user_id,
name: name.map(|name| name.to_string()),
admin,
connected_once: false,
},
);
state.next_user_id = UserId(state.next_user_id.0 + 1);
user_id
}
pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
let state = self.state.lock().await;
let user = state.users.get(&id).cloned();
Ok(user)
}
}
#[async_trait]
impl UserService for FakeUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let state = self.state.lock().await;
let users = state
.users
.values()
.filter(|user| ids.contains(&user.id))
.cloned()
.collect();
Ok(users)
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let state = self.state.lock().await;
let user = state
.users
.values()
.find(|user| user.github_login == github_login)
.cloned();
Ok(user)
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let _ = query;
let _ = limit;
unimplemented!("not currently exercised by any tests")
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let state = self.state.lock().await;
let users = state
.users
.values()
.filter(|user| user.github_login.contains(query))
.take(limit as usize)
.cloned()
.collect::<Vec<_>>();
let members = self
.database
.get_channel_memberships_for_user_ids(
channel,
users.iter().map(|user| user.id).collect(),
)
.await?;
Ok((
members
.into_iter()
.map(proto::ChannelMember::from)
.collect(),
users,
))
}
#[cfg(feature = "test-support")]
fn as_fake(&self) -> Arc<FakeUserService> {
self.this.upgrade().unwrap()
}
}
}

View file

@ -281,7 +281,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes
// User B signs the zed CLA. // User B signs the zed CLA.
let user_b = server let user_b = server
.app_state .app_state
.db .user_service
.get_user_by_github_login("user_b") .get_user_by_github_login("user_b")
.await .await
.unwrap() .unwrap()

View file

@ -2381,3 +2381,106 @@ async fn test_following_while_deactivated(cx_a: &mut TestAppContext, cx_b: &mut
assert_eq!(editor.tab_content_text(0, cx), "2.js"); assert_eq!(editor.tab_content_text(0, cx), "2.js");
}); });
} }
#[gpui::test(iterations = 10)]
async fn test_following_with_multibuffer_excerpts_at_unobserved_lamport(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let executor = cx_a.executor();
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
cx_a.update(editor::init);
cx_b.update(editor::init);
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
client_a
.fs()
.insert_tree(path!("/a"), json!({ "1.txt": sample_text(20, 5, 'a') }))
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("1.txt")), cx)
})
.await
.unwrap();
// B must already have the buffer open at a low Lamport so that A's
// subsequent edits create anchors B hasn't observed.
let _buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("1.txt")), cx)
})
.await
.unwrap();
workspace_b.update_in(cx_b, |workspace, window, cx| {
workspace.follow(client_a.peer_id().unwrap(), window, cx)
});
executor.run_until_parked();
buffer_a.update(cx_a, |buf, cx| {
for i in 0..30 {
let len = buf.len();
buf.edit([(len..len, format!("\nappended line {i}"))], None, cx);
}
});
let multibuffer_a = cx_a.new(|cx| {
let mut mb = MultiBuffer::new(Capability::ReadWrite);
let max_row = buffer_a.read(cx).max_point().row;
mb.set_excerpts_for_path(
PathKey::for_buffer(&buffer_a, cx),
buffer_a.clone(),
[Point::row_range(max_row.saturating_sub(5)..max_row)],
1,
cx,
);
mb
});
workspace_a.update_in(cx_a, |workspace, window, cx| {
let editor = cx
.new(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), window, cx));
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
});
executor.run_until_parked();
let active_text = |workspace: &Entity<Workspace>, cx: &mut VisualTestContext| {
workspace.update(cx, |workspace, cx| {
workspace
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
.update(cx, |editor, cx| editor.text(cx))
})
};
assert_eq!(
active_text(&workspace_a, cx_a),
active_text(&workspace_b, cx_b)
);
}

View file

@ -7,9 +7,10 @@ use client::{
proto::PeerId, proto::PeerId,
}; };
use clock::FakeSystemClock; use clock::FakeSystemClock;
use collab::services::{FakeUserService, NewUserParams};
use collab::{ use collab::{
AppState, Config, AppState, Config,
db::{NewUserParams, UserId}, db::UserId,
executor::Executor, executor::Executor,
rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion}, rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion},
}; };
@ -179,14 +180,19 @@ impl TestServer {
let clock = Arc::new(FakeSystemClock::new()); let clock = Arc::new(FakeSystemClock::new());
let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await let user_id = if let Ok(Some(user)) = self
.app_state
.user_service
.get_user_by_github_login(name)
.await
{ {
user.id user.id
} else { } else {
let github_user_id = self.next_github_user_id; let github_user_id = self.next_github_user_id;
self.next_github_user_id += 1; self.next_github_user_id += 1;
self.app_state self.app_state
.db .user_service
.as_fake()
.create_user( .create_user(
&format!("{name}@example.com"), &format!("{name}@example.com"),
None, None,
@ -197,8 +203,6 @@ impl TestServer {
}, },
) )
.await .await
.expect("creating user failed")
.user_id
}; };
let http = FakeHttpClient::create({ let http = FakeHttpClient::create({
@ -244,7 +248,7 @@ impl TestServer {
let client_name = name.to_string(); let client_name = name.to_string();
let client = cx.update(|cx| Client::new(clock, http.clone(), cx)); let client = cx.update(|cx| Client::new(clock, http.clone(), cx));
let server = self.server.clone(); let server = self.server.clone();
let db = self.app_state.db.clone(); let user_service = self.app_state.user_service.clone();
let connection_killers = self.connection_killers.clone(); let connection_killers = self.connection_killers.clone();
let forbid_connections = self.forbid_connections.clone(); let forbid_connections = self.forbid_connections.clone();
@ -268,7 +272,7 @@ impl TestServer {
); );
let server = server.clone(); let server = server.clone();
let db = db.clone(); let user_service = user_service.clone();
let connection_killers = connection_killers.clone(); let connection_killers = connection_killers.clone();
let forbid_connections = forbid_connections.clone(); let forbid_connections = forbid_connections.clone();
let client_name = client_name.clone(); let client_name = client_name.clone();
@ -281,7 +285,8 @@ impl TestServer {
let (client_conn, server_conn, killed) = let (client_conn, server_conn, killed) =
Connection::in_memory(cx.background_executor().clone()); Connection::in_memory(cx.background_executor().clone());
let (connection_id_tx, connection_id_rx) = oneshot::channel(); let (connection_id_tx, connection_id_rx) = oneshot::channel();
let user = db let user = user_service
.as_fake()
.get_user_by_id(user_id) .get_user_by_id(user_id)
.await .await
.map_err(|e| { .map_err(|e| {
@ -294,7 +299,7 @@ impl TestServer {
cx.background_spawn(server.handle_connection( cx.background_spawn(server.handle_connection(
server_conn, server_conn,
client_name, client_name,
Principal::User(user.into()), Principal::User(user),
ZedVersion(semver::Version::new(1, 0, 0)), ZedVersion(semver::Version::new(1, 0, 0)),
Some("test".to_string()), Some("test".to_string()),
None, None,
@ -576,6 +581,7 @@ impl TestServer {
blob_store_client: None, blob_store_client: None,
executor, executor,
kinesis_client: None, kinesis_client: None,
user_service: FakeUserService::new(test_db.db().clone()),
config: Config { config: Config {
http_port: 0, http_port: 0,
database_url: "".into(), database_url: "".into(),

View file

@ -17,7 +17,6 @@ test-support = ["gpui/test-support"]
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
async-channel.workspace = true async-channel.workspace = true
async-process.workspace = true
async-trait.workspace = true async-trait.workspace = true
base64.workspace = true base64.workspace = true
collections.workspace = true collections.workspace = true

View file

@ -131,6 +131,7 @@ struct Notification<'a, T> {
jsonrpc: &'static str, jsonrpc: &'static str,
#[serde(borrow)] #[serde(borrow)]
method: &'a str, method: &'a str,
#[serde(skip_serializing_if = "is_null_value")]
params: T, params: T,
} }

View file

@ -1,15 +1,16 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use anyhow::{Context as _, Result}; use anyhow::Result;
use async_process::Child;
use async_trait::async_trait; use async_trait::async_trait;
use futures::io::{BufReader, BufWriter}; use futures::io::{BufReader, BufWriter};
use futures::{ use futures::{
AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _, AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _,
}; };
use gpui::AsyncApp; use gpui::AsyncApp;
use util::TryFutureExt as _; use util::TryFutureExt as _;
use util::process::Child;
use util::shell::Shell; use util::shell::Shell;
use util::shell_builder::ShellBuilder; use util::shell_builder::ShellBuilder;
@ -31,22 +32,20 @@ impl StdioTransport {
) -> Result<Self> { ) -> Result<Self> {
let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive(); let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive();
let mut command = let mut command =
builder.build_smol_command(Some(binary.executable.display().to_string()), &binary.args); builder.build_std_command(Some(binary.executable.display().to_string()), &binary.args);
command command.envs(binary.env.unwrap_or_default());
.envs(binary.env.unwrap_or_default())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
if let Some(working_directory) = working_directory { if let Some(working_directory) = working_directory {
command.current_dir(working_directory); command.current_dir(working_directory);
} }
let mut server = command let mut server = Child::spawn(
.spawn() command,
.with_context(|| format!("failed to spawn command {command:?})",))?; std::process::Stdio::piped(),
std::process::Stdio::piped(),
std::process::Stdio::piped(),
)?;
let stdin = server.stdin.take().unwrap(); let stdin = server.stdin.take().unwrap();
let stdout = server.stdout.take().unwrap(); let stdout = server.stdout.take().unwrap();

View file

@ -24,8 +24,7 @@ use zeta_prompt::{ParsedOutput, ZetaPromptInput};
use std::{env, ops::Range, path::Path, sync::Arc}; use std::{env, ops::Range, path::Path, sync::Arc};
use zeta_prompt::{ use zeta_prompt::{
ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, stop_tokens_for_format,
prompt_input_contains_special_tokens, stop_tokens_for_format,
zeta1::{self, EDITABLE_REGION_END_MARKER}, zeta1::{self, EDITABLE_REGION_END_MARKER},
}; };
@ -120,10 +119,6 @@ pub fn request_prediction_with_zeta(
repo_url, repo_url,
); );
if prompt_input_contains_special_tokens(&prompt_input, zeta_version) {
return Err(anyhow::anyhow!("prompt contains special tokens"));
}
let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_version); let formatted_prompt = format_zeta_prompt(&prompt_input, zeta_version);
if let Some(debug_tx) = &debug_tx { if let Some(debug_tx) = &debug_tx {

View file

@ -12,7 +12,7 @@ workspace = true
path = "src/edit_prediction_metrics.rs" path = "src/edit_prediction_metrics.rs"
[dependencies] [dependencies]
language.workspace = true imara-diff.workspace = true
serde.workspace = true serde.workspace = true
serde_json = "1.0" serde_json = "1.0"
similar = "2.7.0" similar = "2.7.0"

View file

@ -218,7 +218,9 @@ pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore {
for expected in input.expected_patches { for expected in input.expected_patches {
let delta_chr_f_metrics = delta_chr_f(input.original_text, &expected.text, &actual_text); let delta_chr_f_metrics = delta_chr_f(input.original_text, &expected.text, &actual_text);
if delta_chr_f_metrics.score > best_delta_chr_f_metrics.score { if best_expected_text.is_none()
|| delta_chr_f_metrics.score > best_delta_chr_f_metrics.score
{
best_delta_chr_f_metrics = delta_chr_f_metrics; best_delta_chr_f_metrics = delta_chr_f_metrics;
best_expected_cursor = expected.cursor_editable_region_offset; best_expected_cursor = expected.cursor_editable_region_offset;
best_expected_text = Some(expected.text.as_str()); best_expected_text = Some(expected.text.as_str());
@ -317,3 +319,33 @@ fn compute_cursor_metrics(
(Some(_), None) | (None, Some(_)) => (None, Some(false)), (Some(_), None) | (None, Some(_)) => (None, Some(false)),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kept_rate_is_computed_when_best_delta_chr_f_score_is_zero() {
let original_text = "";
let actual_patch = "--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+bbbbbb\n";
let expected_patch = "--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+cccccc\n";
let expected_patches = [PreparedExpectedPatch {
patch: expected_patch.to_string(),
text: "cccccc".to_string(),
cursor_editable_region_offset: None,
}];
let score = score_prediction(PredictionScoringInput {
original_text,
expected_patches: &expected_patches,
actual_patch: Some(actual_patch),
actual_cursor: None,
reversal_context: None,
cumulative_logprob: None,
avg_logprob: None,
});
assert_eq!(score.delta_chr_f, 0.0);
assert_eq!(score.kept_rate, Some(0.0));
}
}

View file

@ -1,10 +1,155 @@
use std::iter;
use std::ops::Range; use std::ops::Range;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use language::{char_diff, text_diff}; use crate::tokenize::tokenize;
use imara_diff::{
Algorithm, diff,
intern::{InternedInput, Token},
sources::lines_with_terminator,
};
use zeta_prompt::udiff::apply_diff_to_string; use zeta_prompt::udiff::apply_diff_to_string;
fn text_diff(old_text: &str, new_text: &str) -> Vec<(Range<usize>, Arc<str>)> {
let empty: Arc<str> = Arc::default();
let mut edits = Vec::new();
let mut hunk_input = InternedInput::default();
let input = InternedInput::new(
lines_with_terminator(old_text),
lines_with_terminator(new_text),
);
diff_internal(&input, &mut |old_byte_range,
new_byte_range,
old_rows,
new_rows| {
if should_perform_token_diff_within_hunk(
&old_byte_range,
&new_byte_range,
&old_rows,
&new_rows,
) {
let old_offset = old_byte_range.start;
let new_offset = new_byte_range.start;
hunk_input.clear();
hunk_input.update_before(tokenize(&old_text[old_byte_range]).into_iter());
hunk_input.update_after(tokenize(&new_text[new_byte_range]).into_iter());
diff_internal(&hunk_input, &mut |old_byte_range, new_byte_range, _, _| {
let old_byte_range =
old_offset + old_byte_range.start..old_offset + old_byte_range.end;
let new_byte_range =
new_offset + new_byte_range.start..new_offset + new_byte_range.end;
let replacement_text = if new_byte_range.is_empty() {
empty.clone()
} else {
new_text[new_byte_range].into()
};
edits.push((old_byte_range, replacement_text));
});
} else {
let replacement_text = if new_byte_range.is_empty() {
empty.clone()
} else {
new_text[new_byte_range].into()
};
edits.push((old_byte_range, replacement_text));
}
});
edits
}
fn char_diff<'a>(old_text: &'a str, new_text: &'a str) -> Vec<(Range<usize>, &'a str)> {
let mut input: InternedInput<&str> = InternedInput::default();
input.update_before(tokenize_chars(old_text));
input.update_after(tokenize_chars(new_text));
let mut edits = Vec::new();
diff_internal(&input, &mut |old_byte_range, new_byte_range, _, _| {
let replacement = if new_byte_range.is_empty() {
""
} else {
&new_text[new_byte_range]
};
edits.push((old_byte_range, replacement));
});
edits
}
fn should_perform_token_diff_within_hunk(
old_byte_range: &Range<usize>,
new_byte_range: &Range<usize>,
old_row_range: &Range<u32>,
new_row_range: &Range<u32>,
) -> bool {
const MAX_TOKEN_DIFF_LEN: usize = 512;
const MAX_TOKEN_DIFF_LINE_COUNT: usize = 8;
!old_byte_range.is_empty()
&& !new_byte_range.is_empty()
&& old_byte_range.len() <= MAX_TOKEN_DIFF_LEN
&& new_byte_range.len() <= MAX_TOKEN_DIFF_LEN
&& old_row_range.len() <= MAX_TOKEN_DIFF_LINE_COUNT
&& new_row_range.len() <= MAX_TOKEN_DIFF_LINE_COUNT
}
fn diff_internal(
input: &InternedInput<&str>,
on_change: &mut dyn FnMut(Range<usize>, Range<usize>, Range<u32>, Range<u32>),
) {
let mut old_offset = 0;
let mut new_offset = 0;
let mut old_token_ix = 0;
let mut new_token_ix = 0;
diff(
Algorithm::Histogram,
input,
|old_tokens: Range<u32>, new_tokens: Range<u32>| {
old_offset += token_len(
input,
&input.before[old_token_ix as usize..old_tokens.start as usize],
);
new_offset += token_len(
input,
&input.after[new_token_ix as usize..new_tokens.start as usize],
);
let old_len = token_len(
input,
&input.before[old_tokens.start as usize..old_tokens.end as usize],
);
let new_len = token_len(
input,
&input.after[new_tokens.start as usize..new_tokens.end as usize],
);
let old_byte_range = old_offset..old_offset + old_len;
let new_byte_range = new_offset..new_offset + new_len;
old_token_ix = old_tokens.end;
new_token_ix = new_tokens.end;
old_offset = old_byte_range.end;
new_offset = new_byte_range.end;
on_change(old_byte_range, new_byte_range, old_tokens, new_tokens);
},
);
}
fn tokenize_chars(text: &str) -> impl Iterator<Item = &str> {
let mut chars = text.char_indices();
iter::from_fn(move || {
let (start, character) = chars.next()?;
Some(&text[start..start + character.len_utf8()])
})
}
fn token_len(input: &InternedInput<&str>, tokens: &[Token]) -> usize {
tokens
.iter()
.map(|token| input.interner[*token].len())
.sum()
}
fn apply_diff_to_string_lenient(diff_str: &str, text: &str) -> String { fn apply_diff_to_string_lenient(diff_str: &str, text: &str) -> String {
let hunks = parse_diff_hunks(diff_str); let hunks = parse_diff_hunks(diff_str);
let mut result = text.to_string(); let mut result = text.to_string();
@ -651,7 +796,7 @@ pub fn compute_prediction_reversal_ratio_from_history(
mod tests { mod tests {
use super::*; use super::*;
use indoc::indoc; use indoc::indoc;
use zeta_prompt::udiff::apply_diff_to_string; use zeta_prompt::udiff::{apply_diff_to_string, unified_diff_with_context};
use zeta_prompt::{ExcerptRanges, ZetaPromptInput}; use zeta_prompt::{ExcerptRanges, ZetaPromptInput};
fn compute_prediction_reversal_ratio( fn compute_prediction_reversal_ratio(
@ -1008,8 +1153,8 @@ mod tests {
last line last line
"}; "};
// unified_diff doesn't include file headers, but apply_diff_to_string needs them // unified_diff_with_context doesn't include file headers, but apply_diff_to_string needs them
let diff_body = language::unified_diff(original, modified); let diff_body = unified_diff_with_context(original, modified, 0, 0, 3);
let forward_diff = format!("--- a/file\n+++ b/file\n{}", diff_body); let forward_diff = format!("--- a/file\n+++ b/file\n{}", diff_body);
let reversed_diff = reverse_diff(&forward_diff); let reversed_diff = reverse_diff(&forward_diff);

352
crates/editor/src/config.rs Normal file
View file

@ -0,0 +1,352 @@
use super::*;
impl Editor {
pub fn style(&mut self, cx: &App) -> &EditorStyle {
match self.style {
Some(ref style) => style,
None => {
let style = self.create_style(cx);
self.style.insert(style)
}
}
}
pub fn set_soft_wrap_mode(
&mut self,
mode: language_settings::SoftWrap,
cx: &mut Context<Self>,
) {
self.soft_wrap_mode_override = Some(mode);
cx.notify();
}
pub fn set_hard_wrap(&mut self, hard_wrap: Option<usize>, cx: &mut Context<Self>) {
self.hard_wrap = hard_wrap;
cx.notify();
}
pub fn set_text_style_refinement(&mut self, style: TextStyleRefinement) {
self.text_style_refinement = Some(style);
}
/// called by the Element so we know what style we were most recently rendered with.
pub fn set_style(&mut self, style: EditorStyle, window: &mut Window, cx: &mut Context<Self>) {
// We intentionally do not inform the display map about the minimap style
// so that wrapping is not recalculated and stays consistent for the editor
// and its linked minimap.
if !self.mode.is_minimap() {
let font = style.text.font();
let font_size = style.text.font_size.to_pixels(window.rem_size());
let display_map = self
.placeholder_display_map
.as_ref()
.filter(|_| self.is_empty(cx))
.unwrap_or(&self.display_map);
display_map.update(cx, |map, cx| map.set_font(font, font_size, cx));
}
self.style = Some(style);
}
pub fn set_soft_wrap(&mut self) {
self.soft_wrap_mode_override = Some(language_settings::SoftWrap::EditorWidth)
}
pub fn set_show_wrap_guides(&mut self, show_wrap_guides: bool, cx: &mut Context<Self>) {
self.show_wrap_guides = Some(show_wrap_guides);
cx.notify();
}
pub fn set_show_indent_guides(&mut self, show_indent_guides: bool, cx: &mut Context<Self>) {
self.show_indent_guides = Some(show_indent_guides);
cx.notify();
}
pub fn disable_indent_guides_for_buffer(
&mut self,
buffer_id: BufferId,
cx: &mut Context<Self>,
) {
self.buffers_with_disabled_indent_guides.insert(buffer_id);
cx.notify();
}
pub fn toggle_line_numbers(
&mut self,
_: &ToggleLineNumbers,
_: &mut Window,
cx: &mut Context<Self>,
) {
let mut editor_settings = EditorSettings::get_global(cx).clone();
editor_settings.gutter.line_numbers = !editor_settings.gutter.line_numbers;
EditorSettings::override_global(editor_settings, cx);
}
pub fn line_numbers_enabled(&self, cx: &App) -> bool {
if let Some(show_line_numbers) = self.show_line_numbers {
return show_line_numbers;
}
EditorSettings::get_global(cx).gutter.line_numbers
}
pub fn relative_line_numbers(&self, cx: &App) -> RelativeLineNumbers {
match (
self.use_relative_line_numbers,
EditorSettings::get_global(cx).relative_line_numbers,
) {
(None, setting) => setting,
(Some(false), _) => RelativeLineNumbers::Disabled,
(Some(true), RelativeLineNumbers::Wrapped) => RelativeLineNumbers::Wrapped,
(Some(true), _) => RelativeLineNumbers::Enabled,
}
}
pub fn set_relative_line_number(&mut self, is_relative: Option<bool>, cx: &mut Context<Self>) {
self.use_relative_line_numbers = is_relative;
cx.notify();
}
pub fn set_show_gutter(&mut self, show_gutter: bool, cx: &mut Context<Self>) {
self.show_gutter = show_gutter;
cx.notify();
}
pub fn set_show_vertical_scrollbar(&mut self, show: bool, cx: &mut Context<Self>) {
self.show_scrollbars.vertical = show;
cx.notify();
}
pub fn set_show_horizontal_scrollbar(&mut self, show: bool, cx: &mut Context<Self>) {
self.show_scrollbars.horizontal = show;
cx.notify();
}
pub fn set_minimap_visibility(
&mut self,
minimap_visibility: MinimapVisibility,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.minimap_visibility != minimap_visibility {
if minimap_visibility.visible() && self.minimap.is_none() {
let minimap_settings = EditorSettings::get_global(cx).minimap;
self.minimap =
self.create_minimap(minimap_settings.with_show_override(), window, cx);
}
self.minimap_visibility = minimap_visibility;
cx.notify();
}
}
pub fn disable_scrollbars_and_minimap(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_show_scrollbars(false, cx);
self.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
}
pub fn hide_minimap_by_default(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_minimap_visibility(self.minimap_visibility.hidden(), window, cx);
}
/// Normally the text in full mode and auto height editors is padded on the
/// left side by roughly half a character width for improved hit testing.
///
/// Use this method to disable this for cases where this is not wanted (e.g.
/// if you want to align the editor text with some other text above or below)
/// or if you want to add this padding to single-line editors.
pub fn set_offset_content(&mut self, offset_content: bool, cx: &mut Context<Self>) {
self.offset_content = offset_content;
cx.notify();
}
pub fn set_show_line_numbers(&mut self, show_line_numbers: bool, cx: &mut Context<Self>) {
self.show_line_numbers = Some(show_line_numbers);
cx.notify();
}
pub fn disable_expand_excerpt_buttons(&mut self, cx: &mut Context<Self>) {
self.disable_expand_excerpt_buttons = true;
cx.notify();
}
pub fn set_show_git_diff_gutter(&mut self, show_git_diff_gutter: bool, cx: &mut Context<Self>) {
self.show_git_diff_gutter = Some(show_git_diff_gutter);
cx.notify();
}
pub fn set_show_code_actions(&mut self, show_code_actions: bool, cx: &mut Context<Self>) {
self.show_code_actions = Some(show_code_actions);
cx.notify();
}
pub fn set_show_runnables(&mut self, show_runnables: bool, cx: &mut Context<Self>) {
self.show_runnables = Some(show_runnables);
cx.notify();
}
pub fn set_show_breakpoints(&mut self, show_breakpoints: bool, cx: &mut Context<Self>) {
self.show_breakpoints = Some(show_breakpoints);
cx.notify();
}
pub fn set_show_diff_review_button(&mut self, show: bool, cx: &mut Context<Self>) {
self.show_diff_review_button = show;
cx.notify();
}
fn set_show_scrollbars(&mut self, show: bool, cx: &mut Context<Self>) {
self.show_scrollbars = ScrollbarAxes {
horizontal: show,
vertical: show,
};
cx.notify();
}
pub(super) fn wrap_guides(&self, cx: &App) -> SmallVec<[(usize, bool); 2]> {
let mut wrap_guides = smallvec![];
if self.show_wrap_guides == Some(false) {
return wrap_guides;
}
let settings = self.buffer.read(cx).language_settings(cx);
if settings.show_wrap_guides {
match self.soft_wrap_mode(cx) {
SoftWrap::Bounded(soft_wrap) => {
wrap_guides.push((soft_wrap as usize, true));
}
SoftWrap::GitDiff | SoftWrap::None | SoftWrap::EditorWidth => {}
}
wrap_guides.extend(settings.wrap_guides.iter().map(|guide| (*guide, false)))
}
wrap_guides
}
pub(super) fn soft_wrap_mode(&self, cx: &App) -> SoftWrap {
let settings = self.buffer.read(cx).language_settings(cx);
let mode = self.soft_wrap_mode_override.unwrap_or(settings.soft_wrap);
match mode {
language_settings::SoftWrap::PreferLine | language_settings::SoftWrap::None => {
SoftWrap::None
}
language_settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth,
language_settings::SoftWrap::Bounded => {
SoftWrap::Bounded(settings.preferred_line_length)
}
}
}
// Called by the element. This method is not designed to be called outside of the editor
// element's layout code because it does not notify when rewrapping is computed synchronously.
pub(super) fn set_wrap_width(&self, width: Option<Pixels>, cx: &mut App) -> bool {
if self.is_empty(cx) {
self.placeholder_display_map
.as_ref()
.map_or(false, |display_map| {
display_map.update(cx, |map, cx| map.set_wrap_width(width, cx))
})
} else {
self.display_map
.update(cx, |map, cx| map.set_wrap_width(width, cx))
}
}
pub(super) fn toggle_soft_wrap(
&mut self,
_: &ToggleSoftWrap,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.soft_wrap_mode_override.is_some() {
self.soft_wrap_mode_override.take();
} else {
let soft_wrap = match self.soft_wrap_mode(cx) {
SoftWrap::GitDiff => return,
SoftWrap::None => language_settings::SoftWrap::EditorWidth,
SoftWrap::EditorWidth | SoftWrap::Bounded(_) => language_settings::SoftWrap::None,
};
self.soft_wrap_mode_override = Some(soft_wrap);
}
cx.notify();
}
pub(super) fn toggle_tab_bar(
&mut self,
_: &ToggleTabBar,
_: &mut Window,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace() else {
return;
};
let fs = workspace.read(cx).app_state().fs.clone();
let current_show = TabBarSettings::get_global(cx).show;
update_settings_file(fs, cx, move |setting, _| {
setting.tab_bar.get_or_insert_default().show = Some(!current_show);
});
}
pub(super) fn toggle_indent_guides(
&mut self,
_: &ToggleIndentGuides,
_: &mut Window,
cx: &mut Context<Self>,
) {
let currently_enabled = self.should_show_indent_guides().unwrap_or_else(|| {
self.buffer
.read(cx)
.language_settings(cx)
.indent_guides
.enabled
});
self.show_indent_guides = Some(!currently_enabled);
cx.notify();
}
pub(super) fn should_show_indent_guides(&self) -> Option<bool> {
self.show_indent_guides
}
pub(super) fn has_indent_guides_disabled_for_buffer(&self, buffer_id: BufferId) -> bool {
self.buffers_with_disabled_indent_guides
.contains(&buffer_id)
}
pub(super) fn toggle_relative_line_numbers(
&mut self,
_: &ToggleRelativeLineNumbers,
_: &mut Window,
cx: &mut Context<Self>,
) {
let is_relative = self.relative_line_numbers(cx);
self.set_relative_line_number(Some(!is_relative.enabled()), cx)
}
pub(super) fn set_number_deleted_lines(&mut self, number: bool, cx: &mut Context<Self>) {
self.number_deleted_lines = number;
cx.notify();
}
pub fn set_delegate_open_excerpts(&mut self, delegate: bool) {
self.delegate_open_excerpts = delegate;
}
pub(super) fn set_delegate_expand_excerpts(&mut self, delegate: bool) {
self.delegate_expand_excerpts = delegate;
}
pub(super) fn set_delegate_stage_and_restore(&mut self, delegate: bool) {
self.delegate_stage_and_restore = delegate;
}
pub(super) fn set_on_local_selections_changed(
&mut self,
callback: Option<Box<dyn Fn(Point, &mut Window, &mut Context<Self>) + 'static>>,
) {
self.on_local_selections_changed = callback;
}
pub(super) fn set_suppress_selection_callback(&mut self, suppress: bool) {
self.suppress_selection_callback = suppress;
}
}

File diff suppressed because it is too large Load diff

View file

@ -8173,7 +8173,7 @@ async fn test_rewrap(cx: &mut TestAppContext) {
) { ) {
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
cx.set_state(unwrapped_text); cx.set_state(unwrapped_text);
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx));
cx.assert_editor_state(wrapped_text); cx.assert_editor_state(wrapped_text);
} }
} }
@ -8578,7 +8578,7 @@ async fn test_rewrap_block_comments(cx: &mut TestAppContext) {
) { ) {
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
cx.set_state(unwrapped_text); cx.set_state(unwrapped_text);
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx));
cx.assert_editor_state(wrapped_text); cx.assert_editor_state(wrapped_text);
} }
} }
@ -8604,7 +8604,7 @@ async fn test_rewrap_line_comment_in_go(cx: &mut TestAppContext) {
cx.set_state(indoc! {" cx.set_state(indoc! {"
// Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ
"}); "});
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx));
cx.assert_editor_state(indoc! {" cx.assert_editor_state(indoc! {"
// Lorem ipsum dolor sit amet, // Lorem ipsum dolor sit amet,
// consectetur adipiscing elit.ˇ // consectetur adipiscing elit.ˇ
@ -8632,7 +8632,7 @@ async fn test_rewrap_line_comment_in_c(cx: &mut TestAppContext) {
cx.set_state(indoc! {" cx.set_state(indoc! {"
// Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ // Lorem ipsum dolor sit amet, consectetur adipiscing elit.ˇ
"}); "});
cx.update_editor(|e, window, cx| e.rewrap(&Rewrap, window, cx)); cx.update_editor(|e, _, cx| e.rewrap(RewrapOptions::default(), cx));
cx.assert_editor_state(indoc! {" cx.assert_editor_state(indoc! {"
// Lorem ipsum dolor sit amet, // Lorem ipsum dolor sit amet,
// consectetur adipiscing elit.ˇ // consectetur adipiscing elit.ˇ

View file

@ -570,7 +570,9 @@ impl EditorElement {
register_action(editor, window, Editor::move_line_up); register_action(editor, window, Editor::move_line_up);
register_action(editor, window, Editor::move_line_down); register_action(editor, window, Editor::move_line_down);
register_action(editor, window, Editor::transpose); register_action(editor, window, Editor::transpose);
register_action(editor, window, Editor::rewrap); register_action(editor, window, |editor, _: &crate::Rewrap, _, cx| {
editor.rewrap(crate::RewrapOptions::default(), cx);
});
register_action(editor, window, Editor::cut); register_action(editor, window, Editor::cut);
register_action(editor, window, Editor::kill_ring_cut); register_action(editor, window, Editor::kill_ring_cut);
register_action(editor, window, Editor::kill_ring_yank); register_action(editor, window, Editor::kill_ring_yank);
@ -10081,8 +10083,6 @@ impl Element for EditorElement {
.editor .editor
.update(cx, |editor, cx| editor.highlighted_display_rows(window, cx)); .update(cx, |editor, cx| editor.highlighted_display_rows(window, cx));
let is_light = cx.theme().appearance().is_light();
let mut highlighted_ranges = self let mut highlighted_ranges = self
.editor_with_selections(cx) .editor_with_selections(cx)
.map(|editor| { .map(|editor| {
@ -10122,42 +10122,49 @@ impl Element for EditorElement {
}) })
.unwrap_or_default(); .unwrap_or_default();
struct DiffHunkHighlightColors {
filled_background: Hsla,
hollow_background: Hsla,
hollow_border: Hsla,
}
let colors = cx.theme().colors();
let added_diff_hunk_colors = DiffHunkHighlightColors {
filled_background: colors.editor_diff_hunk_added_background,
hollow_background: colors.editor_diff_hunk_added_hollow_background,
hollow_border: colors.editor_diff_hunk_added_hollow_border,
};
let deleted_diff_hunk_colors = DiffHunkHighlightColors {
filled_background: colors.editor_diff_hunk_deleted_background,
hollow_background: colors.editor_diff_hunk_deleted_hollow_background,
hollow_border: colors.editor_diff_hunk_deleted_hollow_border,
};
let drag_highlight_color = colors.editor_active_line_background;
let drag_border_color = colors.border_focused;
for (ix, row_info) in row_infos.iter().enumerate() { for (ix, row_info) in row_infos.iter().enumerate() {
let Some(diff_status) = row_info.diff_status else { let Some(diff_status) = row_info.diff_status else {
continue; continue;
}; };
let background_color = match diff_status.kind { let diff_hunk_colors = match diff_status.kind {
DiffHunkStatusKind::Added => cx.theme().colors().version_control_added, DiffHunkStatusKind::Added => &added_diff_hunk_colors,
DiffHunkStatusKind::Deleted => { DiffHunkStatusKind::Deleted => &deleted_diff_hunk_colors,
cx.theme().colors().version_control_deleted
}
DiffHunkStatusKind::Modified => { DiffHunkStatusKind::Modified => {
debug_panic!("modified diff status for row info"); debug_panic!("modified diff status for row info");
continue; continue;
} }
}; };
let hunk_opacity = if is_light { 0.16 } else { 0.12 };
let hollow_highlight = LineHighlight { let hollow_highlight = LineHighlight {
background: (background_color.opacity(if is_light { background: diff_hunk_colors.hollow_background.into(),
0.08 border: Some(diff_hunk_colors.hollow_border),
} else {
0.06
}))
.into(),
border: Some(if is_light {
background_color.opacity(0.48)
} else {
background_color.opacity(0.36)
}),
include_gutter: true, include_gutter: true,
type_id: None, type_id: None,
}; };
let filled_highlight = LineHighlight { let filled_highlight = LineHighlight {
background: solid_background(background_color.opacity(hunk_opacity)), background: solid_background(diff_hunk_colors.filled_background),
border: None, border: None,
include_gutter: true, include_gutter: true,
type_id: None, type_id: None,
@ -10182,11 +10189,9 @@ impl Element for EditorElement {
let range = drag_state.row_range(&snapshot.display_snapshot); let range = drag_state.row_range(&snapshot.display_snapshot);
let start_row = range.start().0; let start_row = range.start().0;
let end_row = range.end().0; let end_row = range.end().0;
let drag_highlight_color =
cx.theme().colors().editor_active_line_background;
let drag_highlight = LineHighlight { let drag_highlight = LineHighlight {
background: solid_background(drag_highlight_color), background: solid_background(drag_highlight_color),
border: Some(cx.theme().colors().border_focused), border: Some(drag_border_color),
include_gutter: true, include_gutter: true,
type_id: None, type_id: None,
}; };

View file

@ -14,7 +14,7 @@ use settings::Settings;
use std::{ops::Range, sync::LazyLock}; use std::{ops::Range, sync::LazyLock};
use text::OffsetRangeExt; use text::OffsetRangeExt;
use theme::ActiveTheme as _; use theme::ActiveTheme as _;
use util::{ResultExt, TryFutureExt as _, maybe}; use util::{ResultExt, TryFutureExt as _, maybe, paths::PathWithPosition};
#[derive(Debug)] #[derive(Debug)]
pub struct HoveredLinkState { pub struct HoveredLinkState {
@ -63,7 +63,7 @@ impl RangeInEditor {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum HoverLink { pub enum HoverLink {
Url(String), Url(String),
File(ResolvedPath), File(ResolvedFileTarget),
Text(LocationLink), Text(LocationLink),
InlayHint(lsp::Location, LanguageServerId), InlayHint(lsp::Location, LanguageServerId),
} }
@ -376,7 +376,7 @@ pub fn show_link_definition(
(range, vec![HoverLink::Url(url)]) (range, vec![HoverLink::Url(url)])
}) })
.ok() .ok()
} else if let Some((filename_range, filename)) = } else if let Some((filename_range, file_target)) =
find_file(&buffer, project.clone(), anchor, cx).await find_file(&buffer, project.clone(), anchor, cx).await
{ {
let range = maybe!({ let range = maybe!({
@ -385,7 +385,7 @@ pub fn show_link_definition(
Some(RangeInEditor::Text(range)) Some(RangeInEditor::Text(range))
}); });
Some((range, vec![HoverLink::File(filename)])) Some((range, vec![HoverLink::File(file_target)]))
} else if let Some(provider) = provider { } else if let Some(provider) = provider {
let task = cx.update(|_, cx| { let task = cx.update(|_, cx| {
provider.definitions(&buffer, anchor, preferred_kind, cx) provider.definitions(&buffer, anchor, preferred_kind, cx)
@ -608,12 +608,49 @@ pub(crate) fn find_url_from_range(
None None
} }
#[derive(Debug, Clone)]
pub(crate) struct ResolvedFileTarget {
pub resolved_path: ResolvedPath,
pub row: Option<u32>,
pub column: Option<u32>,
}
impl ResolvedFileTarget {
/// After opening a file, navigate the editor to the row/column position if present.
pub fn navigate_item_to_position(
&self,
item: Box<dyn crate::ItemHandle>,
cx: &mut AsyncWindowContext,
) {
if let Some(row) = self.row {
let col = self.column.unwrap_or(0);
if let Some(active_editor) = item.downcast::<crate::Editor>() {
active_editor
.downgrade()
.update_in(cx, |editor, window, cx| {
let row = row.saturating_sub(1);
let col = col.saturating_sub(1);
let Some(buffer) = editor.buffer().read(cx).as_singleton() else {
return;
};
let point = buffer
.read(cx)
.snapshot()
.point_from_external_input(row, col);
editor.go_to_singleton_buffer_point_silently(point, window, cx);
})
.log_err();
}
}
}
}
pub(crate) async fn find_file( pub(crate) async fn find_file(
buffer: &Entity<language::Buffer>, buffer: &Entity<language::Buffer>,
project: Option<Entity<Project>>, project: Option<Entity<Project>>,
position: text::Anchor, position: text::Anchor,
cx: &mut AsyncWindowContext, cx: &mut AsyncWindowContext,
) -> Option<(Range<text::Anchor>, ResolvedPath)> { ) -> Option<(Range<text::Anchor>, ResolvedFileTarget)> {
let project = project?; let project = project?;
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let scope = snapshot.language_scope_at(position); let scope = snapshot.language_scope_at(position);
@ -636,19 +673,53 @@ pub(crate) async fn find_file(
let pattern_candidates = link_pattern_file_candidates(&candidate_file_path); let pattern_candidates = link_pattern_file_candidates(&candidate_file_path);
// Compute the highlight range for a pattern_range within the candidate string.
let make_range = |pattern_range: &Range<usize>| -> Range<text::Anchor> {
let offset_range = range.to_offset(&snapshot);
let actual_start = offset_range.start + pattern_range.start;
let actual_end = offset_range.end - (candidate_len - pattern_range.end);
snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end)
};
// For each candidate extracted by link_pattern_file_candidates, try resolving in order:
// 1. The raw candidate string
// 2. The path portion after stripping `:row:col` suffix
// 3. With language-specific file extensions appended to raw candidate
// 4. With language-specific file extensions appended to stripped path
for (pattern_candidate, pattern_range) in &pattern_candidates { for (pattern_candidate, pattern_range) in &pattern_candidates {
// Try the raw candidate first.
if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await { if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await {
let offset_range = range.to_offset(&snapshot);
let actual_start = offset_range.start + pattern_range.start;
let actual_end = offset_range.end - (candidate_len - pattern_range.end);
return Some(( return Some((
snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), make_range(pattern_range),
existing_path, ResolvedFileTarget {
resolved_path: existing_path,
row: None,
column: None,
},
)); ));
} }
}
if let Some(scope) = scope { // Parse row:col suffix once per candidate for use in fallback attempts.
for (pattern_candidate, pattern_range) in pattern_candidates { // This handles patterns like `file.rs:83:1`, `file.rs:83`, and `file.rs:20:in`.
let parsed = PathWithPosition::parse_str(pattern_candidate);
let parsed_path = parsed.path.to_string_lossy();
// Try resolving just the path portion (without :row:col).
if parsed.row.is_some() {
if let Some(existing_path) = check_path(&parsed_path, &project, buffer, cx).await {
return Some((
make_range(pattern_range),
ResolvedFileTarget {
resolved_path: existing_path,
row: parsed.row,
column: parsed.column,
},
));
}
}
// Try with language-specific suffixes.
if let Some(scope) = &scope {
for suffix in scope.path_suffixes() { for suffix in scope.path_suffixes() {
if pattern_candidate.ends_with(format!(".{suffix}").as_str()) { if pattern_candidate.ends_with(format!(".{suffix}").as_str()) {
continue; continue;
@ -658,15 +729,39 @@ pub(crate) async fn find_file(
if let Some(existing_path) = if let Some(existing_path) =
check_path(&suffixed_candidate, &project, buffer, cx).await check_path(&suffixed_candidate, &project, buffer, cx).await
{ {
let offset_range = range.to_offset(&snapshot);
let actual_start = offset_range.start + pattern_range.start;
let actual_end = offset_range.end - (candidate_len - pattern_range.end);
return Some(( return Some((
snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end), make_range(pattern_range),
existing_path, ResolvedFileTarget {
resolved_path: existing_path,
row: None,
column: None,
},
)); ));
} }
} }
// Try with language-specific suffixes on the stripped path.
if parsed.row.is_some() {
for suffix in scope.path_suffixes() {
if parsed_path.ends_with(&format!(".{suffix}")) {
continue;
}
let suffixed_candidate = format!("{parsed_path}.{suffix}");
if let Some(existing_path) =
check_path(&suffixed_candidate, &project, buffer, cx).await
{
return Some((
make_range(pattern_range),
ResolvedFileTarget {
resolved_path: existing_path,
row: parsed.row,
column: parsed.column,
},
));
}
}
}
} }
} }
None None
@ -721,7 +816,7 @@ fn surrounding_filename(
found_start = true; found_start = true;
break; break;
} }
if (ch == '"' || ch == '\'') && !inside_quotes { if (ch == '"' || ch == '\'' || ch == '`') && !inside_quotes {
found_start = true; found_start = true;
inside_quotes = true; inside_quotes = true;
break; break;
@ -754,7 +849,7 @@ fn surrounding_filename(
found_end = true; found_end = true;
break; break;
} }
if ch == '"' || ch == '\'' { if ch == '"' || ch == '\'' || ch == '`' {
// If we're inside quotes, we stop when we come across the next quote // If we're inside quotes, we stop when we come across the next quote
if inside_quotes { if inside_quotes {
found_end = true; found_end = true;
@ -1576,6 +1671,16 @@ mod tests {
(" ˇ\"\"", Some("")), (" ˇ\"\"", Some("")),
(" \"ˇ常\"", Some("")), (" \"ˇ常\"", Some("")),
("ˇ\"\"", Some("")), ("ˇ\"\"", Some("")),
// Path with row:column suffix
("fiˇle.rs:83:1", Some("file.rs:83:1")),
("file.rs:83ˇ:1 foo", Some("file.rs:83:1")),
("file.rs:20ˇ:in bar", Some("file.rs:20:in")),
// Backtick delimiters
("`fˇile.txt`", Some("file.txt")),
("ˇ`file.txt`", Some("file.txt")),
("`fˇile.txt` and more", Some("file.txt")),
// Backtick with row:col
("`fiˇle.rs:83:1`", Some("file.rs:83:1")),
]; ];
for (input, expected) in test_cases { for (input, expected) in test_cases {
@ -1873,6 +1978,274 @@ mod tests {
}); });
} }
#[gpui::test]
async fn test_hover_filename_with_row_column(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
..Default::default()
},
cx,
)
.await;
// Insert a new file with multiple lines
let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
fs.as_fake()
.insert_file(
path!("/root/dir/file2.rs"),
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n"
.as_bytes()
.to_vec(),
)
.await;
// file2.rs:5:3 should be highlighted and clickable
cx.set_state(indoc! {"
Go to file2.rs:5:3 for the fix.ˇ
"});
let screen_coord = cx.pixel_position(indoc! {"
Go to filˇe2.rs:5:3 for the fix.
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
cx.assert_editor_text_highlights(
HighlightKey::HoveredLinkState,
indoc! {"
Go to «file2.rs:5:3ˇ» for the fix.
"},
);
cx.simulate_click(screen_coord, Modifiers::secondary_key());
cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
cx.update_workspace(|workspace, window, cx| {
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
{
let editor = active_editor.read(cx);
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
let file = buffer.read(cx).file().unwrap();
let file_path = file.as_local().unwrap().abs_path(cx);
assert_eq!(
file_path,
std::path::PathBuf::from(path!("/root/dir/file2.rs"))
);
}
// Check that the cursor is at row 5, column 3 (0-indexed: row 4, col 2)
let (count, snapshot) = active_editor.update(cx, |editor, cx| {
(editor.selections.count(), editor.snapshot(window, cx))
});
assert_eq!(count, 1);
let selections = active_editor
.read(cx)
.selections
.newest::<language::Point>(&snapshot.display_snapshot);
assert_eq!(
selections.head().row,
4,
"Expected cursor on row 5 (0-indexed: 4)"
);
assert_eq!(
selections.head().column,
2,
"Expected cursor on column 3 (0-indexed: 2)"
);
});
}
#[gpui::test]
async fn test_hover_filename_with_row_only(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
..Default::default()
},
cx,
)
.await;
let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
fs.as_fake()
.insert_file(
path!("/root/dir/file2.rs"),
"line 1\nline 2\nline 3\nline 4\nline 5\n"
.as_bytes()
.to_vec(),
)
.await;
// file2.rs:3 should be highlighted and clickable
cx.set_state(indoc! {"
Go to file2.rs:3 please.ˇ
"});
let screen_coord = cx.pixel_position(indoc! {"
Go to filˇe2.rs:3 please.
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
cx.assert_editor_text_highlights(
HighlightKey::HoveredLinkState,
indoc! {"
Go to «file2.rs:3ˇ» please.
"},
);
cx.simulate_click(screen_coord, Modifiers::secondary_key());
cx.update_workspace(|workspace, window, cx| {
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
let (count, snapshot) = active_editor.update(cx, |editor, cx| {
(editor.selections.count(), editor.snapshot(window, cx))
});
assert_eq!(count, 1);
let selections = active_editor
.read(cx)
.selections
.newest::<language::Point>(&snapshot.display_snapshot);
assert_eq!(
selections.head().row,
2,
"Expected cursor on row 3 (0-indexed: 2)"
);
assert_eq!(selections.head().column, 0, "Expected cursor on column 0");
});
}
#[gpui::test]
async fn test_hover_filename_with_non_numeric_suffix(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
..Default::default()
},
cx,
)
.await;
let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
fs.as_fake()
.insert_file(
path!("/root/dir/file2.rs"),
"line 1\nline 2\nline 3\n".as_bytes().to_vec(),
)
.await;
// file2.rs:2:in should resolve to file2.rs line 2 (like Ruby backtraces)
cx.set_state(indoc! {"
Error at file2.rs:2:in 'method'ˇ
"});
let screen_coord = cx.pixel_position(indoc! {"
Error at filˇe2.rs:2:in 'method'
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
cx.assert_editor_text_highlights(
HighlightKey::HoveredLinkState,
indoc! {"
Error at «file2.rs:2:inˇ» 'method'
"},
);
cx.simulate_click(screen_coord, Modifiers::secondary_key());
cx.update_workspace(|workspace, window, cx| {
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
let (count, snapshot) = active_editor.update(cx, |editor, cx| {
(editor.selections.count(), editor.snapshot(window, cx))
});
assert_eq!(count, 1);
let selections = active_editor
.read(cx)
.selections
.newest::<language::Point>(&snapshot.display_snapshot);
assert_eq!(
selections.head().row,
1,
"Expected cursor on row 2 (0-indexed: 1)"
);
});
}
#[gpui::test]
async fn test_hover_markdown_link_with_row_column(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
..Default::default()
},
cx,
)
.await;
let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
fs.as_fake()
.insert_file(
path!("/root/dir/file2.rs"),
"line 1\nline 2\nline 3\nline 4\nline 5\n"
.as_bytes()
.to_vec(),
)
.await;
// Markdown link [text](file2.rs:3:2) should highlight only the inner link,
// not the surrounding markdown syntax.
cx.set_state(indoc! {"
See [here](file2.rs:3:2) for details.ˇ
"});
let screen_coord = cx.pixel_position(indoc! {"
See [here](filˇe2.rs:3:2) for details.
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
cx.assert_editor_text_highlights(
HighlightKey::HoveredLinkState,
indoc! {"
See [here](«file2.rs:3:2ˇ») for details.
"},
);
cx.simulate_click(screen_coord, Modifiers::secondary_key());
cx.update_workspace(|workspace, window, cx| {
let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
{
let editor = active_editor.read(cx);
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
let file = buffer.read(cx).file().unwrap();
let file_path = file.as_local().unwrap().abs_path(cx);
assert_eq!(
file_path,
std::path::PathBuf::from(path!("/root/dir/file2.rs"))
);
}
// Check cursor is at row 3, column 2 (0-indexed: row 2, col 1)
let (count, snapshot) = active_editor.update(cx, |editor, cx| {
(editor.selections.count(), editor.snapshot(window, cx))
});
assert_eq!(count, 1);
let selections = active_editor
.read(cx)
.selections
.newest::<language::Point>(&snapshot.display_snapshot);
assert_eq!(
selections.head().row,
2,
"Expected cursor on row 3 (0-indexed: 2)"
);
assert_eq!(
selections.head().column,
1,
"Expected cursor on column 2 (0-indexed: 1)"
);
});
}
#[gpui::test] #[gpui::test]
async fn test_hover_directories(cx: &mut gpui::TestAppContext) { async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {}); init_test(cx, |_| {});

View file

@ -101,6 +101,10 @@ impl FollowableItem for Editor {
.await .await
.debug_assert_ok("leaders don't share views for unshared buffers")?; .debug_assert_ok("leaders don't share views for unshared buffers")?;
let path_excerpts =
deserialize_path_excerpts_and_wait_for_anchors(state.path_excerpts, &buffers, cx)
.await?;
let editor = cx.update(|window, cx| { let editor = cx.update(|window, cx| {
let multibuffer = cx.new(|cx| { let multibuffer = cx.new(|cx| {
let mut multibuffer; let mut multibuffer;
@ -108,27 +112,13 @@ impl FollowableItem for Editor {
multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx) multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx)
} else { } else {
multibuffer = MultiBuffer::new(project.read(cx).capability()); multibuffer = MultiBuffer::new(project.read(cx).capability());
for path_with_ranges in state.path_excerpts { for (path_key, buffer_id, ranges) in path_excerpts {
let Some(path_key) =
path_with_ranges.path_key.and_then(deserialize_path_key)
else {
continue;
};
let Some(buffer_id) = BufferId::new(path_with_ranges.buffer_id).ok()
else {
continue;
};
let Some(buffer) = let Some(buffer) =
buffers.iter().find(|b| b.read(cx).remote_id() == buffer_id) buffers.iter().find(|b| b.read(cx).remote_id() == buffer_id)
else { else {
continue; continue;
}; };
let buffer_snapshot = buffer.read(cx).snapshot(); let buffer_snapshot = buffer.read(cx).snapshot();
let ranges = path_with_ranges
.ranges
.into_iter()
.filter_map(deserialize_excerpt_range)
.collect::<Vec<_>>();
multibuffer.update_path_excerpts( multibuffer.update_path_excerpts(
path_key, path_key,
buffer.clone(), buffer.clone(),
@ -402,25 +392,20 @@ async fn update_editor_from_message(
.map(|id| BufferId::new(id).map(|id| project.open_buffer_by_id(id, cx))) .map(|id| BufferId::new(id).map(|id| project.open_buffer_by_id(id, cx)))
.collect::<Result<Vec<_>>>() .collect::<Result<Vec<_>>>()
})?; })?;
let _inserted_excerpt_buffers = try_join_all(inserted_excerpt_buffers).await?; let inserted_excerpt_buffers = try_join_all(inserted_excerpt_buffers).await?;
let updated_paths = deserialize_path_excerpts_and_wait_for_anchors(
message.updated_paths,
&inserted_excerpt_buffers,
cx,
)
.await?;
// Update the editor's excerpts. // Update the editor's excerpts.
let buffer_snapshot = this.update(cx, |editor, cx| { let buffer_snapshot = this.update(cx, |editor, cx| {
editor.buffer.update(cx, |multibuffer, cx| { editor.buffer.update(cx, |multibuffer, cx| {
for path_with_excerpts in message.updated_paths { for (path_key, buffer_id, ranges) in updated_paths {
let Some(path_key) = path_with_excerpts.path_key.and_then(deserialize_path_key) let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else {
else {
continue;
};
let ranges = path_with_excerpts
.ranges
.into_iter()
.filter_map(deserialize_excerpt_range)
.collect::<Vec<_>>();
let Some(buffer) = BufferId::new(path_with_excerpts.buffer_id)
.ok()
.and_then(|buffer_id| project.read(cx).buffer_for_id(buffer_id, cx))
else {
continue; continue;
}; };
@ -539,6 +524,56 @@ fn serialize_excerpt_range(range: ExcerptRange<language::Anchor>) -> proto::Exce
} }
} }
async fn deserialize_path_excerpts_and_wait_for_anchors(
path_excerpts: Vec<proto::PathExcerpts>,
buffers: &[Entity<Buffer>],
cx: &mut AsyncWindowContext,
) -> Result<Vec<(PathKey, BufferId, Vec<ExcerptRange<language::Anchor>>)>> {
let path_excerpts = path_excerpts
.into_iter()
.filter_map(|path_with_ranges| {
let path_key = path_with_ranges.path_key.and_then(deserialize_path_key)?;
let buffer_id = BufferId::new(path_with_ranges.buffer_id).ok()?;
let ranges = path_with_ranges
.ranges
.into_iter()
.filter_map(deserialize_excerpt_range)
.collect::<Vec<_>>();
Some((path_key, buffer_id, ranges))
})
.collect::<Vec<_>>();
let wait_for_anchors = cx.update(|_, cx| {
buffers
.iter()
.map(|buffer| {
let buffer_id = buffer.read(cx).remote_id();
let anchors = path_excerpts
.iter()
.filter(|(_, id, _)| *id == buffer_id)
.flat_map(|(_, _, ranges)| {
ranges.iter().flat_map(|range| {
[
range.context.start,
range.context.end,
range.primary.start,
range.primary.end,
]
})
})
.collect::<Vec<_>>();
buffer.update(cx, |buffer, _| buffer.wait_for_anchors(anchors))
})
.collect::<Vec<_>>()
})?;
// Without this wait, resolving these anchors later can race ahead of the
// leader's pending buffer ops and trip `panic_bad_anchor` on a stale
// snapshot.
try_join_all(wait_for_anchors).await?;
Ok(path_excerpts)
}
fn deserialize_excerpt_range( fn deserialize_excerpt_range(
excerpt_range: proto::ExcerptRange, excerpt_range: proto::ExcerptRange,
) -> Option<ExcerptRange<language::Anchor>> { ) -> Option<ExcerptRange<language::Anchor>> {

782
crates/editor/src/rewrap.rs Normal file
View file

@ -0,0 +1,782 @@
use super::*;
impl Editor {
pub fn rewrap(&mut self, options: RewrapOptions, cx: &mut Context<Self>) {
if self.read_only(cx) || self.mode.is_single_line() {
return;
}
let buffer = self.buffer.read(cx).snapshot(cx);
let selections = self.selections.all::<Point>(&self.display_snapshot(cx));
#[derive(Clone, Debug, PartialEq)]
enum CommentFormat {
/// single line comment, with prefix for line
Line(String),
/// single line within a block comment, with prefix for line
BlockLine(String),
/// a single line of a block comment that includes the initial delimiter
BlockCommentWithStart(BlockCommentConfig),
/// a single line of a block comment that includes the ending delimiter
BlockCommentWithEnd(BlockCommentConfig),
}
// Split selections to respect paragraph, indent, and comment prefix boundaries.
let wrap_ranges = selections.into_iter().flat_map(|selection| {
let language_settings = buffer.language_settings_at(selection.head(), cx);
let language_scope = buffer.language_scope_at(selection.head());
let indent_and_prefix_for_row =
|row: u32| -> (IndentSize, Option<CommentFormat>, Option<String>) {
let indent = buffer.indent_size_for_line(MultiBufferRow(row));
let (comment_prefix, rewrap_prefix) = if let Some(language_scope) =
&language_scope
{
let indent_end = Point::new(row, indent.len);
let line_end = Point::new(row, buffer.line_len(MultiBufferRow(row)));
let line_text_after_indent = buffer
.text_for_range(indent_end..line_end)
.collect::<String>();
let is_within_comment_override = buffer
.language_scope_at(indent_end)
.is_some_and(|scope| scope.override_name() == Some("comment"));
let comment_delimiters = if is_within_comment_override {
// we are within a comment syntax node, but we don't
// yet know what kind of comment: block, doc or line
match (
language_scope.documentation_comment(),
language_scope.block_comment(),
) {
(Some(config), _) | (_, Some(config))
if buffer.contains_str_at(indent_end, &config.start) =>
{
Some(CommentFormat::BlockCommentWithStart(config.clone()))
}
(Some(config), _) | (_, Some(config))
if line_text_after_indent.ends_with(config.end.as_ref()) =>
{
Some(CommentFormat::BlockCommentWithEnd(config.clone()))
}
(Some(config), _) | (_, Some(config))
if !config.prefix.is_empty()
&& buffer.contains_str_at(indent_end, &config.prefix) =>
{
Some(CommentFormat::BlockLine(config.prefix.to_string()))
}
(_, _) => language_scope
.line_comment_prefixes()
.iter()
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
.map(|prefix| CommentFormat::Line(prefix.to_string())),
}
} else {
// we not in an overridden comment node, but we may
// be within a non-overridden line comment node
language_scope
.line_comment_prefixes()
.iter()
.find(|prefix| buffer.contains_str_at(indent_end, prefix))
.map(|prefix| CommentFormat::Line(prefix.to_string()))
};
let rewrap_prefix = language_scope
.rewrap_prefixes()
.iter()
.find_map(|prefix_regex| {
prefix_regex.find(&line_text_after_indent).map(|mat| {
if mat.start() == 0 {
Some(mat.as_str().to_string())
} else {
None
}
})
})
.flatten();
(comment_delimiters, rewrap_prefix)
} else {
(None, None)
};
(indent, comment_prefix, rewrap_prefix)
};
let mut start_row = selection.start.row;
let mut end_row = selection.end.row;
if selection.is_empty() {
let cursor_row = selection.start.row;
let (mut indent_size, comment_prefix, _) = indent_and_prefix_for_row(cursor_row);
let line_prefix = match &comment_prefix {
Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => {
Some(prefix.as_str())
}
Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig {
prefix, ..
})) => Some(prefix.as_ref()),
Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig {
start: _,
end: _,
prefix,
tab_size,
})) => {
indent_size.len += tab_size;
Some(prefix.as_ref())
}
None => None,
};
let indent_prefix = indent_size.chars().collect::<String>();
let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or(""));
'expand_upwards: while start_row > 0 {
let prev_row = start_row - 1;
if buffer.contains_str_at(Point::new(prev_row, 0), &line_prefix)
&& buffer.line_len(MultiBufferRow(prev_row)) as usize > line_prefix.len()
&& !buffer.is_line_blank(MultiBufferRow(prev_row))
{
start_row = prev_row;
} else {
break 'expand_upwards;
}
}
'expand_downwards: while end_row < buffer.max_point().row {
let next_row = end_row + 1;
if buffer.contains_str_at(Point::new(next_row, 0), &line_prefix)
&& buffer.line_len(MultiBufferRow(next_row)) as usize > line_prefix.len()
&& !buffer.is_line_blank(MultiBufferRow(next_row))
{
end_row = next_row;
} else {
break 'expand_downwards;
}
}
}
let mut non_blank_rows_iter = (start_row..=end_row)
.filter(|row| !buffer.is_line_blank(MultiBufferRow(*row)))
.peekable();
let first_row = if let Some(&row) = non_blank_rows_iter.peek() {
row
} else {
return Vec::new();
};
let mut ranges = Vec::new();
let mut current_range_start = first_row;
let mut prev_row = first_row;
let (
mut current_range_indent,
mut current_range_comment_delimiters,
mut current_range_rewrap_prefix,
) = indent_and_prefix_for_row(first_row);
for row in non_blank_rows_iter.skip(1) {
let has_paragraph_break = row > prev_row + 1;
let (row_indent, row_comment_delimiters, row_rewrap_prefix) =
indent_and_prefix_for_row(row);
let has_indent_change = row_indent != current_range_indent;
let has_comment_change = row_comment_delimiters != current_range_comment_delimiters;
let has_boundary_change = has_comment_change
|| row_rewrap_prefix.is_some()
|| (has_indent_change && current_range_comment_delimiters.is_some());
if has_paragraph_break || has_boundary_change {
ranges.push((
language_settings.clone(),
Point::new(current_range_start, 0)
..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))),
current_range_indent,
current_range_comment_delimiters.clone(),
current_range_rewrap_prefix.clone(),
));
current_range_start = row;
current_range_indent = row_indent;
current_range_comment_delimiters = row_comment_delimiters;
current_range_rewrap_prefix = row_rewrap_prefix;
}
prev_row = row;
}
ranges.push((
language_settings.clone(),
Point::new(current_range_start, 0)
..Point::new(prev_row, buffer.line_len(MultiBufferRow(prev_row))),
current_range_indent,
current_range_comment_delimiters,
current_range_rewrap_prefix,
));
ranges
});
let mut edits = Vec::new();
let mut rewrapped_row_ranges = Vec::<RangeInclusive<u32>>::new();
for (language_settings, wrap_range, mut indent_size, comment_prefix, rewrap_prefix) in
wrap_ranges
{
let start_row = wrap_range.start.row;
let end_row = wrap_range.end.row;
// Skip selections that overlap with a range that has already been rewrapped.
let selection_range = start_row..end_row;
if rewrapped_row_ranges
.iter()
.any(|range| range.overlaps(&selection_range))
{
continue;
}
let tab_size = language_settings.tab_size;
let (line_prefix, inside_comment) = match &comment_prefix {
Some(CommentFormat::Line(prefix) | CommentFormat::BlockLine(prefix)) => {
(Some(prefix.as_str()), true)
}
Some(CommentFormat::BlockCommentWithEnd(BlockCommentConfig { prefix, .. })) => {
(Some(prefix.as_ref()), true)
}
Some(CommentFormat::BlockCommentWithStart(BlockCommentConfig {
start: _,
end: _,
prefix,
tab_size,
})) => {
indent_size.len += tab_size;
(Some(prefix.as_ref()), true)
}
None => (None, false),
};
let indent_prefix = indent_size.chars().collect::<String>();
let line_prefix = format!("{indent_prefix}{}", line_prefix.unwrap_or(""));
let allow_rewrap_based_on_language = match language_settings.allow_rewrap {
RewrapBehavior::InComments => inside_comment,
RewrapBehavior::InSelections => !wrap_range.is_empty(),
RewrapBehavior::Anywhere => true,
};
let should_rewrap = options.override_language_settings
|| allow_rewrap_based_on_language
|| self.hard_wrap.is_some();
if !should_rewrap {
continue;
}
let start = Point::new(start_row, 0);
let start_offset = ToOffset::to_offset(&start, &buffer);
let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row)));
let selection_text = buffer.text_for_range(start..end).collect::<String>();
let mut first_line_delimiter = None;
let mut last_line_delimiter = None;
let Some(lines_without_prefixes) = selection_text
.lines()
.enumerate()
.map(|(ix, line)| {
let line_trimmed = line.trim_start();
if rewrap_prefix.is_some() && ix > 0 {
Ok(line_trimmed)
} else if let Some(
CommentFormat::BlockCommentWithStart(BlockCommentConfig {
start,
prefix,
end,
tab_size,
})
| CommentFormat::BlockCommentWithEnd(BlockCommentConfig {
start,
prefix,
end,
tab_size,
}),
) = &comment_prefix
{
let line_trimmed = line_trimmed
.strip_prefix(start.as_ref())
.map(|s| {
let mut indent_size = indent_size;
indent_size.len -= tab_size;
let indent_prefix: String = indent_size.chars().collect();
first_line_delimiter = Some((indent_prefix, start));
s.trim_start()
})
.unwrap_or(line_trimmed);
let line_trimmed = line_trimmed
.strip_suffix(end.as_ref())
.map(|s| {
last_line_delimiter = Some(end);
s.trim_end()
})
.unwrap_or(line_trimmed);
let line_trimmed = line_trimmed
.strip_prefix(prefix.as_ref())
.unwrap_or(line_trimmed);
Ok(line_trimmed)
} else if let Some(CommentFormat::BlockLine(prefix)) = &comment_prefix {
line_trimmed.strip_prefix(prefix).with_context(|| {
format!("line did not start with prefix {prefix:?}: {line:?}")
})
} else {
line_trimmed
.strip_prefix(&line_prefix.trim_start())
.with_context(|| {
format!("line did not start with prefix {line_prefix:?}: {line:?}")
})
}
})
.collect::<Result<Vec<_>, _>>()
.log_err()
else {
continue;
};
let wrap_column = options.line_length.or(self.hard_wrap).unwrap_or_else(|| {
buffer
.language_settings_at(Point::new(start_row, 0), cx)
.preferred_line_length as usize
});
let subsequent_lines_prefix = if let Some(rewrap_prefix_str) = &rewrap_prefix {
format!("{}{}", indent_prefix, " ".repeat(rewrap_prefix_str.len()))
} else {
line_prefix.clone()
};
let wrapped_text = {
let mut wrapped_text = wrap_with_prefix(
line_prefix,
subsequent_lines_prefix,
lines_without_prefixes.join("\n"),
wrap_column,
tab_size,
options.preserve_existing_whitespace,
);
if let Some((indent, delimiter)) = first_line_delimiter {
wrapped_text = format!("{indent}{delimiter}\n{wrapped_text}");
}
if let Some(last_line) = last_line_delimiter {
wrapped_text = format!("{wrapped_text}\n{indent_prefix}{last_line}");
}
wrapped_text
};
// TODO: should always use char-based diff while still supporting cursor behavior that
// matches vim.
let mut diff_options = DiffOptions::default();
if options.override_language_settings {
diff_options.max_word_diff_len = 0;
diff_options.max_word_diff_line_count = 0;
} else {
diff_options.max_word_diff_len = usize::MAX;
diff_options.max_word_diff_line_count = usize::MAX;
}
for (old_range, new_text) in
text_diff_with_options(&selection_text, &wrapped_text, diff_options)
{
let edit_start = buffer.anchor_after(start_offset + old_range.start);
let edit_end = buffer.anchor_after(start_offset + old_range.end);
edits.push((edit_start..edit_end, new_text));
}
rewrapped_row_ranges.push(start_row..=end_row);
}
self.buffer
.update(cx, |buffer, cx| buffer.edit(edits, None, cx));
}
}
fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize {
let tab_size = tab_size.get() as usize;
let mut width = offset;
for ch in text.chars() {
width += if ch == '\t' {
tab_size - (width % tab_size)
} else {
1
};
}
width - offset
}
/// Tokenizes a string into runs of text that should stick together, or that is whitespace.
struct WordBreakingTokenizer<'a> {
input: &'a str,
}
impl<'a> WordBreakingTokenizer<'a> {
fn new(input: &'a str) -> Self {
Self { input }
}
}
fn is_char_ideographic(ch: char) -> bool {
use unicode_script::Script::*;
use unicode_script::UnicodeScript;
matches!(ch.script(), Han | Tangut | Yi)
}
fn is_grapheme_ideographic(text: &str) -> bool {
text.chars().any(is_char_ideographic)
}
fn is_grapheme_whitespace(text: &str) -> bool {
text.chars().any(|x| x.is_whitespace())
}
fn should_stay_with_preceding_ideograph(text: &str) -> bool {
text.chars()
.next()
.is_some_and(|ch| matches!(ch, '。' | '、' | '' | '' | '' | '' | '' | '…'))
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
enum WordBreakToken<'a> {
Word { token: &'a str, grapheme_len: usize },
InlineWhitespace { token: &'a str, grapheme_len: usize },
Newline,
}
impl<'a> Iterator for WordBreakingTokenizer<'a> {
/// Yields a span, the count of graphemes in the token, and whether it was
/// whitespace. Note that it also breaks at word boundaries.
type Item = WordBreakToken<'a>;
fn next(&mut self) -> Option<Self::Item> {
use unicode_segmentation::UnicodeSegmentation;
if self.input.is_empty() {
return None;
}
let mut iter = self.input.graphemes(true).peekable();
let mut offset = 0;
let mut grapheme_len = 0;
if let Some(first_grapheme) = iter.next() {
let is_newline = first_grapheme == "\n";
let is_whitespace = is_grapheme_whitespace(first_grapheme);
offset += first_grapheme.len();
grapheme_len += 1;
if is_grapheme_ideographic(first_grapheme) && !is_whitespace {
if let Some(grapheme) = iter.peek().copied()
&& should_stay_with_preceding_ideograph(grapheme)
{
offset += grapheme.len();
grapheme_len += 1;
}
} else {
let mut words = self.input[offset..].split_word_bound_indices().peekable();
let mut next_word_bound = words.peek().copied();
if next_word_bound.is_some_and(|(i, _)| i == 0) {
next_word_bound = words.next();
}
while let Some(grapheme) = iter.peek().copied() {
if next_word_bound.is_some_and(|(i, _)| i == offset) {
break;
};
if is_grapheme_whitespace(grapheme) != is_whitespace
|| (grapheme == "\n") != is_newline
{
break;
};
offset += grapheme.len();
grapheme_len += 1;
iter.next();
}
}
let token = &self.input[..offset];
self.input = &self.input[offset..];
if token == "\n" {
Some(WordBreakToken::Newline)
} else if is_whitespace {
Some(WordBreakToken::InlineWhitespace {
token,
grapheme_len,
})
} else {
Some(WordBreakToken::Word {
token,
grapheme_len,
})
}
} else {
None
}
}
}
fn wrap_with_prefix(
first_line_prefix: String,
subsequent_lines_prefix: String,
unwrapped_text: String,
wrap_column: usize,
tab_size: NonZeroU32,
preserve_existing_whitespace: bool,
) -> String {
let first_line_prefix_len = char_len_with_expanded_tabs(0, &first_line_prefix, tab_size);
let subsequent_lines_prefix_len =
char_len_with_expanded_tabs(0, &subsequent_lines_prefix, tab_size);
let mut wrapped_text = String::new();
let mut current_line = first_line_prefix;
let mut is_first_line = true;
let tokenizer = WordBreakingTokenizer::new(&unwrapped_text);
let mut current_line_len = first_line_prefix_len;
let mut in_whitespace = false;
for token in tokenizer {
let have_preceding_whitespace = in_whitespace;
match token {
WordBreakToken::Word {
token,
grapheme_len,
} => {
in_whitespace = false;
let current_prefix_len = if is_first_line {
first_line_prefix_len
} else {
subsequent_lines_prefix_len
};
if current_line_len + grapheme_len > wrap_column
&& current_line_len != current_prefix_len
{
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
is_first_line = false;
current_line = subsequent_lines_prefix.clone();
current_line_len = subsequent_lines_prefix_len;
}
current_line.push_str(token);
current_line_len += grapheme_len;
}
WordBreakToken::InlineWhitespace {
mut token,
mut grapheme_len,
} => {
in_whitespace = true;
if have_preceding_whitespace && !preserve_existing_whitespace {
continue;
}
if !preserve_existing_whitespace {
// Keep a single whitespace grapheme as-is
if let Some(first) =
unicode_segmentation::UnicodeSegmentation::graphemes(token, true).next()
{
token = first;
} else {
token = " ";
}
grapheme_len = 1;
}
let current_prefix_len = if is_first_line {
first_line_prefix_len
} else {
subsequent_lines_prefix_len
};
if current_line_len + grapheme_len > wrap_column {
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
is_first_line = false;
current_line = subsequent_lines_prefix.clone();
current_line_len = subsequent_lines_prefix_len;
} else if current_line_len != current_prefix_len || preserve_existing_whitespace {
current_line.push_str(token);
current_line_len += grapheme_len;
}
}
WordBreakToken::Newline => {
in_whitespace = true;
let current_prefix_len = if is_first_line {
first_line_prefix_len
} else {
subsequent_lines_prefix_len
};
if preserve_existing_whitespace {
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
is_first_line = false;
current_line = subsequent_lines_prefix.clone();
current_line_len = subsequent_lines_prefix_len;
} else if have_preceding_whitespace {
continue;
} else if current_line_len + 1 > wrap_column
&& current_line_len != current_prefix_len
{
wrapped_text.push_str(current_line.trim_end());
wrapped_text.push('\n');
is_first_line = false;
current_line = subsequent_lines_prefix.clone();
current_line_len = subsequent_lines_prefix_len;
} else if current_line_len != current_prefix_len {
current_line.push(' ');
current_line_len += 1;
}
}
}
}
if !current_line.is_empty() {
wrapped_text.push_str(&current_line);
}
wrapped_text
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_string_size_with_expanded_tabs() {
let nz = |val| NonZeroU32::new(val).unwrap();
assert_eq!(char_len_with_expanded_tabs(0, "", nz(4)), 0);
assert_eq!(char_len_with_expanded_tabs(0, "hello", nz(4)), 5);
assert_eq!(char_len_with_expanded_tabs(0, "\thello", nz(4)), 9);
assert_eq!(char_len_with_expanded_tabs(0, "abc\tab", nz(4)), 6);
assert_eq!(char_len_with_expanded_tabs(0, "hello\t", nz(4)), 8);
assert_eq!(char_len_with_expanded_tabs(0, "\t\t", nz(8)), 16);
assert_eq!(char_len_with_expanded_tabs(0, "x\t", nz(8)), 8);
assert_eq!(char_len_with_expanded_tabs(7, "x\t", nz(8)), 9);
}
#[test]
fn test_word_breaking_tokenizer() {
let tests: &[(&str, &[WordBreakToken<'static>])] = &[
("", &[]),
(" ", &[whitespace(" ", 2)]),
("Ʒ", &[word("Ʒ", 1)]),
("Ǽ", &[word("Ǽ", 1)]),
("", &[word("", 1)]),
("⋑⋑", &[word("⋑⋑", 2)]),
(
"原理,进而",
&[word("", 1), word("理,", 2), word("", 1), word("", 1)],
),
(
"hello world",
&[word("hello", 5), whitespace(" ", 1), word("world", 5)],
),
(
"hello, world",
&[word("hello,", 6), whitespace(" ", 1), word("world", 5)],
),
(
" hello world",
&[
whitespace(" ", 2),
word("hello", 5),
whitespace(" ", 1),
word("world", 5),
],
),
(
"这是什么 \n 钢笔",
&[
word("", 1),
word("", 1),
word("", 1),
word("", 1),
whitespace(" ", 1),
newline(),
whitespace(" ", 1),
word("", 1),
word("", 1),
],
),
("mutton", &[whitespace("", 1), word("mutton", 6)]),
];
fn word(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> {
WordBreakToken::Word {
token,
grapheme_len,
}
}
fn whitespace(token: &'static str, grapheme_len: usize) -> WordBreakToken<'static> {
WordBreakToken::InlineWhitespace {
token,
grapheme_len,
}
}
fn newline() -> WordBreakToken<'static> {
WordBreakToken::Newline
}
for (input, result) in tests {
assert_eq!(
WordBreakingTokenizer::new(input)
.collect::<Vec<_>>()
.as_slice(),
*result,
);
}
}
#[test]
fn test_wrap_with_prefix() {
assert_eq!(
wrap_with_prefix(
"# ".to_string(),
"# ".to_string(),
"abcdefg".to_string(),
4,
NonZeroU32::new(4).unwrap(),
false,
),
"# abcdefg"
);
assert_eq!(
wrap_with_prefix(
"".to_string(),
"".to_string(),
"\thello world".to_string(),
8,
NonZeroU32::new(4).unwrap(),
false,
),
"hello\nworld"
);
assert_eq!(
wrap_with_prefix(
"// ".to_string(),
"// ".to_string(),
"xx \nyy zz aa bb cc".to_string(),
12,
NonZeroU32::new(4).unwrap(),
false,
),
"// xx yy zz\n// aa bb cc"
);
assert_eq!(
wrap_with_prefix(
String::new(),
String::new(),
"这是什么 \n 钢笔".to_string(),
3,
NonZeroU32::new(4).unwrap(),
false,
),
"这是什\n么 钢\n"
);
assert_eq!(
wrap_with_prefix(
String::new(),
String::new(),
format!("foo{}bar", '\u{2009}'), // thin space
80,
NonZeroU32::new(4).unwrap(),
false,
),
format!("foo{}bar", '\u{2009}')
);
}
}

View file

@ -70,6 +70,7 @@ pub fn init(cx: &mut App) -> Arc<AgentCliAppState> {
git_binary_path, git_binary_path,
cx.background_executor().clone(), cx.background_executor().clone(),
)); ));
<dyn fs::Fs>::set_global(fs.clone(), cx);
let mut languages = LanguageRegistry::new(cx.background_executor().clone()); let mut languages = LanguageRegistry::new(cx.background_executor().clone());
languages.set_language_server_download_dir(paths::languages_dir().clone()); languages.set_language_server_download_dir(paths::languages_dir().clone());

View file

@ -28,23 +28,15 @@ const RUST_TARGET: &str = "wasm32-wasip2";
/// Once Clang 17 and its wasm target are available via system package managers, we won't need /// Once Clang 17 and its wasm target are available via system package managers, we won't need
/// to download this. /// to download this.
const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/"; const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/";
const WASI_SDK_ASSET_NAME: Option<&str> = if cfg!(all(target_os = "macos", target_arch = "x86_64")) const WASI_SDK_ASSET_NAME: Option<&str> = cfg_select! {
{ all(target_os = "macos", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-macos.tar.gz"),
Some("wasi-sdk-25.0-x86_64-macos.tar.gz") all(target_os = "macos", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-macos.tar.gz"),
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { all(target_os = "linux", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"),
Some("wasi-sdk-25.0-arm64-macos.tar.gz") all(target_os = "linux", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"),
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { all(target_os = "freebsd", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"),
Some("wasi-sdk-25.0-x86_64-linux.tar.gz") all(target_os = "freebsd", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"),
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { all(target_os = "windows", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-windows.tar.gz"),
Some("wasi-sdk-25.0-arm64-linux.tar.gz") _ => None
} else if cfg!(all(target_os = "freebsd", target_arch = "x86_64")) {
Some("wasi-sdk-25.0-x86_64-linux.tar.gz")
} else if cfg!(all(target_os = "freebsd", target_arch = "aarch64")) {
Some("wasi-sdk-25.0-arm64-linux.tar.gz")
} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
Some("wasi-sdk-25.0-x86_64-windows.tar.gz")
} else {
None
}; };
pub struct ExtensionBuilder { pub struct ExtensionBuilder {

View file

@ -19,7 +19,7 @@ use extension::{
ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy, ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy,
ExtensionLanguageServerProxy, ExtensionSnippetProxy, ExtensionThemeProxy, ExtensionLanguageServerProxy, ExtensionSnippetProxy, ExtensionThemeProxy,
}; };
use fs::{Fs, RemoveOptions}; use fs::{Fs, RemoveOptions, RenameOptions};
use futures::future::join_all; use futures::future::join_all;
use futures::{ use futures::{
AsyncReadExt as _, Future, FutureExt as _, StreamExt as _, AsyncReadExt as _, Future, FutureExt as _, StreamExt as _,
@ -77,7 +77,7 @@ const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(1);
/// ///
/// These snippets should no longer be downloaded or loaded, because their /// These snippets should no longer be downloaded or loaded, because their
/// functionality has been integrated into the core editor. /// functionality has been integrated into the core editor.
const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright"]; const SUPPRESSED_EXTENSIONS: &[&str] = &["snippets", "ruff", "ty", "basedpyright", "basher"];
/// Returns the [`SchemaVersion`] range that is compatible with this version of Zed. /// Returns the [`SchemaVersion`] range that is compatible with this version of Zed.
pub fn schema_version_range() -> RangeInclusive<SchemaVersion> { pub fn schema_version_range() -> RangeInclusive<SchemaVersion> {
@ -726,41 +726,67 @@ impl ExtensionStore {
} }
}); });
let mut response = http_client cx.background_spawn(async move {
.get(url.as_ref(), Default::default(), true) let mut response = http_client
.await .get(url.as_ref(), Default::default(), true)
.context("downloading extension")?; .await
.context("downloading extension")?;
fs.remove_dir( let content_length = response
&extension_dir, .headers()
RemoveOptions { .get(http_client::http::header::CONTENT_LENGTH)
recursive: true, .and_then(|value| value.to_str().ok()?.parse::<usize>().ok());
ignore_if_not_exists: true,
}, let mut body = BufReader::new(response.body_mut());
) let mut tar_gz_bytes = Vec::new();
body.read_to_end(&mut tar_gz_bytes).await?;
if let Some(content_length) = content_length {
let actual_len = tar_gz_bytes.len();
if content_length != actual_len {
bail!(
"downloaded extension size {actual_len} \
does not match content length {content_length}"
);
}
}
let decompressed_bytes = GzipDecoder::new(BufReader::new(tar_gz_bytes.as_slice()));
let archive = Archive::new(decompressed_bytes);
let remove_dir = || {
fs.remove_dir(
&extension_dir,
RemoveOptions {
recursive: true,
ignore_if_not_exists: true,
},
)
};
match tempfile::tempdir_in(paths::temp_dir()).or_else(|_| tempfile::tempdir()) {
Ok(temp_dir) => {
archive.unpack(temp_dir.path()).await?;
remove_dir().await?;
fs.rename(
temp_dir.path(),
&extension_dir,
RenameOptions {
overwrite: true,
ignore_if_exists: true,
create_parents: true,
},
)
.await
}
Err(_) => {
remove_dir().await?;
archive.unpack(extension_dir).await.map_err(Into::into)
}
}
})
.await?; .await?;
let content_length = response
.headers()
.get(http_client::http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok()?.parse::<usize>().ok());
let mut body = BufReader::new(response.body_mut());
let mut tar_gz_bytes = Vec::new();
body.read_to_end(&mut tar_gz_bytes).await?;
if let Some(content_length) = content_length {
let actual_len = tar_gz_bytes.len();
if content_length != actual_len {
bail!(concat!(
"downloaded extension size {actual_len} ",
"does not match content length {content_length}"
));
}
}
let decompressed_bytes = GzipDecoder::new(BufReader::new(tar_gz_bytes.as_slice()));
let archive = Archive::new(decompressed_bytes);
archive.unpack(extension_dir).await?;
this.update(cx, |this, cx| this.reload(Some(extension_id.clone()), cx))? this.update(cx, |this, cx| this.reload(Some(extension_id.clone()), cx))?
.await; .await;

View file

@ -72,6 +72,7 @@ pub struct FakeGitRepositoryState {
pub simulated_index_write_error_message: Option<String>, pub simulated_index_write_error_message: Option<String>,
pub simulated_create_worktree_error: Option<String>, pub simulated_create_worktree_error: Option<String>,
pub simulated_graph_error: Option<String>, pub simulated_graph_error: Option<String>,
pub branches_requiring_force_delete: HashSet<String>,
pub refs: HashMap<String, String>, pub refs: HashMap<String, String>,
pub graph_commits: Vec<Arc<InitialGraphCommitData>>, pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
pub commit_data: HashMap<Oid, FakeCommitDataEntry>, pub commit_data: HashMap<Oid, FakeCommitDataEntry>,
@ -91,6 +92,7 @@ impl FakeGitRepositoryState {
simulated_index_write_error_message: Default::default(), simulated_index_write_error_message: Default::default(),
simulated_create_worktree_error: Default::default(), simulated_create_worktree_error: Default::default(),
simulated_graph_error: None, simulated_graph_error: None,
branches_requiring_force_delete: Default::default(),
refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
merge_base_contents: Default::default(), merge_base_contents: Default::default(),
oids: Default::default(), oids: Default::default(),
@ -888,11 +890,22 @@ impl GitRepository for FakeGitRepository {
}) })
} }
fn delete_branch(&self, _is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> { fn delete_branch(
&self,
_is_remote: bool,
name: String,
force: bool,
) -> BoxFuture<'_, Result<()>> {
self.with_state_async(true, move |state| { self.with_state_async(true, move |state| {
if !force && state.branches_requiring_force_delete.contains(&name) {
bail!(
"error: The branch '{name}' is not fully merged.\nIf you are sure you want to delete it, run 'git branch -D {name}'."
);
}
if !state.branches.remove(&name) { if !state.branches.remove(&name) {
bail!("no such branch: {name}"); bail!("no such branch: {name}");
} }
state.branches_requiring_force_delete.remove(&name);
Ok(()) Ok(())
}) })
} }

View file

@ -1,4 +1,4 @@
use notify::EventKind; use notify::{Event, EventKind};
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{ use std::{
collections::{BTreeMap, HashMap}, collections::{BTreeMap, HashMap},
@ -458,6 +458,16 @@ fn handle_poll_event(event: Result<notify::Event, notify::Error>) {
} }
fn handle_event(mode: WatcherMode, event: Result<notify::Event, notify::Error>) { fn handle_event(mode: WatcherMode, event: Result<notify::Event, notify::Error>) {
if matches!(
event,
Ok(Event {
kind: EventKind::Access(_),
..
})
) {
return;
}
log::trace!("global handle event for {mode:?}: {event:?}"); log::trace!("global handle event for {mode:?}: {event:?}");
let callbacks = { let callbacks = {
@ -472,9 +482,6 @@ fn handle_event(mode: WatcherMode, event: Result<notify::Event, notify::Error>)
match event { match event {
Ok(event) => { Ok(event) => {
if matches!(event.kind, EventKind::Access(_)) {
return;
}
for callback in callbacks { for callback in callbacks {
callback(Ok(&event)); callback(Ok(&event));
} }

View file

@ -2,6 +2,9 @@ mod matcher;
mod paths; mod paths;
mod strings; mod strings;
use fuzzy::CharBag;
use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
pub use paths::{ pub use paths::{
PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets, PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets,
}; };
@ -45,6 +48,83 @@ impl LengthPenalty {
} }
} }
// Matching is always case-insensitive at the nucleo level — using
// `CaseMatching::Smart` there would *reject* candidates whose capitalization
// doesn't match the query, breaking pickers like the command palette
// (`"Editor: Backspace"` against the action named `"editor: backspace"`).
// `Case::Smart` is honored as a *scoring hint* instead: when the query
// contains uppercase, candidates whose matched characters disagree in case
// are downranked by a per-mismatch penalty rather than dropped.
pub(crate) struct Query {
pub(crate) pattern: Pattern,
/// Non-whitespace query chars in input order, populated only when a smart-case
/// penalty will actually be charged. Aligns 1:1 with the indices appended by
/// `Pattern::indices` (atom-order, needle-order within each atom).
pub(crate) query_chars: Option<Vec<char>>,
pub(crate) char_bag: CharBag,
}
impl Query {
pub(crate) fn build(query: &str, case: Case) -> Option<Self> {
if query.chars().all(char::is_whitespace) {
return None;
}
let normalized = query.split_whitespace().collect::<Vec<_>>().join(" ");
let pattern = Pattern::new(
&normalized,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
);
let wants_case_penalty = case.is_smart() && query.chars().any(|c| c.is_uppercase());
let query_chars =
wants_case_penalty.then(|| query.chars().filter(|c| !c.is_whitespace()).collect());
Some(Query {
pattern,
query_chars,
char_bag: CharBag::from(query),
})
}
}
#[inline]
pub(crate) fn count_case_mismatches(
query_chars: Option<&[char]>,
matched_chars: &[u32],
candidate: &str,
candidate_chars: &mut Vec<char>,
) -> u32 {
let Some(query_chars) = query_chars else {
return 0;
};
if query_chars.len() != matched_chars.len() {
return 0;
}
candidate_chars.clear();
candidate_chars.extend(candidate.chars());
let mut mismatches: u32 = 0;
for (&query_char, &pos) in query_chars.iter().zip(matched_chars) {
if let Some(&candidate_char) = candidate_chars.get(pos as usize)
&& candidate_char != query_char
&& candidate_char.eq_ignore_ascii_case(&query_char)
{
mismatches += 1;
}
}
mismatches
}
const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9;
#[inline]
pub(crate) fn case_penalty(mismatches: u32) -> f64 {
if mismatches == 0 {
1.0
} else {
SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32)
}
}
/// Reconstruct byte-offset match positions from a list of matched char offsets /// Reconstruct byte-offset match positions from a list of matched char offsets
/// that is already sorted ascending and deduplicated. /// that is already sorted ascending and deduplicated.
pub(crate) fn positions_from_sorted(s: &str, sorted_char_indices: &[u32]) -> Vec<usize> { pub(crate) fn positions_from_sorted(s: &str, sorted_char_indices: &[u32]) -> Vec<usize> {

View file

@ -9,12 +9,12 @@ use std::{
use util::{paths::PathStyle, rel_path::RelPath}; use util::{paths::PathStyle, rel_path::RelPath};
use nucleo::Utf32Str; use nucleo::Utf32Str;
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; use nucleo::pattern::Pattern;
use fuzzy::CharBag; use fuzzy::CharBag;
use crate::matcher::{self, LENGTH_PENALTY}; use crate::matcher::{self, LENGTH_PENALTY};
use crate::{Cancelled, Case, positions_from_sorted}; use crate::{Cancelled, Case, Query, case_penalty, count_case_mismatches, positions_from_sorted};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PathMatchCandidate<'a> { pub struct PathMatchCandidate<'a> {
@ -96,47 +96,6 @@ impl Ord for PathMatch {
} }
} }
// Path matching is always case-insensitive at the nucleo level. `Case::Smart`
// is honored as a *scoring hint*: when the query contains uppercase, candidates
// whose matched characters disagree in case are downranked by a factor per
// mismatch rather than dropped. This keeps `"Editor: Backspace"` matching
// `"editor: backspace"` while still preferring exact-case hits.
const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9;
pub(crate) fn make_atoms(query: &str) -> Vec<Atom> {
query
.split_whitespace()
.map(|word| {
Atom::new(
word,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
)
})
.collect()
}
// Only populated when we will actually charge a smart-case penalty, so the hot
// path can iterate a plain `&[Atom]` and ignore this slice entirely.
fn make_source_words(query: &str, case: Case) -> Option<Vec<Vec<char>>> {
(case.is_smart() && query.chars().any(|c| c.is_uppercase())).then(|| {
query
.split_whitespace()
.map(|word| word.chars().collect())
.collect()
})
}
fn case_penalty(mismatches: u32) -> f64 {
if mismatches == 0 {
1.0
} else {
SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32)
}
}
pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> usize { pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> usize {
let mut path_components = path.components(); let mut path_components = path.components();
let mut relative_components = relative_to.components(); let mut relative_components = relative_to.components();
@ -150,34 +109,34 @@ pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> u
path_components.count() + relative_components.count() + 1 path_components.count() + relative_components.count() + 1
} }
#[inline]
fn get_filename_match_bonus( fn get_filename_match_bonus(
candidate_buf: &str, candidate_buf: &str,
query_atoms: &[Atom], pattern: &Pattern,
matcher: &mut nucleo::Matcher, matcher: &mut nucleo::Matcher,
) -> f64 { ) -> f64 {
let filename = match std::path::Path::new(candidate_buf).file_name() { let Some(filename) = std::path::Path::new(candidate_buf)
Some(f) => f.to_str().unwrap_or(""), .file_name()
None => return 0.0, .and_then(|f| f.to_str())
}; .filter(|f| !f.is_empty())
if filename.is_empty() || query_atoms.is_empty() { else {
return 0.0; return 0.0;
} };
let mut buf = Vec::new(); let mut buf = Vec::new();
let haystack = Utf32Str::new(filename, &mut buf); let haystack = Utf32Str::new(filename, &mut buf);
let mut total_score = 0u32; let score: u32 = pattern
for atom in query_atoms { .atoms
if let Some(score) = atom.score(haystack, matcher) { .iter()
total_score = total_score.saturating_add(score as u32); .filter_map(|atom| atom.score(haystack, matcher))
} .map(|s| s as u32)
} .sum();
total_score as f64 / filename.len().max(1) as f64
score as f64 / filename.len().max(1) as f64
} }
fn path_match_helper<'a>( fn path_match_helper<'a>(
matcher: &mut nucleo::Matcher, matcher: &mut nucleo::Matcher,
atoms: &[Atom], query: &Query,
source_words: Option<&[Vec<char>]>,
query_bag: CharBag,
candidates: impl Iterator<Item = PathMatchCandidate<'a>>, candidates: impl Iterator<Item = PathMatchCandidate<'a>>,
results: &mut Vec<PathMatch>, results: &mut Vec<PathMatch>,
worktree_id: usize, worktree_id: usize,
@ -197,7 +156,6 @@ fn path_match_helper<'a>(
let path_prefix_len = candidate_buf.len(); let path_prefix_len = candidate_buf.len();
let mut buf = Vec::new(); let mut buf = Vec::new();
let mut matched_chars: Vec<u32> = Vec::new(); let mut matched_chars: Vec<u32> = Vec::new();
let mut atom_matched_chars = Vec::new();
let mut candidate_chars: Vec<char> = Vec::new(); let mut candidate_chars: Vec<char> = Vec::new();
for candidate in candidates { for candidate in candidates {
buf.clear(); buf.clear();
@ -206,7 +164,7 @@ fn path_match_helper<'a>(
return Err(Cancelled); return Err(Cancelled);
} }
if !candidate.char_bag.is_superset(query_bag) { if !candidate.char_bag.is_superset(query.char_bag) {
continue; continue;
} }
@ -219,70 +177,45 @@ fn path_match_helper<'a>(
let haystack = Utf32Str::new(&candidate_buf, &mut buf); let haystack = Utf32Str::new(&candidate_buf, &mut buf);
if source_words.is_some() { let Some(score) = query.pattern.indices(haystack, matcher, &mut matched_chars) else {
candidate_chars.clear(); continue;
candidate_chars.extend(candidate_buf.chars()); };
}
let mut total_score: u32 = 0; let case_mismatches = count_case_mismatches(
let mut case_mismatches: u32 = 0; query.query_chars.as_deref(),
let mut all_matched = true; &matched_chars,
&candidate_buf,
&mut candidate_chars,
);
for (atom_idx, atom) in atoms.iter().enumerate() { matched_chars.sort_unstable();
atom_matched_chars.clear(); matched_chars.dedup();
let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) else {
all_matched = false;
break;
};
total_score = total_score.saturating_add(score as u32);
if let Some(source_words) = source_words {
let query_chars = &source_words[atom_idx];
if query_chars.len() == atom_matched_chars.len() {
for (&query_char, &pos) in query_chars.iter().zip(&atom_matched_chars) {
if let Some(&candidate_char) = candidate_chars.get(pos as usize)
&& candidate_char != query_char
&& candidate_char.eq_ignore_ascii_case(&query_char)
{
case_mismatches += 1;
}
}
}
}
matched_chars.extend_from_slice(&atom_matched_chars);
}
if all_matched && !atoms.is_empty() { let length_penalty = candidate_buf.len() as f64 * LENGTH_PENALTY;
matched_chars.sort_unstable(); let filename_bonus = get_filename_match_bonus(&candidate_buf, &query.pattern, matcher);
matched_chars.dedup(); let positive = (score as f64 + filename_bonus) * case_penalty(case_mismatches);
let adjusted_score = positive - length_penalty;
let positions = positions_from_sorted(&candidate_buf, &matched_chars);
let length_penalty = candidate_buf.len() as f64 * LENGTH_PENALTY; results.push(PathMatch {
let filename_bonus = get_filename_match_bonus(&candidate_buf, atoms, matcher); score: adjusted_score,
let positive = (total_score as f64 + filename_bonus) * case_penalty(case_mismatches); positions,
let adjusted_score = positive - length_penalty; worktree_id,
let positions = positions_from_sorted(&candidate_buf, &matched_chars); path: if root_is_file {
Arc::clone(path_prefix)
results.push(PathMatch { } else {
score: adjusted_score, candidate.path.into()
positions, },
worktree_id, path_prefix: if root_is_file {
path: if root_is_file { RelPath::empty().into()
Arc::clone(path_prefix) } else {
} else { Arc::clone(path_prefix)
candidate.path.into() },
}, is_dir: candidate.is_dir,
path_prefix: if root_is_file { distance_to_relative_ancestor: relative_to.as_ref().map_or(usize::MAX, |relative_to| {
RelPath::empty().into() distance_between_paths(candidate.path, relative_to.as_ref())
} else { }),
Arc::clone(path_prefix) });
},
is_dir: candidate.is_dir,
distance_to_relative_ancestor: relative_to
.as_ref()
.map_or(usize::MAX, |relative_to| {
distance_between_paths(candidate.path, relative_to.as_ref())
}),
});
}
} }
Ok(()) Ok(())
} }
@ -296,14 +229,14 @@ pub fn match_fixed_path_set(
max_results: usize, max_results: usize,
path_style: PathStyle, path_style: PathStyle,
) -> Vec<PathMatch> { ) -> Vec<PathMatch> {
let Some(query) = Query::build(query, case) else {
return Vec::new();
};
let mut config = nucleo::Config::DEFAULT; let mut config = nucleo::Config::DEFAULT;
config.set_match_paths(); config.set_match_paths();
let mut matcher = matcher::get_matcher(config); let mut matcher = matcher::get_matcher(config);
let atoms = make_atoms(query);
let source_words = make_source_words(query, case);
let query_bag = CharBag::from(query);
let root_is_file = worktree_root_name.is_some() && candidates.iter().all(|c| c.path.is_empty()); let root_is_file = worktree_root_name.is_some() && candidates.iter().all(|c| c.path.is_empty());
let path_prefix = worktree_root_name.unwrap_or_else(|| RelPath::empty().into()); let path_prefix = worktree_root_name.unwrap_or_else(|| RelPath::empty().into());
@ -312,9 +245,7 @@ pub fn match_fixed_path_set(
path_match_helper( path_match_helper(
&mut matcher, &mut matcher,
&atoms, &query,
source_words.as_deref(),
query_bag,
candidates.into_iter(), candidates.into_iter(),
&mut results, &mut results,
worktree_id, worktree_id,
@ -352,9 +283,9 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
query.to_owned() query.to_owned()
}; };
let atoms = make_atoms(&query); let Some(query) = Query::build(&query, case) else {
let source_words = make_source_words(&query, case); return Vec::new();
let query_bag = CharBag::from(query.as_str()); };
let num_cpus = executor.num_cpus().min(path_count); let num_cpus = executor.num_cpus().min(path_count);
let segment_size = path_count.div_ceil(num_cpus); let segment_size = path_count.div_ceil(num_cpus);
@ -371,8 +302,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
.zip(matchers.iter_mut()) .zip(matchers.iter_mut())
.enumerate() .enumerate()
{ {
let atoms = atoms.clone(); let query = &query;
let source_words = source_words.clone();
let relative_to = relative_to.clone(); let relative_to = relative_to.clone();
scope.spawn(async move { scope.spawn(async move {
let segment_start = segment_idx * segment_size; let segment_start = segment_idx * segment_size;
@ -389,9 +319,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
if path_match_helper( if path_match_helper(
matcher, matcher,
&atoms, query,
source_words.as_deref(),
query_bag,
candidates, candidates,
results, results,
candidate_set.id(), candidate_set.id(),

View file

@ -8,61 +8,14 @@ use std::{
use gpui::{BackgroundExecutor, SharedString}; use gpui::{BackgroundExecutor, SharedString};
use nucleo::Utf32Str; use nucleo::Utf32Str;
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
use crate::{ use crate::{
Cancelled, Case, LengthPenalty, Cancelled, Case, LengthPenalty, Query, case_penalty, count_case_mismatches,
matcher::{self, LENGTH_PENALTY}, matcher::{self, LENGTH_PENALTY},
positions_from_sorted, positions_from_sorted,
}; };
use fuzzy::CharBag; use fuzzy::CharBag;
// String matching is always case-insensitive at the nucleo level — using
// `CaseMatching::Smart` there would reject queries whose capitalization
// doesn't match the candidate, breaking pickers like the command palette
// (`"Editor: Backspace"` against the action named `"editor: backspace"`).
// `Case::Smart` is still honored as a *scoring hint*: when the query
// contains uppercase, candidates whose matched characters disagree in case
// are downranked rather than dropped.
const SMART_CASE_PENALTY_PER_MISMATCH: f64 = 0.9;
struct Query {
atoms: Vec<Atom>,
source_words: Option<Vec<Vec<char>>>,
char_bag: CharBag,
}
impl Query {
fn build(query: &str, case: Case) -> Option<Self> {
let mut atoms = Vec::new();
let mut source_words = Vec::new();
let wants_case_penalty = case.is_smart() && query.chars().any(|c| c.is_uppercase());
for word in query.split_whitespace() {
atoms.push(Atom::new(
word,
CaseMatching::Ignore,
Normalization::Smart,
AtomKind::Fuzzy,
false,
));
if wants_case_penalty {
source_words.push(word.chars().collect());
}
}
if atoms.is_empty() {
return None;
}
Some(Query {
atoms,
source_words: wants_case_penalty.then_some(source_words),
char_bag: CharBag::from(query),
})
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct StringMatchCandidate { pub struct StringMatchCandidate {
pub id: usize, pub id: usize,
@ -281,7 +234,6 @@ where
{ {
let mut buf = Vec::new(); let mut buf = Vec::new();
let mut matched_chars: Vec<u32> = Vec::new(); let mut matched_chars: Vec<u32> = Vec::new();
let mut atom_matched_chars = Vec::new();
let mut candidate_chars: Vec<char> = Vec::new(); let mut candidate_chars: Vec<char> = Vec::new();
for candidate in candidates { for candidate in candidates {
@ -297,69 +249,37 @@ where
continue; continue;
} }
let haystack: Utf32Str = Utf32Str::new(&borrowed.string, &mut buf); let haystack: Utf32Str = Utf32Str::new(borrowed.string.as_ref(), &mut buf);
if query.source_words.is_some() { let Some(score) = query.pattern.indices(haystack, matcher, &mut matched_chars) else {
candidate_chars.clear(); continue;
candidate_chars.extend(borrowed.string.chars()); };
}
let mut total_score: u32 = 0; let case_mismatches = count_case_mismatches(
let mut case_mismatches: u32 = 0; query.query_chars.as_deref(),
let mut all_matched = true; &matched_chars,
borrowed.string.as_ref(),
&mut candidate_chars,
);
for (atom_idx, atom) in query.atoms.iter().enumerate() { matched_chars.sort_unstable();
atom_matched_chars.clear(); matched_chars.dedup();
let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) else {
all_matched = false;
break;
};
total_score = total_score.saturating_add(score as u32);
if let Some(source_words) = query.source_words.as_deref() {
let query_chars = &source_words[atom_idx];
if query_chars.len() == atom_matched_chars.len() {
for (&query_char, &pos) in query_chars.iter().zip(&atom_matched_chars) {
if let Some(&candidate_char) = candidate_chars.get(pos as usize)
&& candidate_char != query_char
&& candidate_char.eq_ignore_ascii_case(&query_char)
{
case_mismatches += 1;
}
}
}
}
matched_chars.extend_from_slice(&atom_matched_chars);
}
if all_matched { let positive = score as f64 * case_penalty(case_mismatches);
matched_chars.sort_unstable(); let adjusted_score =
matched_chars.dedup(); positive - length_penalty_for(borrowed.string.as_ref(), length_penalty);
let positions = positions_from_sorted(borrowed.string.as_ref(), &matched_chars);
let positive = total_score as f64 * case_penalty(case_mismatches); results.push(StringMatch {
let adjusted_score = candidate_id: borrowed.id,
positive - length_penalty_for(borrowed.string.as_ref(), length_penalty); score: adjusted_score,
let positions = positions_from_sorted(borrowed.string.as_ref(), &matched_chars); positions,
string: borrowed.string.clone(),
results.push(StringMatch { });
candidate_id: borrowed.id,
score: adjusted_score,
positions,
string: borrowed.string.clone(),
});
}
} }
Ok(()) Ok(())
} }
#[inline]
fn case_penalty(mismatches: u32) -> f64 {
if mismatches == 0 {
1.0
} else {
SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32)
}
}
#[inline] #[inline]
fn length_penalty_for(s: &str, length_penalty: LengthPenalty) -> f64 { fn length_penalty_for(s: &str, length_penalty: LengthPenalty) -> f64 {
if length_penalty.is_on() { if length_penalty.is_on() {

View file

@ -721,6 +721,15 @@ pub struct SearchCommitArgs {
pub case_sensitive: bool, pub case_sensitive: bool,
} }
pub fn delete_branch_flag(is_remote_tracking_ref: bool, force: bool) -> &'static str {
match (is_remote_tracking_ref, force) {
(true, true) => "-Dr",
(true, false) => "-dr",
(false, true) => "-D",
(false, false) => "-d",
}
}
pub trait GitRepository: Send + Sync { pub trait GitRepository: Send + Sync {
fn reload_index(&self); fn reload_index(&self);
@ -775,7 +784,12 @@ pub trait GitRepository: Send + Sync {
-> BoxFuture<'_, Result<()>>; -> BoxFuture<'_, Result<()>>;
fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>; fn rename_branch(&self, branch: String, new_name: String) -> BoxFuture<'_, Result<()>>;
fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>>; fn delete_branch(
&self,
is_remote: bool,
name: String,
force: bool,
) -> BoxFuture<'_, Result<()>>;
fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>; fn worktrees(&self) -> BoxFuture<'_, Result<Vec<Worktree>>>;
@ -2033,14 +2047,18 @@ impl GitRepository for RealGitRepository {
.boxed() .boxed()
} }
fn delete_branch(&self, is_remote: bool, name: String) -> BoxFuture<'_, Result<()>> { fn delete_branch(
&self,
is_remote: bool,
name: String,
force: bool,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary_in_worktree(); let git_binary = self.git_binary_in_worktree();
self.executor self.executor
.spawn(async move { .spawn(async move {
git_binary? let flag = delete_branch_flag(is_remote, force);
.run(&["branch", if is_remote { "-dr" } else { "-d" }, &name]) git_binary?.run(&["branch", flag, &name]).await?;
.await?;
anyhow::Ok(()) anyhow::Ok(())
}) })
.boxed() .boxed()

View file

@ -3,12 +3,12 @@ use editor::Editor;
use fuzzy_nucleo::StringMatchCandidate; use fuzzy_nucleo::StringMatchCandidate;
use collections::HashSet; use collections::HashSet;
use git::repository::Branch; use git::repository::{Branch, delete_branch_flag};
use gpui::http_client::Url; use gpui::http_client::Url;
use gpui::{ use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems, Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
}; };
use picker::{Picker, PickerDelegate, PickerEditorPosition}; use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::git_store::{Repository, RepositoryEvent}; use project::git_store::{Repository, RepositoryEvent};
@ -29,6 +29,8 @@ actions!(
[ [
/// Deletes the selected git branch or remote. /// Deletes the selected git branch or remote.
DeleteBranch, DeleteBranch,
/// Force deletes the selected git branch or remote.
ForceDeleteBranch,
/// Filter the list of remotes /// Filter the list of remotes
FilterRemotes FilterRemotes
] ]
@ -254,8 +256,10 @@ impl BranchList {
_: &mut Window, _: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.picker self.picker.update(cx, |picker, cx| {
.update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) picker.delegate.modifiers = ev.modifiers;
cx.notify();
})
} }
pub fn handle_delete( pub fn handle_delete(
@ -267,7 +271,20 @@ impl BranchList {
self.picker.update(cx, |picker, cx| { self.picker.update(cx, |picker, cx| {
picker picker
.delegate .delegate
.delete_at(picker.delegate.selected_index, window, cx) .delete_at(picker.delegate.selected_index, false, window, cx)
})
}
pub fn handle_force_delete(
&mut self,
_: &branch_picker::ForceDeleteBranch,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.picker.update(cx, |picker, cx| {
picker
.delegate
.delete_at(picker.delegate.selected_index, true, window, cx)
}) })
} }
@ -301,6 +318,7 @@ impl Render for BranchList {
.w(self.width) .w(self.width)
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.on_action(cx.listener(Self::handle_delete)) .on_action(cx.listener(Self::handle_delete))
.on_action(cx.listener(Self::handle_force_delete))
.on_action(cx.listener(Self::handle_filter)) .on_action(cx.listener(Self::handle_filter))
.child(self.picker.clone()) .child(self.picker.clone())
.when(!self.embedded, |this| { .when(!self.embedded, |this| {
@ -393,6 +411,7 @@ pub struct BranchListDelegate {
focus_handle: FocusHandle, focus_handle: FocusHandle,
restore_selected_branch: Option<SharedString>, restore_selected_branch: Option<SharedString>,
show_footer: bool, show_footer: bool,
hovered_delete_index: Option<usize>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -407,6 +426,77 @@ enum PickerState {
NewBranch, NewBranch,
} }
fn delete_branch_command(is_remote: bool, branch_name: &str, force: bool) -> String {
format!(
"branch {} {branch_name}",
delete_branch_flag(is_remote, force)
)
}
// Git only reports "not fully merged" via localized stderr, so this
// best-effort check may miss some locales and fall back to the raw error toast.
fn is_unmerged_branch_delete_error(error: &anyhow::Error) -> bool {
error
.to_string()
.to_lowercase()
.contains("not fully merged")
}
struct DeleteBranchTooltip {
picker: WeakEntity<Picker<BranchListDelegate>>,
focus_handle: FocusHandle,
delete_index: usize,
_subscription: Subscription,
}
impl DeleteBranchTooltip {
fn new(
picker: Entity<Picker<BranchListDelegate>>,
focus_handle: FocusHandle,
delete_index: usize,
cx: &mut Context<Self>,
) -> Self {
let subscription = cx.observe(&picker, |_, _, cx| cx.notify());
Self {
picker: picker.downgrade(),
focus_handle,
delete_index,
_subscription: subscription,
}
}
}
impl Render for DeleteBranchTooltip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let force_delete = self
.picker
.read_with(cx, |picker, _| {
picker
.delegate
.is_force_delete_hovering_index(self.delete_index)
})
.unwrap_or(false);
if force_delete {
Tooltip::for_action_in(
"Force Delete Branch",
&branch_picker::ForceDeleteBranch,
&self.focus_handle,
cx,
)
.into_any_element()
} else {
Tooltip::with_meta_in(
"Delete Branch",
Some(&branch_picker::DeleteBranch),
"Hold alt to force delete",
&self.focus_handle,
cx,
)
.into_any_element()
}
}
}
fn process_branches(branches: &Arc<[Branch]>) -> Vec<Branch> { fn process_branches(branches: &Arc<[Branch]>) -> Vec<Branch> {
let remote_upstreams: HashSet<_> = branches let remote_upstreams: HashSet<_> = branches
.iter() .iter()
@ -460,9 +550,14 @@ impl BranchListDelegate {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
restore_selected_branch: None, restore_selected_branch: None,
show_footer: false, show_footer: false,
hovered_delete_index: None,
} }
} }
fn is_force_delete_hovering_index(&self, index: usize) -> bool {
self.modifiers.alt && self.hovered_delete_index == Some(index)
}
fn create_branch( fn create_branch(
&self, &self,
from_branch: Option<SharedString>, from_branch: Option<SharedString>,
@ -509,7 +604,13 @@ impl BranchListDelegate {
cx.emit(DismissEvent); cx.emit(DismissEvent);
} }
fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) { fn delete_at(
&self,
idx: usize,
force: bool,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let Some(entry) = self.matches.get(idx).cloned() else { let Some(entry) = self.matches.get(idx).cloned() else {
return; return;
}; };
@ -520,49 +621,75 @@ impl BranchListDelegate {
let workspace = self.workspace.clone(); let workspace = self.workspace.clone();
cx.spawn_in(window, async move |picker, cx| { cx.spawn_in(window, async move |picker, cx| {
let is_remote; let Entry::Branch { branch, .. } = &entry else {
let result = match &entry { log::error!("Failed to delete entry: wrong entry to delete");
Entry::Branch { branch, .. } => { return Ok(());
if branch.is_head { };
return Ok(());
if branch.is_head {
return Ok(());
}
let is_remote = branch.is_remote();
let branch_name = branch.name().to_string();
let initial_result = repo
.update(cx, |repo, _| {
repo.delete_branch(is_remote, branch_name.clone(), force)
})
.await?;
let (result, attempted_force) = match initial_result {
Ok(()) => (Ok(()), force),
Err(error) => {
if is_remote {
log::error!("Failed to delete remote branch: {error}");
} else {
log::error!("Failed to delete branch: {error}");
} }
is_remote = branch.is_remote(); if force || !is_unmerged_branch_delete_error(&error) {
repo.update(cx, |repo, _| { (Err(error), force)
repo.delete_branch(is_remote, branch.name().to_string()) } else {
}) let answer = cx.update(|window, cx| {
.await? window.prompt(
} PromptLevel::Warning,
_ => { &format!(
log::error!("Failed to delete entry: wrong entry to delete"); "Branch \"{}\" is not fully merged. Force delete it?",
return Ok(()); entry.name()
),
None,
&["Force Delete", "Cancel"],
cx,
)
})?;
if answer.await != Ok(0) {
return Ok(());
}
let retry = repo
.update(cx, |repo, _| {
repo.delete_branch(is_remote, branch_name, true)
})
.await?;
if let Err(error) = &retry {
log::error!("Failed to force delete branch: {error}");
}
(retry, true)
}
} }
}; };
if let Err(e) = result { if let Err(error) = result {
if is_remote {
log::error!("Failed to delete remote branch: {}", e);
} else {
log::error!("Failed to delete branch: {}", e);
}
if let Some(workspace) = workspace.upgrade() { if let Some(workspace) = workspace.upgrade() {
cx.update(|_window, cx| { cx.update(|_window, cx| {
if is_remote { show_error_toast(
show_error_toast( workspace,
workspace, delete_branch_command(is_remote, entry.name(), attempted_force),
format!("branch -dr {}", entry.name()), error,
e, cx,
cx, )
)
} else {
show_error_toast(
workspace,
format!("branch -d {}", entry.name()),
e,
cx,
)
}
})?; })?;
} }
@ -585,6 +712,8 @@ impl BranchListDelegate {
picker.delegate.selected_index = picker.delegate.matches.len() - 1; picker.delegate.selected_index = picker.delegate.matches.len() - 1;
} }
picker.delegate.hovered_delete_index = None;
cx.notify(); cx.notify();
})?; })?;
@ -980,6 +1109,7 @@ impl PickerDelegate for BranchListDelegate {
}; };
let focus_handle = self.focus_handle.clone(); let focus_handle = self.focus_handle.clone();
let picker = cx.entity();
let is_new_items = matches!( let is_new_items = matches!(
entry, entry,
Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. } Entry::NewUrl { .. } | Entry::NewBranch { .. } | Entry::NewRemoteName { .. }
@ -988,19 +1118,44 @@ impl PickerDelegate for BranchListDelegate {
let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head); let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head);
let deleted_branch_icon = |entry_ix: usize| { let deleted_branch_icon = |entry_ix: usize| {
IconButton::new(("delete", entry_ix), IconName::Trash) let picker = picker.clone();
.icon_size(IconSize::Small) let focus_handle = focus_handle.clone();
.tooltip(move |_, cx| { let force_delete = self.is_force_delete_hovering_index(entry_ix);
Tooltip::for_action_in(
"Delete Branch", div()
&branch_picker::DeleteBranch, .id(("delete-hover", entry_ix))
&focus_handle, .on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
cx, if *hovered {
) this.delegate.hovered_delete_index = Some(entry_ix);
}) } else if this.delegate.hovered_delete_index == Some(entry_ix) {
.on_click(cx.listener(move |this, _, window, cx| { this.delegate.hovered_delete_index = None;
this.delegate.delete_at(entry_ix, window, cx); }
cx.notify();
})) }))
.child(
IconButton::new(("delete", entry_ix), IconName::Trash)
.icon_size(IconSize::Small)
.when(force_delete, |this| this.icon_color(Color::Error))
.tooltip(move |_, cx| {
cx.new(|cx| {
DeleteBranchTooltip::new(
picker.clone(),
focus_handle.clone(),
entry_ix,
cx,
)
})
.into()
})
.on_click(cx.listener(move |this, _, window, cx| {
this.delegate.delete_at(
entry_ix,
this.delegate.modifiers.alt,
window,
cx,
);
})),
)
}; };
let create_from_default_button = self.default_branch.as_ref().map(|default_branch| { let create_from_default_button = self.default_branch.as_ref().map(|default_branch| {
@ -1480,9 +1635,9 @@ mod tests {
(branch_list, cx) (branch_list, cx)
} }
async fn init_fake_repository( async fn init_fake_repository_with_fs(
cx: &mut TestAppContext, cx: &mut TestAppContext,
) -> (Entity<Project>, Entity<Repository>) { ) -> (Arc<FakeFs>, Entity<Project>, Entity<Repository>) {
let fs = FakeFs::new(cx.executor()); let fs = FakeFs::new(cx.executor());
fs.insert_tree( fs.insert_tree(
path!("/dir"), path!("/dir"),
@ -1505,7 +1660,14 @@ mod tests {
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let repository = cx.read(|cx| project.read(cx).active_repository(cx)); let repository = cx.read(|cx| project.read(cx).active_repository(cx));
(project, repository.unwrap()) (fs, project, repository.unwrap())
}
async fn init_fake_repository(
cx: &mut TestAppContext,
) -> (Entity<Project>, Entity<Repository>) {
let (_, project, repository) = init_fake_repository_with_fs(cx).await;
(project, repository)
} }
#[gpui::test] #[gpui::test]
@ -1597,7 +1759,7 @@ mod tests {
branch_list.picker.update(cx, |picker, cx| { branch_list.picker.update(cx, |picker, cx| {
assert_eq!(picker.delegate.matches.len(), 4); assert_eq!(picker.delegate.matches.len(), 4);
let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
picker.delegate.delete_at(1, window, cx); picker.delegate.delete_at(1, false, window, cx);
branch_to_delete branch_to_delete
}) })
}); });
@ -1641,6 +1803,238 @@ mod tests {
}); });
} }
#[gpui::test]
async fn test_delete_unmerged_branch_prompts_for_force_delete(cx: &mut TestAppContext) {
init_test(cx);
let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
let branches = create_test_branches();
let branch_names = branches
.iter()
.map(|branch| branch.name().to_string())
.collect::<Vec<String>>();
let repo = repository.clone();
cx.spawn(async move |mut cx| {
for branch in branch_names {
repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
.await
.unwrap()
.unwrap();
}
})
.await;
cx.run_until_parked();
let branch_to_delete = "feature-auth";
fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
state
.branches_requiring_force_delete
.insert(branch_to_delete.to_string());
})
.expect("failed to mark test branch as requiring force delete");
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
let cx = &mut ctx;
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
branch_list.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
let branch_index = picker
.delegate
.matches
.iter()
.position(|entry| entry.name() == branch_to_delete)
.unwrap();
picker.delegate.delete_at(branch_index, false, window, cx);
})
});
cx.run_until_parked();
assert!(cx.has_pending_prompt());
cx.simulate_prompt_answer("Force Delete");
cx.run_until_parked();
let repo_branches = branch_list
.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, cx| {
picker
.delegate
.repo
.as_ref()
.unwrap()
.update(cx, |repo, _cx| repo.branches())
})
})
.await
.unwrap()
.unwrap();
assert!(
repo_branches
.iter()
.all(|branch| branch.name() != branch_to_delete)
);
}
#[gpui::test]
async fn test_delete_unmerged_branch_cancel_keeps_branch(cx: &mut TestAppContext) {
init_test(cx);
let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
let branches = create_test_branches();
let branch_names = branches
.iter()
.map(|branch| branch.name().to_string())
.collect::<Vec<String>>();
let repo = repository.clone();
cx.spawn(async move |mut cx| {
for branch in branch_names {
repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
.await
.unwrap()
.unwrap();
}
})
.await;
cx.run_until_parked();
let branch_to_delete = "feature-auth";
fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
state
.branches_requiring_force_delete
.insert(branch_to_delete.to_string());
})
.expect("failed to mark test branch as requiring force delete");
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
let cx = &mut ctx;
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
let initial_match_count = branch_list.update(cx, |branch_list, cx| {
branch_list
.picker
.update(cx, |picker, _| picker.delegate.matches.len())
});
branch_list.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
let branch_index = picker
.delegate
.matches
.iter()
.position(|entry| entry.name() == branch_to_delete)
.unwrap();
picker.delegate.delete_at(branch_index, false, window, cx);
})
});
cx.run_until_parked();
assert!(cx.has_pending_prompt());
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
let repo_branches = branch_list
.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, cx| {
picker
.delegate
.repo
.as_ref()
.unwrap()
.update(cx, |repo, _cx| repo.branches())
})
})
.await
.unwrap()
.unwrap();
assert!(
repo_branches
.iter()
.any(|branch| branch.name() == branch_to_delete),
"branch should still exist after cancelling the force-delete prompt"
);
let final_match_count = branch_list.update(cx, |branch_list, cx| {
branch_list
.picker
.update(cx, |picker, _| picker.delegate.matches.len())
});
assert_eq!(
initial_match_count, final_match_count,
"picker matches should be unchanged after cancel"
);
}
#[gpui::test]
async fn test_force_delete_click_deletes_branch_without_prompt(cx: &mut TestAppContext) {
init_test(cx);
let (fs, _project, repository) = init_fake_repository_with_fs(cx).await;
let branches = create_test_branches();
let branch_names = branches
.iter()
.map(|branch| branch.name().to_string())
.collect::<Vec<String>>();
let repo = repository.clone();
cx.spawn(async move |mut cx| {
for branch in branch_names {
repo.update(&mut cx, |repo, _| repo.create_branch(branch, None))
.await
.unwrap()
.unwrap();
}
})
.await;
cx.run_until_parked();
let branch_to_delete = "feature-auth";
fs.with_git_state(path!("/dir/.git").as_ref(), true, |state| {
state
.branches_requiring_force_delete
.insert(branch_to_delete.to_string());
})
.expect("failed to mark test branch as requiring force delete");
let (branch_list, mut ctx) = init_branch_list_test(repository.into(), branches, cx).await;
let cx = &mut ctx;
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
branch_list.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
picker.delegate.modifiers = Modifiers::alt();
let branch_index = picker
.delegate
.matches
.iter()
.position(|entry| entry.name() == branch_to_delete)
.unwrap();
picker.delegate.delete_at(branch_index, true, window, cx);
})
});
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
let repo_branches = branch_list
.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, cx| {
picker
.delegate
.repo
.as_ref()
.unwrap()
.update(cx, |repo, _cx| repo.branches())
})
})
.await
.unwrap()
.unwrap();
assert!(
repo_branches
.iter()
.all(|branch| branch.name() != branch_to_delete)
);
}
#[gpui::test] #[gpui::test]
async fn test_delete_remote_branch(cx: &mut TestAppContext) { async fn test_delete_remote_branch(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
@ -1683,7 +2077,7 @@ mod tests {
branch_list.picker.update(cx, |picker, cx| { branch_list.picker.update(cx, |picker, cx| {
assert_eq!(picker.delegate.matches.len(), 4); assert_eq!(picker.delegate.matches.len(), 4);
let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string();
picker.delegate.delete_at(1, window, cx); picker.delegate.delete_at(1, false, window, cx);
branch_to_delete branch_to_delete
}) })
}); });

View file

@ -2244,7 +2244,7 @@ impl GitPanel {
let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx)); let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
let wrapped_message = editor.update(cx, |editor, cx| { let wrapped_message = editor.update(cx, |editor, cx| {
editor.select_all(&Default::default(), window, cx); editor.select_all(&Default::default(), window, cx);
editor.rewrap_impl( editor.rewrap(
RewrapOptions { RewrapOptions {
override_language_settings: false, override_language_settings: false,
preserve_existing_whitespace: true, preserve_existing_whitespace: true,

View file

@ -12,7 +12,7 @@ use ui::{
}; };
use workspace::{ModalView, Workspace, pane}; use workspace::{ModalView, Workspace, pane};
use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes}; use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes, ForceDeleteBranch};
use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList}; use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList};
actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]); actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]);
@ -295,6 +295,19 @@ impl GitPicker {
} }
} }
fn handle_force_delete_branch(
&mut self,
_: &ForceDeleteBranch,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(branch_list) = &self.branch_list {
branch_list.update(cx, |list, cx| {
list.handle_force_delete(&ForceDeleteBranch, window, cx);
});
}
}
fn handle_filter_remotes( fn handle_filter_remotes(
&mut self, &mut self,
_: &FilterRemotes, _: &FilterRemotes,
@ -407,6 +420,7 @@ impl Render for GitPicker {
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.when(self.tab == GitPickerTab::Branches, |el| { .when(self.tab == GitPickerTab::Branches, |el| {
el.on_action(cx.listener(Self::handle_delete_branch)) el.on_action(cx.listener(Self::handle_delete_branch))
.on_action(cx.listener(Self::handle_force_delete_branch))
.on_action(cx.listener(Self::handle_filter_remotes)) .on_action(cx.listener(Self::handle_filter_remotes))
}) })
.when(self.tab == GitPickerTab::Stash, |el| { .when(self.tab == GitPickerTab::Stash, |el| {

View file

@ -71,12 +71,27 @@ struct StateInner {
scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>, scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>,
scrollbar_drag_start_height: Option<Pixels>, scrollbar_drag_start_height: Option<Pixels>,
measuring_behavior: ListMeasuringBehavior, measuring_behavior: ListMeasuringBehavior,
pending_scroll: Option<PendingScrollFraction>, pending_scroll: Option<PendingScroll>,
follow_state: FollowState, follow_state: FollowState,
} }
/// Deferred scroll adjustment applied after the scroll-top item has been remeasured.
///
/// An absolute pending scroll preserves the same pixel offset into the item, which keeps
/// visible text stable while content is appended to or removed from that item. A
/// proportional pending scroll preserves the same fractional position within the item,
/// which is useful when the whole list is being resized and each item scales similarly.
#[derive(Clone)]
enum PendingScroll {
/// Preserve the same pixel offset into the item after it is remeasured.
Absolute { item_ix: usize, offset: Pixels },
/// Preserve the same fractional offset into the item after it is remeasured.
Proportional(PendingScrollFraction),
}
/// Keeps track of a fractional scroll position within an item for restoration /// Keeps track of a fractional scroll position within an item for restoration
/// after remeasurement. /// after remeasurement.
#[derive(Clone)]
struct PendingScrollFraction { struct PendingScrollFraction {
/// The index of the item to scroll within. /// The index of the item to scroll within.
item_ix: usize, item_ix: usize,
@ -84,6 +99,15 @@ struct PendingScrollFraction {
fraction: f32, fraction: f32,
} }
/// Determines how remeasurement preserves the scroll position when the scroll-top item
/// changes height.
enum ScrollAnchor {
/// Preserve the same pixel offset into the scroll-top item.
Absolute,
/// Preserve the same fractional position within the scroll-top item.
Proportional,
}
/// Controls whether the list automatically follows new content at the end. /// Controls whether the list automatically follows new content at the end.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum FollowMode { pub enum FollowMode {
@ -271,6 +295,7 @@ struct ListItemSummary {
unrendered_count: usize, unrendered_count: usize,
height: Pixels, height: Pixels,
has_focus_handles: bool, has_focus_handles: bool,
has_unknown_height: bool,
} }
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
@ -335,7 +360,7 @@ impl ListState {
/// but the number and identity of items remains the same. /// but the number and identity of items remains the same.
pub fn remeasure(&self) { pub fn remeasure(&self) {
let count = self.item_count(); let count = self.item_count();
self.remeasure_items(0..count); self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional);
} }
/// Mark items in `range` as needing remeasurement while preserving /// Mark items in `range` as needing remeasurement while preserving
@ -346,31 +371,47 @@ impl ListState {
/// height may be different (e.g., streaming text, tool results /// height may be different (e.g., streaming text, tool results
/// loading), but the item itself still exists at the same index. /// loading), but the item itself still exists at the same index.
pub fn remeasure_items(&self, range: Range<usize>) { pub fn remeasure_items(&self, range: Range<usize>) {
self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute);
}
fn remeasure_items_with_scroll_anchor(&self, range: Range<usize>, scroll_anchor: ScrollAnchor) {
let state = &mut *self.0.borrow_mut(); let state = &mut *self.0.borrow_mut();
// If the scroll-top item falls within the remeasured range,
// store a fractional offset so the layout can restore the
// proportional scroll position after the item is re-rendered
// at its new height.
if let Some(scroll_top) = state.logical_scroll_top { if let Some(scroll_top) = state.logical_scroll_top {
if range.contains(&scroll_top.item_ix) { if range.contains(&scroll_top.item_ix) {
let mut cursor = state.items.cursor::<Count>(()); state.pending_scroll = match scroll_anchor {
cursor.seek(&Count(scroll_top.item_ix), Bias::Right); ScrollAnchor::Absolute => Some(PendingScroll::Absolute {
item_ix: scroll_top.item_ix,
offset: scroll_top.offset_in_item,
}),
ScrollAnchor::Proportional => {
// If the scroll-top item falls within the remeasured range,
// store a fractional offset so the layout can restore the
// proportional scroll position after the item is re-rendered
// at its new height.
let mut cursor = state.items.cursor::<Count>(());
cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
if let Some(item) = cursor.item() { cursor
if let Some(size) = item.size() { .item()
let fraction = if size.height.0 > 0.0 { .and_then(|item| {
(scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0) item.size().map(|size| {
} else { let fraction = if size.height.0 > 0.0 {
0.0 (scroll_top.offset_in_item.0 / size.height.0)
}; .clamp(0.0, 1.0)
} else {
0.0
};
state.pending_scroll = Some(PendingScrollFraction { PendingScroll::Proportional(PendingScrollFraction {
item_ix: scroll_top.item_ix, item_ix: scroll_top.item_ix,
fraction, fraction,
}); })
})
})
.or_else(|| state.pending_scroll.clone())
} }
} };
} }
} }
@ -399,6 +440,25 @@ impl ListState {
self.0.borrow().items.summary().count self.0.borrow().items.summary().count
} }
/// Whether the list is scrolled to the end, or `None` if the list is
/// not scrollable or the total content height is not yet known.
pub fn is_scrolled_to_end(&self) -> Option<bool> {
let state = self.0.borrow();
let bounds = state.last_layout_bounds?;
let summary = state.items.summary();
if summary.has_unknown_height {
return None;
}
let padding = state.last_padding.unwrap_or_default();
let content_height = summary.height + padding.top + padding.bottom;
let scroll_max = (content_height - bounds.size.height).max(px(0.));
if scroll_max <= px(0.) {
return None;
}
let scroll_top = state.scroll_top(&state.logical_scroll_top());
Some(scroll_top >= scroll_max)
}
/// Inform the list state that the items in `old_range` have been replaced /// Inform the list state that the items in `old_range` have been replaced
/// by `count` new items that must be recalculated. /// by `count` new items that must be recalculated.
pub fn splice(&self, old_range: Range<usize>, count: usize) { pub fn splice(&self, old_range: Range<usize>, count: usize) {
@ -874,14 +934,26 @@ impl StateInner {
size = Some(element_size); size = Some(element_size);
// If there's a pending scroll adjustment for the scroll-top // If there's a pending scroll adjustment for the scroll-top
// item, apply it, ensuring proportional scroll position is // item, apply it.
// maintained after re-measuring.
if ix == 0 { if ix == 0 {
if let Some(pending_scroll) = self.pending_scroll.take() { if let Some(pending_scroll) = self.pending_scroll.take() {
if pending_scroll.item_ix == scroll_top.item_ix { match pending_scroll {
scroll_top.offset_in_item = PendingScroll::Absolute { item_ix, offset }
Pixels(pending_scroll.fraction * element_size.height.0); if item_ix == scroll_top.item_ix =>
self.logical_scroll_top = Some(scroll_top); {
scroll_top.offset_in_item = offset.min(element_size.height);
self.logical_scroll_top = Some(scroll_top);
}
PendingScroll::Proportional(pending_scroll)
if pending_scroll.item_ix == scroll_top.item_ix =>
{
// Ensuring proportional scroll position is
// maintained after re-measuring.
scroll_top.offset_in_item =
Pixels(pending_scroll.fraction * element_size.height.0);
self.logical_scroll_top = Some(scroll_top);
}
_ => {}
} }
} }
} }
@ -1385,6 +1457,7 @@ impl sum_tree::Item for ListItem {
px(0.) px(0.)
}, },
has_focus_handles: focus_handle.is_some(), has_focus_handles: focus_handle.is_some(),
has_unknown_height: size_hint.is_none(),
}, },
ListItem::Measured { ListItem::Measured {
size, focus_handle, .. size, focus_handle, ..
@ -1394,6 +1467,7 @@ impl sum_tree::Item for ListItem {
unrendered_count: 0, unrendered_count: 0,
height: size.height, height: size.height,
has_focus_handles: focus_handle.is_some(), has_focus_handles: focus_handle.is_some(),
has_unknown_height: false,
}, },
} }
} }
@ -1410,6 +1484,7 @@ impl sum_tree::ContextLessSummary for ListItemSummary {
self.unrendered_count += summary.unrendered_count; self.unrendered_count += summary.unrendered_count;
self.height += summary.height; self.height += summary.height;
self.has_focus_handles |= summary.has_focus_handles; self.has_focus_handles |= summary.has_focus_handles;
self.has_unknown_height |= summary.has_unknown_height;
} }
} }
@ -1646,6 +1721,60 @@ mod test {
assert_eq!(offset.offset_in_item, px(20.)); assert_eq!(offset.offset_in_item, px(20.));
} }
#[gpui::test]
fn test_remeasure_item_preserves_scroll_offset(cx: &mut TestAppContext) {
let cx = cx.add_empty_window();
let item_height = Rc::new(Cell::new(100usize));
let state = ListState::new(20, crate::ListAlignment::Top, px(10.));
struct TestView {
state: ListState,
item_height: Rc<Cell<usize>>,
}
impl Render for TestView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let height = self.item_height.get();
list(self.state.clone(), move |index, _, _| {
let height = if index == 5 { height } else { 100 };
div().h(px(height as f32)).w_full().into_any()
})
.w_full()
.h_full()
}
}
let state_clone = state.clone();
let item_height_clone = item_height.clone();
let view = cx.update(|_, cx| {
cx.new(|_| TestView {
state: state_clone,
item_height: item_height_clone,
})
});
state.scroll_to(gpui::ListOffset {
item_ix: 5,
offset_in_item: px(40.),
});
cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
view.clone().into_any_element()
});
item_height.set(200);
state.remeasure_items(5..6);
cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| {
view.into_any_element()
});
let offset = state.logical_scroll_top();
assert_eq!(offset.item_ix, 5);
assert_eq!(offset.offset_in_item, px(40.));
}
#[gpui::test] #[gpui::test]
fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
let cx = cx.add_empty_window(); let cx = cx.add_empty_window();

View file

@ -8,7 +8,7 @@ use crate::{
AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity, AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity,
GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement,
IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size,
StyleRefinement, Styled, Window, point, size, StyleRefinement, Styled, Window, point, px, size,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize}; use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize};
@ -236,6 +236,18 @@ impl UniformListScrollHandle {
} }
} }
/// Whether the list is scrolled to the end, or `None` if the list is
/// not scrollable.
pub fn is_scrolled_to_end(&self) -> Option<bool> {
let state = self.0.borrow();
let max_offset = state.base_handle.max_offset();
if max_offset.y <= px(0.) {
return None;
}
let offset = state.base_handle.offset();
Some(-offset.y >= max_offset.y)
}
/// Scroll to the bottom of the list. /// Scroll to the bottom of the list.
pub fn scroll_to_bottom(&self) { pub fn scroll_to_bottom(&self) {
self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom); self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom);

View file

@ -129,6 +129,12 @@ where
.unwrap(); .unwrap();
parser parser
}); });
// Tree-sitter auto-resets the parser at the end of a successful parse,
// but the cancellation paths (progress callback returning `Break`,
// cancelled balancing) leave outstanding state on the parser. The next
// call to `parse_with_options` would then *resume* that cancelled parse
// instead of starting fresh.
parser.reset();
parser.set_included_ranges(&[]).unwrap(); parser.set_included_ranges(&[]).unwrap();
let result = func(&mut parser); let result = func(&mut parser);
PARSERS.lock().push(parser); PARSERS.lock().push(parser);
@ -1677,6 +1683,82 @@ mod tests {
); );
} }
#[test]
fn test_with_parser_resets_after_cancellation() {
use std::ops::ControlFlow;
use tree_sitter::{Language as TsLanguage, ParseOptions};
let rust_language: TsLanguage = tree_sitter_rust::LANGUAGE.into();
// Drain the shared pool so this test sees a deterministic LIFO order:
// the parser we push at the end of the first `with_parser` call is the
// one we pop at the start of the second call.
PARSERS.lock().clear();
// Large enough that tree-sitter invokes the progress callback before
// the parse completes; otherwise the cancellation never fires.
let large_input = format!("fn a() {{ {} }}", "b(c, d); e(f, g); ".repeat(5000));
let small_input = "fn z() {}";
// Cancel a parse via the progress callback. Tree-sitter retains the
// in-progress parse state on the parser (its `canceled_balancing` flag
// and/or non-empty parse stack), and the next call to
// `parse_with_options` will *resume* that parse unless the parser is
// reset first.
let cancelled = with_parser(|parser| {
parser.set_language(&rust_language).unwrap();
let bytes = large_input.as_bytes();
let mut break_immediately = |_: &_| ControlFlow::Break(());
parser.parse_with_options(
&mut |offset, _| {
if offset < bytes.len() {
&bytes[offset..]
} else {
&[]
}
},
None,
Some(ParseOptions {
progress_callback: Some(&mut break_immediately),
}),
)
});
assert!(
cancelled.is_none(),
"first parse should be cancelled by the progress callback"
);
// Deliberately do NOT call `set_language` here: tree-sitter's
// `ts_parser_set_language` internally calls `ts_parser_reset`, which
// would mask the very bug we're checking for. Instead we rely on the
// language being preserved across `parser.reset()` (it is) and verify
// that `with_parser` itself produces a clean parser for the next user.
let tree = with_parser(|parser| {
let bytes = small_input.as_bytes();
parser
.parse_with_options(
&mut |offset, _| {
if offset < bytes.len() {
&bytes[offset..]
} else {
&[]
}
},
None,
None,
)
.expect("parse of small_input should succeed")
});
assert_eq!(tree.root_node().byte_range(), 0..small_input.len());
assert_eq!(tree.root_node().kind(), "source_file");
assert!(
!tree.root_node().has_error(),
"tree should be error-free, got: {}",
tree.root_node().to_sexp()
);
}
#[gpui::test(iterations = 10)] #[gpui::test(iterations = 10)]
async fn test_language_loading(cx: &mut TestAppContext) { async fn test_language_loading(cx: &mut TestAppContext) {

View file

@ -1021,7 +1021,7 @@ mod tests {
speed: None, speed: None,
}; };
let (mistral_request, _) = into_mistral(request, mistral::Model::Pixtral12BLatest, None); let (mistral_request, _) = into_mistral(request, mistral::Model::MistralSmallLatest, None);
assert_eq!(mistral_request.messages.len(), 1); assert_eq!(mistral_request.messages.len(), 1);
assert!(matches!( assert!(matches!(

View file

@ -381,11 +381,14 @@ impl OllamaLanguageModel {
} }
} }
Role::Assistant => { Role::Assistant => {
let content = msg.string_contents(); let mut text_content = String::new();
let mut thinking = None; let mut thinking = None;
let mut tool_calls = Vec::new(); let mut tool_calls = Vec::new();
for content in msg.content.into_iter() { for content in msg.content.into_iter() {
match content { match content {
MessageContent::Text(text) => {
text_content.push_str(&text);
}
MessageContent::Thinking { text, .. } if !text.is_empty() => { MessageContent::Thinking { text, .. } if !text.is_empty() => {
thinking = Some(text) thinking = Some(text)
} }
@ -402,7 +405,7 @@ impl OllamaLanguageModel {
} }
} }
messages.push(ChatMessage::Assistant { messages.push(ChatMessage::Assistant {
content, content: text_content,
tool_calls: Some(tool_calls), tool_calls: Some(tool_calls),
images: if images.is_empty() { images: if images.is_empty() {
None None

View file

@ -1,5 +1,14 @@
use anyhow::Result;
use async_trait::async_trait;
use collections::HashMap;
use language::{LanguageServerName, LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
use lsp::LanguageServerBinary;
use node_runtime::{NodeRuntime, VersionStrategy};
use project::ContextProviderWithTasks; use project::ContextProviderWithTasks;
use semver::Version;
use std::{path::PathBuf, vec};
use task::{TaskTemplate, TaskTemplates, VariableName}; use task::{TaskTemplate, TaskTemplates, VariableName};
use util::{ResultExt, maybe};
pub(super) fn bash_task_context() -> ContextProviderWithTasks { pub(super) fn bash_task_context() -> ContextProviderWithTasks {
ContextProviderWithTasks::new(TaskTemplates(vec![ ContextProviderWithTasks::new(TaskTemplates(vec![
@ -17,6 +26,146 @@ pub(super) fn bash_task_context() -> ContextProviderWithTasks {
])) ]))
} }
pub struct BashLspAdapter {
node: NodeRuntime,
}
impl BashLspAdapter {
const PACKAGE_NAME: &str = "bash-language-server";
const NODE_MODULE_RELATIVE_SERVER_PATH: &str = "bash-language-server/out/cli.js";
pub fn new(node: NodeRuntime) -> Self {
Self { node }
}
async fn get_cached_server_binary(
container_dir: PathBuf,
env: HashMap<String, String>,
node: &NodeRuntime,
) -> Option<lsp::LanguageServerBinary> {
maybe!(async {
let server_path = container_dir
.join("node_modules")
.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
anyhow::ensure!(
server_path.exists(),
"missing executable in directory {server_path:?}"
);
Ok(LanguageServerBinary {
path: node.binary_path().await?,
env: Some(env),
arguments: vec![server_path.into(), "start".into()],
})
})
.await
.log_err()
}
}
impl LspInstaller for BashLspAdapter {
type BinaryVersion = Version;
async fn cached_server_binary(
&self,
container_dir: std::path::PathBuf,
delegate: &dyn LspAdapterDelegate,
) -> Option<lsp::LanguageServerBinary> {
let env = delegate.shell_env().await;
Self::get_cached_server_binary(container_dir, env, &self.node).await
}
async fn check_if_user_installed(
&self,
delegate: &dyn LspAdapterDelegate,
_: Option<Toolchain>,
_: &gpui::AsyncApp,
) -> Option<lsp::LanguageServerBinary> {
let path = delegate.which(Self::PACKAGE_NAME.as_ref()).await?;
let env = delegate.shell_env().await;
Some(LanguageServerBinary {
path,
env: Some(env),
arguments: vec!["start".into()],
})
}
async fn check_if_version_installed(
&self,
version: &Self::BinaryVersion,
container_dir: &PathBuf,
delegate: &dyn LspAdapterDelegate,
) -> Option<lsp::LanguageServerBinary> {
let server_path = container_dir
.join("node_modules")
.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
let should_install_language_server = self
.node
.should_install_npm_package(
Self::PACKAGE_NAME,
&server_path,
container_dir,
VersionStrategy::Latest(version),
)
.await;
if should_install_language_server {
None
} else {
let env = delegate.shell_env().await;
Some(LanguageServerBinary {
path: self.node.binary_path().await.ok()?,
env: Some(env),
arguments: vec![server_path.into(), "start".into()],
})
}
}
async fn fetch_latest_server_version(
&self,
_: &dyn LspAdapterDelegate,
_: bool,
_: &mut gpui::AsyncApp,
) -> Result<Self::BinaryVersion> {
self.node
.npm_package_latest_version(Self::PACKAGE_NAME)
.await
}
async fn fetch_server_binary(
&self,
latest_version: Self::BinaryVersion,
container_dir: std::path::PathBuf,
delegate: &dyn LspAdapterDelegate,
) -> Result<lsp::LanguageServerBinary> {
let server_path = container_dir
.join("node_modules")
.join(Self::NODE_MODULE_RELATIVE_SERVER_PATH);
self.node
.npm_install_packages(
&container_dir,
&[(Self::PACKAGE_NAME, &latest_version.to_string())],
)
.await?;
let env = delegate.shell_env().await;
Ok(LanguageServerBinary {
path: self.node.binary_path().await?,
env: Some(env),
arguments: vec![server_path.into(), "start".into()],
})
}
}
#[async_trait(?Send)]
impl LspAdapter for BashLspAdapter {
fn name(&self) -> LanguageServerName {
LanguageServerName::new_static(Self::PACKAGE_NAME)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext}; use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};

View file

@ -57,6 +57,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
#[cfg(feature = "load-grammars")] #[cfg(feature = "load-grammars")]
languages.register_native_grammars(grammars::native_grammars()); languages.register_native_grammars(grammars::native_grammars());
let bash_lsp_adapter = Arc::new(bash::BashLspAdapter::new(node.clone()));
let c_lsp_adapter = Arc::new(c::CLspAdapter); let c_lsp_adapter = Arc::new(c::CLspAdapter);
let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone())); let css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.clone()));
let eslint_adapter = Arc::new(eslint::EsLintLspAdapter::new(node.clone(), fs.clone())); let eslint_adapter = Arc::new(eslint::EsLintLspAdapter::new(node.clone(), fs.clone()));
@ -88,6 +89,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
LanguageInfo { LanguageInfo {
name: "bash", name: "bash",
context: Some(Arc::new(bash::bash_task_context())), context: Some(Arc::new(bash::bash_task_context())),
adapters: vec![bash_lsp_adapter],
..Default::default() ..Default::default()
}, },
LanguageInfo { LanguageInfo {

View file

@ -58,23 +58,19 @@ pub enum Model {
#[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")] #[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")]
MagistralMediumLatest, MagistralMediumLatest,
#[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")]
MagistralSmallLatest,
#[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")] #[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")]
OpenMistralNemo, OpenMistralNemo,
#[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")]
OpenCodestralMamba,
#[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")] #[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")]
DevstralMediumLatest, DevstralMediumLatest,
#[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")]
DevstralSmallLatest,
#[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")] #[serde(rename = "ministral-3b-latest", alias = "ministral-3b-latest")]
Pixtral12BLatest, Ministral3bLatest,
#[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")] #[serde(rename = "ministral-8b-latest", alias = "ministral-8b-latest")]
PixtralLargeLatest, Ministral8bLatest,
#[serde(rename = "ministral-14b-latest", alias = "ministral-14b-latest")]
Ministral14bLatest,
#[serde(rename = "custom")] #[serde(rename = "custom")]
Custom { Custom {
@ -102,13 +98,8 @@ impl Model {
"mistral-medium-latest" => Ok(Self::MistralMediumLatest), "mistral-medium-latest" => Ok(Self::MistralMediumLatest),
"mistral-small-latest" => Ok(Self::MistralSmallLatest), "mistral-small-latest" => Ok(Self::MistralSmallLatest),
"magistral-medium-latest" => Ok(Self::MagistralMediumLatest), "magistral-medium-latest" => Ok(Self::MagistralMediumLatest),
"magistral-small-latest" => Ok(Self::MagistralSmallLatest),
"open-mistral-nemo" => Ok(Self::OpenMistralNemo), "open-mistral-nemo" => Ok(Self::OpenMistralNemo),
"open-codestral-mamba" => Ok(Self::OpenCodestralMamba),
"devstral-medium-latest" => Ok(Self::DevstralMediumLatest), "devstral-medium-latest" => Ok(Self::DevstralMediumLatest),
"devstral-small-latest" => Ok(Self::DevstralSmallLatest),
"pixtral-12b-latest" => Ok(Self::Pixtral12BLatest),
"pixtral-large-latest" => Ok(Self::PixtralLargeLatest),
invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"),
} }
} }
@ -120,13 +111,11 @@ impl Model {
Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralMediumLatest => "mistral-medium-latest",
Self::MistralSmallLatest => "mistral-small-latest", Self::MistralSmallLatest => "mistral-small-latest",
Self::MagistralMediumLatest => "magistral-medium-latest", Self::MagistralMediumLatest => "magistral-medium-latest",
Self::MagistralSmallLatest => "magistral-small-latest",
Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenMistralNemo => "open-mistral-nemo",
Self::OpenCodestralMamba => "open-codestral-mamba",
Self::DevstralMediumLatest => "devstral-medium-latest", Self::DevstralMediumLatest => "devstral-medium-latest",
Self::DevstralSmallLatest => "devstral-small-latest", Self::Ministral3bLatest => "ministral-3b-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest", Self::Ministral8bLatest => "ministral-8b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest", Self::Ministral14bLatest => "ministral-14b-latest",
Self::Custom { name, .. } => name, Self::Custom { name, .. } => name,
} }
} }
@ -138,13 +127,11 @@ impl Model {
Self::MistralMediumLatest => "mistral-medium-latest", Self::MistralMediumLatest => "mistral-medium-latest",
Self::MistralSmallLatest => "mistral-small-latest", Self::MistralSmallLatest => "mistral-small-latest",
Self::MagistralMediumLatest => "magistral-medium-latest", Self::MagistralMediumLatest => "magistral-medium-latest",
Self::MagistralSmallLatest => "magistral-small-latest",
Self::OpenMistralNemo => "open-mistral-nemo", Self::OpenMistralNemo => "open-mistral-nemo",
Self::OpenCodestralMamba => "open-codestral-mamba",
Self::DevstralMediumLatest => "devstral-medium-latest", Self::DevstralMediumLatest => "devstral-medium-latest",
Self::DevstralSmallLatest => "devstral-small-latest", Self::Ministral3bLatest => "ministral-3b-latest",
Self::Pixtral12BLatest => "pixtral-12b-latest", Self::Ministral8bLatest => "ministral-8b-latest",
Self::PixtralLargeLatest => "pixtral-large-latest", Self::Ministral14bLatest => "ministral-14b-latest",
Self::Custom { Self::Custom {
name, display_name, .. name, display_name, ..
} => display_name.as_ref().unwrap_or(name), } => display_name.as_ref().unwrap_or(name),
@ -153,18 +140,16 @@ impl Model {
pub fn max_token_count(&self) -> u64 { pub fn max_token_count(&self) -> u64 {
match self { match self {
Self::CodestralLatest => 256000, Self::CodestralLatest => 128000,
Self::MistralLargeLatest => 256000, Self::MistralLargeLatest => 256000,
Self::MistralMediumLatest => 128000, Self::MistralMediumLatest => 128000,
Self::MistralSmallLatest => 32000, Self::MistralSmallLatest => 256000,
Self::MagistralMediumLatest => 128000, Self::MagistralMediumLatest => 128000,
Self::MagistralSmallLatest => 128000, Self::OpenMistralNemo => 128000,
Self::OpenMistralNemo => 131000,
Self::OpenCodestralMamba => 256000,
Self::DevstralMediumLatest => 256000, Self::DevstralMediumLatest => 256000,
Self::DevstralSmallLatest => 256000, Self::Ministral3bLatest => 256000,
Self::Pixtral12BLatest => 128000, Self::Ministral8bLatest => 256000,
Self::PixtralLargeLatest => 128000, Self::Ministral14bLatest => 256000,
Self::Custom { max_tokens, .. } => *max_tokens, Self::Custom { max_tokens, .. } => *max_tokens,
} }
} }
@ -185,31 +170,25 @@ impl Model {
| Self::MistralMediumLatest | Self::MistralMediumLatest
| Self::MistralSmallLatest | Self::MistralSmallLatest
| Self::MagistralMediumLatest | Self::MagistralMediumLatest
| Self::MagistralSmallLatest
| Self::OpenMistralNemo | Self::OpenMistralNemo
| Self::OpenCodestralMamba
| Self::DevstralMediumLatest | Self::DevstralMediumLatest
| Self::DevstralSmallLatest | Self::Ministral3bLatest
| Self::Pixtral12BLatest | Self::Ministral8bLatest
| Self::PixtralLargeLatest => true, | Self::Ministral14bLatest => true,
Self::Custom { supports_tools, .. } => supports_tools.unwrap_or(false), Self::Custom { supports_tools, .. } => supports_tools.unwrap_or(false),
} }
} }
pub fn supports_images(&self) -> bool { pub fn supports_images(&self) -> bool {
match self { match self {
Self::Pixtral12BLatest Self::MistralLargeLatest
| Self::PixtralLargeLatest
| Self::MistralMediumLatest | Self::MistralMediumLatest
| Self::MistralSmallLatest => true, | Self::MistralSmallLatest
Self::CodestralLatest
| Self::MistralLargeLatest
| Self::MagistralMediumLatest | Self::MagistralMediumLatest
| Self::MagistralSmallLatest | Self::Ministral3bLatest
| Self::OpenMistralNemo | Self::Ministral8bLatest
| Self::OpenCodestralMamba | Self::Ministral14bLatest => true,
| Self::DevstralMediumLatest Self::CodestralLatest | Self::OpenMistralNemo | Self::DevstralMediumLatest => false,
| Self::DevstralSmallLatest => false,
Self::Custom { Self::Custom {
supports_images, .. supports_images, ..
} => supports_images.unwrap_or(false), } => supports_images.unwrap_or(false),
@ -218,7 +197,7 @@ impl Model {
pub fn supports_thinking(&self) -> bool { pub fn supports_thinking(&self) -> bool {
match self { match self {
Self::MagistralMediumLatest | Self::MagistralSmallLatest => true, Self::MagistralMediumLatest => true,
Self::Custom { Self::Custom {
supports_thinking, .. supports_thinking, ..
} => supports_thinking.unwrap_or(false), } => supports_thinking.unwrap_or(false),

View file

@ -11,6 +11,41 @@ use util::rel_path::RelPath;
/// A default editorconfig file name to use when resolving project settings. /// A default editorconfig file name to use when resolving project settings.
pub const EDITORCONFIG_NAME: &str = ".editorconfig"; pub const EDITORCONFIG_NAME: &str = ".editorconfig";
/// The application name, used to derive platform-specific data, config, cache,
/// and state directory paths.
///
/// Forks should change this to avoid colliding with Zed's user data.
pub const APP_NAME: &str = "Zed";
/// Lowercased form of [`APP_NAME`], for use in XDG-style paths on
/// Linux/FreeBSD and the macOS `~/.config` fallback.
pub const APP_NAME_LOWERCASE: &str = {
assert!(!APP_NAME.is_empty(), "APP_NAME must not be empty");
assert!(APP_NAME.as_bytes().is_ascii(), "APP_NAME must be ASCII");
const BYTES: [u8; APP_NAME.len()] = {
let mut bytes = [0u8; APP_NAME.len()];
let mut i = 0;
while i < APP_NAME.len() {
assert!(
APP_NAME.as_bytes()[i] != b'/' && APP_NAME.as_bytes()[i] != b'\\',
"APP_NAME must not contain path separators",
);
assert!(
APP_NAME.as_bytes()[i] >= 0x20,
"APP_NAME must not contain control characters"
);
bytes[i] = APP_NAME.as_bytes()[i];
i += 1;
}
bytes.make_ascii_lowercase();
bytes
};
match std::str::from_utf8(&BYTES) {
Ok(s) => s,
Err(_) => unreachable!(),
}
};
/// A custom data directory override, set only by `set_custom_data_dir`. /// A custom data directory override, set only by `set_custom_data_dir`.
/// This is used to override the default data directory location. /// This is used to override the default data directory location.
/// The directory will be created if it doesn't exist when set. /// The directory will be created if it doesn't exist when set.
@ -91,16 +126,16 @@ pub fn config_dir() -> &'static PathBuf {
} else if cfg!(target_os = "windows") { } else if cfg!(target_os = "windows") {
dirs::config_dir() dirs::config_dir()
.expect("failed to determine RoamingAppData directory") .expect("failed to determine RoamingAppData directory")
.join("Zed") .join(APP_NAME)
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) { } else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") { if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") {
flatpak_xdg_config.into() flatpak_xdg_config.into()
} else { } else {
dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory") dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory")
} }
.join("zed") .join(APP_NAME_LOWERCASE)
} else { } else {
home_dir().join(".config").join("zed") home_dir().join(".config").join(APP_NAME_LOWERCASE)
} }
}) })
} }
@ -111,18 +146,20 @@ pub fn data_dir() -> &'static PathBuf {
if let Some(custom_dir) = CUSTOM_DATA_DIR.get() { if let Some(custom_dir) = CUSTOM_DATA_DIR.get() {
custom_dir.clone() custom_dir.clone()
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
home_dir().join("Library/Application Support/Zed") home_dir()
.join("Library/Application Support")
.join(APP_NAME)
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) { } else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") { if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
flatpak_xdg_data.into() flatpak_xdg_data.into()
} else { } else {
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory") dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
} }
.join("zed") .join(APP_NAME_LOWERCASE)
} else if cfg!(target_os = "windows") { } else if cfg!(target_os = "windows") {
dirs::data_local_dir() dirs::data_local_dir()
.expect("failed to determine LocalAppData directory") .expect("failed to determine LocalAppData directory")
.join("Zed") .join(APP_NAME)
} else { } else {
config_dir().clone() // Fallback config_dir().clone() // Fallback
} }
@ -133,7 +170,7 @@ pub fn state_dir() -> &'static PathBuf {
static STATE_DIR: OnceLock<PathBuf> = OnceLock::new(); static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
STATE_DIR.get_or_init(|| { STATE_DIR.get_or_init(|| {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
return home_dir().join(".local").join("state").join("Zed"); return home_dir().join(".local").join("state").join(APP_NAME);
} }
if cfg!(any(target_os = "linux", target_os = "freebsd")) { if cfg!(any(target_os = "linux", target_os = "freebsd")) {
@ -142,12 +179,12 @@ pub fn state_dir() -> &'static PathBuf {
} else { } else {
dirs::state_dir().expect("failed to determine XDG_STATE_HOME directory") dirs::state_dir().expect("failed to determine XDG_STATE_HOME directory")
} }
.join("zed"); .join(APP_NAME_LOWERCASE);
} else { } else {
// Windows // Windows
return dirs::data_local_dir() return dirs::data_local_dir()
.expect("failed to determine LocalAppData directory") .expect("failed to determine LocalAppData directory")
.join("Zed"); .join(APP_NAME);
} }
}) })
} }
@ -159,13 +196,13 @@ pub fn temp_dir() -> &'static PathBuf {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
return dirs::cache_dir() return dirs::cache_dir()
.expect("failed to determine cachesDirectory directory") .expect("failed to determine cachesDirectory directory")
.join("Zed"); .join(APP_NAME);
} }
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {
return dirs::cache_dir() return dirs::cache_dir()
.expect("failed to determine LocalAppData directory") .expect("failed to determine LocalAppData directory")
.join("Zed"); .join(APP_NAME);
} }
if cfg!(any(target_os = "linux", target_os = "freebsd")) { if cfg!(any(target_os = "linux", target_os = "freebsd")) {
@ -174,10 +211,10 @@ pub fn temp_dir() -> &'static PathBuf {
} else { } else {
dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory") dirs::cache_dir().expect("failed to determine XDG_CACHE_HOME directory")
} }
.join("zed"); .join(APP_NAME_LOWERCASE);
} }
home_dir().join(".cache").join("zed") home_dir().join(".cache").join(APP_NAME_LOWERCASE)
}) })
} }
@ -192,7 +229,7 @@ pub fn logs_dir() -> &'static PathBuf {
static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new(); static LOGS_DIR: OnceLock<PathBuf> = OnceLock::new();
LOGS_DIR.get_or_init(|| { LOGS_DIR.get_or_init(|| {
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
home_dir().join("Library/Logs/Zed") home_dir().join("Library/Logs").join(APP_NAME)
} else { } else {
data_dir().join("logs") data_dir().join("logs")
} }
@ -208,13 +245,13 @@ pub fn remote_server_state_dir() -> &'static PathBuf {
/// Returns the path to the `Zed.log` file. /// Returns the path to the `Zed.log` file.
pub fn log_file() -> &'static PathBuf { pub fn log_file() -> &'static PathBuf {
static LOG_FILE: OnceLock<PathBuf> = OnceLock::new(); static LOG_FILE: OnceLock<PathBuf> = OnceLock::new();
LOG_FILE.get_or_init(|| logs_dir().join("Zed.log")) LOG_FILE.get_or_init(|| logs_dir().join(format!("{}.log", APP_NAME)))
} }
/// Returns the path to the `Zed.log.old` file. /// Returns the path to the `Zed.log.old` file.
pub fn old_log_file() -> &'static PathBuf { pub fn old_log_file() -> &'static PathBuf {
static OLD_LOG_FILE: OnceLock<PathBuf> = OnceLock::new(); static OLD_LOG_FILE: OnceLock<PathBuf> = OnceLock::new();
OLD_LOG_FILE.get_or_init(|| logs_dir().join("Zed.log.old")) OLD_LOG_FILE.get_or_init(|| logs_dir().join(format!("{}.log.old", APP_NAME)))
} }
/// Returns the path to the database directory. /// Returns the path to the database directory.

View file

@ -35,6 +35,12 @@ pub enum Direction {
Down, Down,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScrollBehavior {
RevealSelected,
PreserveOffset,
}
actions!( actions!(
picker, picker,
[ [
@ -687,9 +693,19 @@ impl<D: PickerDelegate> Picker<D> {
} }
pub fn update_matches(&mut self, query: String, window: &mut Window, cx: &mut Context<Self>) { pub fn update_matches(&mut self, query: String, window: &mut Window, cx: &mut Context<Self>) {
self.update_matches_with_options(query, ScrollBehavior::RevealSelected, window, cx);
}
pub fn update_matches_with_options(
&mut self,
query: String,
scroll_behavior: ScrollBehavior,
window: &mut Window,
cx: &mut Context<Self>,
) {
let delegate_pending_update_matches = self.delegate.update_matches(query, window, cx); let delegate_pending_update_matches = self.delegate.update_matches(query, window, cx);
self.matches_updated(window, cx); self.matches_updated(scroll_behavior, window, cx);
// This struct ensures that we can synchronously drop the task returned by the // This struct ensures that we can synchronously drop the task returned by the
// delegate's `update_matches` method and the task that the picker is spawning. // delegate's `update_matches` method and the task that the picker is spawning.
// If we simply capture the delegate's task into the picker's task, when the picker's // If we simply capture the delegate's task into the picker's task, when the picker's
@ -709,19 +725,40 @@ impl<D: PickerDelegate> Picker<D> {
})?; })?;
delegate_pending_update_matches.await; delegate_pending_update_matches.await;
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
this.matches_updated(window, cx); this.matches_updated(scroll_behavior, window, cx);
}) })
}), }),
}); });
} }
fn matches_updated(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn matches_updated(
if let ElementContainer::List(state) = &mut self.element_container { &mut self,
state.reset(self.delegate.match_count()); scroll_behavior: ScrollBehavior,
window: &mut Window,
cx: &mut Context<Self>,
) {
let match_count = self.delegate.match_count();
match &mut self.element_container {
ElementContainer::List(state) => match scroll_behavior {
ScrollBehavior::RevealSelected => {
state.reset(match_count);
let index = self.delegate.selected_index();
self.scroll_to_item_index(index);
}
ScrollBehavior::PreserveOffset => {
let offset = state.logical_scroll_top();
state.reset(match_count);
state.scroll_to(offset);
}
},
ElementContainer::UniformList(_) => match scroll_behavior {
ScrollBehavior::RevealSelected => {
let index = self.delegate.selected_index();
self.scroll_to_item_index(index);
}
ScrollBehavior::PreserveOffset => {}
},
} }
let index = self.delegate.selected_index();
self.scroll_to_item_index(index);
self.pending_update_matches = None; self.pending_update_matches = None;
if let Some(secondary) = self.confirm_on_update.take() { if let Some(secondary) = self.confirm_on_update.take() {
self.do_confirm(secondary, window, cx); self.do_confirm(secondary, window, cx);
@ -752,6 +789,13 @@ impl<D: PickerDelegate> Picker<D> {
} }
} }
pub fn is_scrolled_to_end(&self) -> Option<bool> {
match &self.element_container {
ElementContainer::List(state) => state.is_scrolled_to_end(),
ElementContainer::UniformList(scroll_handle) => scroll_handle.is_scrolled_to_end(),
}
}
fn render_element( fn render_element(
&self, &self,
window: &mut Window, window: &mut Window,

View file

@ -1606,6 +1606,13 @@ impl ExternalAgentServer for LocalRegistryNpxAgent {
/// security settings, as the args don't change often. The registry will need to support this better /// security settings, as the args don't change often. The registry will need to support this better
/// at some point, but until then, this is a best-effort workaround that hopefully solves the issue /// at some point, but until then, this is a best-effort workaround that hopefully solves the issue
/// for most users. /// for most users.
///
/// We use npm's hyphen-range syntax (`0.0.0 - <version>`, equivalent to `<=<version>`) instead of
/// the more compact `<=<version>` form because on Windows, `npm` is `npm.cmd` (a batch file run by
/// cmd.exe), and the quotes our shell builder emits are PowerShell string-literal syntax that PS
/// strips during parsing. PS only re-adds CRT-style transport quotes around native command args
/// containing whitespace, so `package@<=0.25.3` reaches cmd.exe bare and the unquoted `<` is
/// interpreted as input redirection. See zed-industries/zed#55921.
fn bounded_npm_package_spec(package_spec: &str) -> String { fn bounded_npm_package_spec(package_spec: &str) -> String {
let Some((package_name, version)) = package_spec.rsplit_once('@') else { let Some((package_name, version)) = package_spec.rsplit_once('@') else {
return package_spec.to_string(); return package_spec.to_string();
@ -1614,7 +1621,7 @@ fn bounded_npm_package_spec(package_spec: &str) -> String {
return package_spec.to_string(); return package_spec.to_string();
} }
format!("{package_name}@<={version}") format!("{package_name}@0.0.0 - {version}")
} }
struct LocalCustomAgent { struct LocalCustomAgent {
@ -2025,11 +2032,11 @@ mod tests {
fn builds_bounded_npm_package_specs() { fn builds_bounded_npm_package_specs() {
assert_eq!( assert_eq!(
bounded_npm_package_spec("agent-package@1.2.3"), bounded_npm_package_spec("agent-package@1.2.3"),
"agent-package@<=1.2.3" "agent-package@0.0.0 - 1.2.3"
); );
assert_eq!( assert_eq!(
bounded_npm_package_spec("@scope/agent-package@1.2.3-beta.1"), bounded_npm_package_spec("@scope/agent-package@1.2.3-beta.1"),
"@scope/agent-package@<=1.2.3-beta.1" "@scope/agent-package@0.0.0 - 1.2.3-beta.1"
); );
assert_eq!( assert_eq!(
bounded_npm_package_spec("@scope/agent-package"), bounded_npm_package_spec("@scope/agent-package"),

View file

@ -607,10 +607,7 @@ impl ContextServerStore {
let server = state.server(); let server = state.server();
let configuration = state.configuration(); let configuration = state.configuration();
let mut result = Ok(()); let result = server.stop();
if let ContextServerState::Running { server, .. } = &state {
result = server.stop();
}
drop(state); drop(state);
self.update_server_state( self.update_server_state(

View file

@ -37,7 +37,7 @@ use git::{
CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository, CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository,
GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus,
Worktree as GitWorktree, Worktree as GitWorktree, delete_branch_flag,
}, },
stash::{GitStash, StashEntry}, stash::{GitStash, StashEntry},
status::{ status::{
@ -2981,10 +2981,11 @@ impl GitStore {
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
let is_remote = envelope.payload.is_remote; let is_remote = envelope.payload.is_remote;
let branch_name = envelope.payload.branch_name; let branch_name = envelope.payload.branch_name;
let force = envelope.payload.force;
repository_handle repository_handle
.update(&mut cx, |repository_handle, _| { .update(&mut cx, |repository_handle, _| {
repository_handle.delete_branch(is_remote, branch_name) repository_handle.delete_branch(is_remote, branch_name, force)
}) })
.await??; .await??;
@ -7367,21 +7368,19 @@ impl Repository {
&mut self, &mut self,
is_remote: bool, is_remote: bool,
branch_name: String, branch_name: String,
force: bool,
) -> oneshot::Receiver<Result<()>> { ) -> oneshot::Receiver<Result<()>> {
let id = self.id; let id = self.id;
let flag = delete_branch_flag(is_remote, force);
self.send_job( self.send_job(
Some( Some(format!("git branch {flag} {branch_name}").into()),
format!(
"git branch {} {}",
if is_remote { "-dr" } else { "-d" },
branch_name
)
.into(),
),
move |repo, _cx| async move { move |repo, _cx| async move {
match repo { match repo {
RepositoryState::Local(state) => { RepositoryState::Local(state) => {
state.backend.delete_branch(is_remote, branch_name).await state
.backend
.delete_branch(is_remote, branch_name, force)
.await
} }
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
client client
@ -7390,6 +7389,7 @@ impl Repository {
repository_id: id.to_proto(), repository_id: id.to_proto(),
is_remote, is_remote,
branch_name, branch_name,
force,
}) })
.await?; .await?;

View file

@ -66,7 +66,9 @@ use ui::{
StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex, StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex,
}; };
use util::{ use util::{
ResultExt, TakeUntilExt, TryFutureExt, maybe, ResultExt, TakeUntilExt, TryFutureExt,
markdown::MarkdownInlineCode,
maybe,
paths::{PathStyle, compare_paths}, paths::{PathStyle, compare_paths},
rel_path::{RelPath, RelPathBuf}, rel_path::{RelPath, RelPathBuf},
}; };
@ -2357,7 +2359,10 @@ impl ProjectPanel {
"" ""
}; };
format!("{message_start} {path}?{unsaved_warning}") format!(
"{message_start} {}?{unsaved_warning}",
MarkdownInlineCode(path)
)
} }
_ => { _ => {
const CUTOFF_POINT: usize = 10; const CUTOFF_POINT: usize = 10;
@ -2365,7 +2370,7 @@ impl ProjectPanel {
let truncated_path_counts = file_paths.len() - CUTOFF_POINT; let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
let mut paths = file_paths let mut paths = file_paths
.iter() .iter()
.map(|(_, _, path)| path.clone()) .map(|(_, _, path)| MarkdownInlineCode(path).to_string())
.take(CUTOFF_POINT) .take(CUTOFF_POINT)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
paths.truncate(CUTOFF_POINT); paths.truncate(CUTOFF_POINT);
@ -2376,7 +2381,10 @@ impl ProjectPanel {
} }
paths paths
} else { } else {
file_paths.iter().map(|(_, _, path)| path.clone()).collect() file_paths
.iter()
.map(|(_, _, path)| MarkdownInlineCode(path).to_string())
.collect()
}; };
let unsaved_warning = if dirty_buffers == 0 { let unsaved_warning = if dirty_buffers == 0 {
String::new() String::new()

View file

@ -11381,3 +11381,39 @@ impl Render for TestProjectItemView {
Empty Empty
} }
} }
#[gpui::test]
async fn test_delete_prompt_escapes_markdown_in_file_name(cx: &mut gpui::TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"__somefile__": "",
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(window.into(), cx);
let panel = workspace.update_in(cx, ProjectPanel::new);
cx.run_until_parked();
select_path(&panel, "root/__somefile__", cx);
panel.update_in(cx, |panel, window, cx| {
panel.delete(&Delete { skip_prompt: false }, window, cx)
});
let (message, _detail) = cx
.pending_prompt()
.expect("delete should show a confirmation prompt");
assert_eq!(
message,
"Are you sure you want to permanently delete `__somefile__`?"
);
}

View file

@ -215,6 +215,7 @@ message GitDeleteBranch {
uint64 repository_id = 2; uint64 repository_id = 2;
string branch_name = 3; string branch_name = 3;
bool is_remote = 4; bool is_remote = 4;
bool force = 5;
} }
message GitDiff { message GitDiff {

View file

@ -64,6 +64,7 @@ fs.workspace = true
gpui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] }
http_client.workspace = true http_client.workspace = true
language = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] }
picker = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] }
release_channel.workspace = true release_channel.workspace = true
remote = { workspace = true, features = ["test-support"] } remote = { workspace = true, features = ["test-support"] }

View file

@ -29,7 +29,7 @@ use gpui::{
}; };
use picker::{ use picker::{
Picker, PickerDelegate, Picker, PickerDelegate, ScrollBehavior,
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
}; };
use project::{Worktree, git_store::Repository}; use project::{Worktree, git_store::Repository};
@ -83,6 +83,15 @@ enum ProjectPickerEntry {
RecentProject(StringMatch), RecentProject(StringMatch),
} }
fn is_selectable_entry(entry: &ProjectPickerEntry) -> bool {
matches!(
entry,
ProjectPickerEntry::OpenFolder { .. }
| ProjectPickerEntry::ProjectGroup(_)
| ProjectPickerEntry::RecentProject(_)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProjectPickerStyle { enum ProjectPickerStyle {
Modal, Modal,
@ -814,8 +823,7 @@ pub struct RecentProjectsDelegate {
selected_index: usize, selected_index: usize,
render_paths: bool, render_paths: bool,
create_new_window: bool, create_new_window: bool,
// Flag to reset index when there is a new query vs not reset index when user delete an item snap_selection_to_first_non_header_match: bool,
reset_selected_match_index: bool,
has_any_non_local_projects: bool, has_any_non_local_projects: bool,
project_connection_options: Option<RemoteConnectionOptions>, project_connection_options: Option<RemoteConnectionOptions>,
focus_handle: FocusHandle, focus_handle: FocusHandle,
@ -843,7 +851,7 @@ impl RecentProjectsDelegate {
selected_index: 0, selected_index: 0,
create_new_window, create_new_window,
render_paths, render_paths,
reset_selected_match_index: true, snap_selection_to_first_non_header_match: true,
has_any_non_local_projects: project_connection_options.is_some(), has_any_non_local_projects: project_connection_options.is_some(),
project_connection_options, project_connection_options,
focus_handle, focus_handle,
@ -1067,14 +1075,14 @@ impl PickerDelegate for RecentProjectsDelegate {
self.filtered_entries = entries; self.filtered_entries = entries;
if self.reset_selected_match_index { if self.snap_selection_to_first_non_header_match {
self.selected_index = self self.selected_index = self
.filtered_entries .filtered_entries
.iter() .iter()
.position(|e| !matches!(e, ProjectPickerEntry::Header(_))) .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
.unwrap_or(0); .unwrap_or(0);
} }
self.reset_selected_match_index = true; self.snap_selection_to_first_non_header_match = true;
Task::ready(()) Task::ready(())
} }
@ -2106,6 +2114,69 @@ impl RecentProjectsDelegate {
.detach(); .detach();
} }
/// Returns the new selection index after the entry at `deleted_index`
/// is removed.
///
/// - Prefers the nearest entry matching `prefer_section` so the user
/// stays in the same section they were navigating.
/// - Falls back to any other selectable entry so the picker doesn't
/// land on a header.
fn replacement_index_after_deletion(
&self,
deleted_index: usize,
prefer_previous: bool,
prefer_section: fn(&ProjectPickerEntry) -> bool,
) -> Option<usize> {
let replacement_index = |matches_entry: fn(&ProjectPickerEntry) -> bool| {
let next_index = self
.filtered_entries
.iter()
.enumerate()
.skip(deleted_index)
.find_map(|(index, entry)| matches_entry(entry).then_some(index));
let previous_index = self
.filtered_entries
.iter()
.enumerate()
.take(deleted_index.min(self.filtered_entries.len()))
.rev()
.find_map(|(index, entry)| matches_entry(entry).then_some(index));
if prefer_previous {
previous_index.or(next_index)
} else {
next_index.or(previous_index)
}
};
replacement_index(prefer_section).or_else(|| replacement_index(is_selectable_entry))
}
fn update_picker_after_recent_project_deletion(
picker: &mut Picker<Self>,
deleted_index: usize,
workspaces: Vec<RecentWorkspace>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let prefer_previous = picker.is_scrolled_to_end() == Some(true);
picker.delegate.set_workspaces(workspaces);
picker.delegate.snap_selection_to_first_non_header_match = false;
picker.update_matches_with_options(
picker.query(cx),
ScrollBehavior::PreserveOffset,
window,
cx,
);
if let Some(replacement_index) = picker.delegate.replacement_index_after_deletion(
deleted_index,
prefer_previous,
|entry| matches!(entry, ProjectPickerEntry::RecentProject(_)),
) {
picker.set_selected_index(replacement_index, None, false, window, cx);
}
}
fn delete_recent_project( fn delete_recent_project(
&self, &self,
ix: usize, ix: usize,
@ -2115,7 +2186,10 @@ impl RecentProjectsDelegate {
if let Some(ProjectPickerEntry::RecentProject(selected_match)) = if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
self.filtered_entries.get(ix) self.filtered_entries.get(ix)
{ {
let recent_workspace = self.workspaces[selected_match.candidate_id].clone(); let Some(recent_workspace) = self.workspaces.get(selected_match.candidate_id).cloned()
else {
return;
};
let fs = self let fs = self
.workspace .workspace
.upgrade() .upgrade()
@ -2133,12 +2207,9 @@ impl RecentProjectsDelegate {
.await .await
.unwrap_or_default(); .unwrap_or_default();
this.update_in(cx, move |picker, window, cx| { this.update_in(cx, move |picker, window, cx| {
picker.delegate.set_workspaces(workspaces); Self::update_picker_after_recent_project_deletion(
picker picker, ix, workspaces, window, cx,
.delegate );
.set_selected_index(ix.saturating_sub(1), window, cx);
picker.delegate.reset_selected_match_index = false;
picker.update_matches(picker.query(cx), window, cx);
// After deleting a project, we want to update the history manager to reflect the change. // After deleting a project, we want to update the history manager to reflect the change.
// But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`. // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
if let Some(history_manager) = HistoryManager::global(cx) { if let Some(history_manager) = HistoryManager::global(cx) {
@ -2234,7 +2305,7 @@ impl RecentProjectsDelegate {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use gpui::{TestAppContext, UpdateGlobal}; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
use serde_json::json; use serde_json::json;
use settings::SettingsStore; use settings::SettingsStore;
@ -2243,6 +2314,220 @@ mod tests {
use super::*; use super::*;
// Test picker for the empty query:
//
// [0] Header("Current Folders")
// [1] OpenFolder(0)
// [2] OpenFolder(1)
// [3] Header("This Window")
// [4] ProjectGroup(0)
// [5] ProjectGroup(1)
// [6] Header("Recent Projects")
// [7..=26] RecentProject(0..=19)
//
const RECENT_PROJECT_COUNT: usize = 20;
const FIRST_RECENT_PROJECT: usize = 7;
const LAST_RECENT_PROJECT: usize = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT - 1;
fn open_folder(index: usize) -> OpenFolderEntry {
OpenFolderEntry {
worktree_id: WorktreeId::from_usize(index),
name: format!("project-folder-{index}").into(),
path: PathBuf::from(format!("/current/project-folder-{index}")),
branch: None,
is_active: false,
}
}
fn project_group(index: usize) -> ProjectGroupKey {
ProjectGroupKey::new(
None,
PathList::new(&[PathBuf::from(format!("/this-window/project-{index}"))]),
)
}
fn recent_workspace(index: usize) -> RecentWorkspace {
let paths = PathList::new(&[PathBuf::from(format!("/recent/project-{index:02}"))]);
RecentWorkspace {
workspace_id: WorkspaceId::from_i64(index as i64),
location: SerializedWorkspaceLocation::Local,
paths: paths.clone(),
identity_paths: paths,
timestamp: Utc::now(),
}
}
fn recent_workspaces() -> Vec<RecentWorkspace> {
(0..RECENT_PROJECT_COUNT).map(recent_workspace).collect()
}
fn draw(cx: &mut VisualTestContext) {
cx.update(|window, cx| window.draw(cx).clear());
}
fn build_picker(
cx: &mut TestAppContext,
) -> (
Entity<Picker<RecentProjectsDelegate>>,
&mut VisualTestContext,
) {
init_test(cx);
let (picker, cx) = cx.add_window_view(|window, cx| {
let mut delegate = RecentProjectsDelegate::new(
WeakEntity::new_invalid(),
false,
cx.focus_handle(),
vec![open_folder(0), open_folder(1)],
vec![project_group(0), project_group(1)],
None,
ProjectPickerStyle::Modal,
);
delegate.set_workspaces(recent_workspaces());
Picker::list(delegate, window, cx)
.list_measure_all()
.show_scrollbar(true)
.max_height(Some(px(240.).into()))
});
draw(cx);
(picker, cx)
}
fn scroll_to_and_select(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
index: usize,
) -> usize {
picker.update_in(cx, |picker, window, cx| {
picker.set_selected_index(index, None, true, window, cx);
});
draw(cx);
picker.update(cx, |picker, _| picker.logical_scroll_top_index())
}
fn delete_recent_project_in_picker(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
index: usize,
) {
picker.update_in(cx, |picker, window, cx| {
let Some(ProjectPickerEntry::RecentProject(hit)) =
picker.delegate.filtered_entries.get(index)
else {
panic!("expected entry at {index} to be a recent project");
};
let mut workspaces = picker.delegate.workspaces.clone();
workspaces.remove(hit.candidate_id);
RecentProjectsDelegate::update_picker_after_recent_project_deletion(
picker, index, workspaces, window, cx,
);
});
}
#[track_caller]
fn assert_scroll_top_is(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
expected: usize,
phase: &str,
) {
picker.update(cx, |picker, _| {
assert_eq!(
picker.logical_scroll_top_index(),
expected,
"scroll top should remain at {expected} ({phase})"
);
assert_selected_entry_is_recent_project(picker);
});
}
#[track_caller]
fn assert_pinned_to_bottom(
picker: &Entity<Picker<RecentProjectsDelegate>>,
cx: &mut VisualTestContext,
phase: &str,
) {
picker.update(cx, |picker, _| {
assert_eq!(
picker.is_scrolled_to_end(),
Some(true),
"picker should remain pinned to the bottom ({phase})"
);
assert!(
picker.logical_scroll_top_index() > 0,
"picker should not jump to the top while pinned to the bottom ({phase})"
);
assert_selected_entry_is_recent_project(picker);
});
}
#[track_caller]
fn assert_selected_entry_is_recent_project(picker: &Picker<RecentProjectsDelegate>) {
assert!(matches!(
picker
.delegate
.filtered_entries
.get(picker.delegate.selected_index),
Some(ProjectPickerEntry::RecentProject(_))
));
}
#[gpui::test]
fn deleting_top_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
let target = FIRST_RECENT_PROJECT;
let (picker, cx) = build_picker(cx);
let scroll_top = scroll_to_and_select(&picker, cx, target);
assert!(
scroll_top > 0,
"test should start scrolled away from the top"
);
delete_recent_project_in_picker(&picker, cx, target);
assert_scroll_top_is(&picker, cx, scroll_top, "after delete");
// The picker re-runs layout on the next frame; the scroll position
// must still be preserved after that redraw.
draw(cx);
assert_scroll_top_is(&picker, cx, scroll_top, "after redraw");
}
#[gpui::test]
fn deleting_middle_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
let target = FIRST_RECENT_PROJECT + RECENT_PROJECT_COUNT / 2;
let (picker, cx) = build_picker(cx);
let scroll_top = scroll_to_and_select(&picker, cx, target);
assert!(
scroll_top > 0,
"test should start scrolled away from the top"
);
delete_recent_project_in_picker(&picker, cx, target);
assert_scroll_top_is(&picker, cx, scroll_top, "after delete");
draw(cx);
assert_scroll_top_is(&picker, cx, scroll_top, "after redraw");
}
#[gpui::test]
fn deleting_last_recent_project_preserves_scroll_position(cx: &mut TestAppContext) {
let target = LAST_RECENT_PROJECT;
let (picker, cx) = build_picker(cx);
scroll_to_and_select(&picker, cx, target);
picker.update(cx, |picker, _| {
assert_eq!(
picker.is_scrolled_to_end(),
Some(true),
"selecting the last entry should leave the picker pinned to the bottom"
);
});
delete_recent_project_in_picker(&picker, cx, target);
assert_pinned_to_bottom(&picker, cx, "after delete");
draw(cx);
assert_pinned_to_bottom(&picker, cx, "after redraw");
}
#[gpui::test] #[gpui::test]
async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) { async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
let app_state = init_test(cx); let app_state = init_test(cx);

View file

@ -429,6 +429,7 @@ impl JsonSchema for LanguageModelProviderSetting {
"mistral", "mistral",
"ollama", "ollama",
"openai", "openai",
"opencode",
"openrouter", "openrouter",
"vercel_ai_gateway", "vercel_ai_gateway",
"x_ai", "x_ai",

View file

@ -854,6 +854,30 @@ pub struct ThemeColorsContent {
#[serde(rename = "editor.document_highlight.bracket_background")] #[serde(rename = "editor.document_highlight.bracket_background")]
pub editor_document_highlight_bracket_background: Option<String>, pub editor_document_highlight_bracket_background: Option<String>,
/// Filled background color for added diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.added.background")]
pub editor_diff_hunk_added_background: Option<String>,
/// Hollow background color for added diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.added.hollow_background")]
pub editor_diff_hunk_added_hollow_background: Option<String>,
/// Hollow border color for added diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.added.hollow_border")]
pub editor_diff_hunk_added_hollow_border: Option<String>,
/// Filled background color for deleted diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.deleted.background")]
pub editor_diff_hunk_deleted_background: Option<String>,
/// Hollow background color for deleted diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.deleted.hollow_background")]
pub editor_diff_hunk_deleted_hollow_background: Option<String>,
/// Hollow border color for deleted diff hunk row highlights in the editor.
#[serde(rename = "editor.diff_hunk.deleted.hollow_border")]
pub editor_diff_hunk_deleted_hollow_border: Option<String>,
/// Terminal background color. /// Terminal background color.
#[serde(rename = "terminal.background")] #[serde(rename = "terminal.background")]
pub terminal_background: Option<String>, pub terminal_background: Option<String>,

View file

@ -17,4 +17,5 @@ pub use tool_permissions_setup::{
render_delete_path_tool_config, render_edit_file_tool_config, render_fetch_tool_config, render_delete_path_tool_config, render_edit_file_tool_config, render_fetch_tool_config,
render_move_path_tool_config, render_restore_file_from_disk_tool_config, render_move_path_tool_config, render_restore_file_from_disk_tool_config,
render_save_file_tool_config, render_terminal_tool_config, render_web_search_tool_config, render_save_file_tool_config, render_terminal_tool_config, render_web_search_tool_config,
render_write_file_tool_config,
}; };

View file

@ -32,6 +32,12 @@ const TOOLS: &[ToolInfo] = &[
description: "File editing operations", description: "File editing operations",
regex_explanation: "Patterns are matched against the file path being edited.", regex_explanation: "Patterns are matched against the file path being edited.",
}, },
ToolInfo {
id: "write_file",
name: "Write File",
description: "File creation and overwrite operations",
regex_explanation: "Patterns are matched against the file path being written.",
},
ToolInfo { ToolInfo {
id: "delete_path", id: "delete_path",
name: "Delete Path", name: "Delete Path",
@ -303,6 +309,7 @@ fn get_tool_render_fn(
match tool_id { match tool_id {
"terminal" => render_terminal_tool_config, "terminal" => render_terminal_tool_config,
"edit_file" => render_edit_file_tool_config, "edit_file" => render_edit_file_tool_config,
"write_file" => render_write_file_tool_config,
"delete_path" => render_delete_path_tool_config, "delete_path" => render_delete_path_tool_config,
"copy_path" => render_copy_path_tool_config, "copy_path" => render_copy_path_tool_config,
"move_path" => render_move_path_tool_config, "move_path" => render_move_path_tool_config,
@ -1383,6 +1390,7 @@ macro_rules! tool_config_page_fn {
tool_config_page_fn!(render_terminal_tool_config, "terminal"); tool_config_page_fn!(render_terminal_tool_config, "terminal");
tool_config_page_fn!(render_edit_file_tool_config, "edit_file"); tool_config_page_fn!(render_edit_file_tool_config, "edit_file");
tool_config_page_fn!(render_write_file_tool_config, "write_file");
tool_config_page_fn!(render_delete_path_tool_config, "delete_path"); tool_config_page_fn!(render_delete_path_tool_config, "delete_path");
tool_config_page_fn!(render_copy_path_tool_config, "copy_path"); tool_config_page_fn!(render_copy_path_tool_config, "copy_path");
tool_config_page_fn!(render_move_path_tool_config, "move_path"); tool_config_page_fn!(render_move_path_tool_config, "move_path");

View file

@ -129,6 +129,12 @@ impl ThemeColors {
editor_document_highlight_read_background: neutral().light_alpha().step_3(), editor_document_highlight_read_background: neutral().light_alpha().step_3(),
editor_document_highlight_write_background: neutral().light_alpha().step_4(), editor_document_highlight_write_background: neutral().light_alpha().step_4(),
editor_document_highlight_bracket_background: green().light_alpha().step_5(), editor_document_highlight_bracket_background: green().light_alpha().step_5(),
editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.16),
editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.08),
editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.48),
editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.16),
editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.08),
editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.48),
terminal_background: neutral().light().step_1(), terminal_background: neutral().light().step_1(),
terminal_foreground: black().light().step_12(), terminal_foreground: black().light().step_12(),
terminal_bright_foreground: black().light().step_11(), terminal_bright_foreground: black().light().step_11(),
@ -276,6 +282,12 @@ impl ThemeColors {
editor_document_highlight_read_background: neutral().dark_alpha().step_4(), editor_document_highlight_read_background: neutral().dark_alpha().step_4(),
editor_document_highlight_write_background: neutral().dark_alpha().step_4(), editor_document_highlight_write_background: neutral().dark_alpha().step_4(),
editor_document_highlight_bracket_background: green().dark_alpha().step_6(), editor_document_highlight_bracket_background: green().dark_alpha().step_6(),
editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.12),
editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.06),
editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.36),
editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.12),
editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.06),
editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.36),
terminal_background: neutral().dark().step_1(), terminal_background: neutral().dark().step_1(),
terminal_ansi_background: neutral().dark().step_1(), terminal_ansi_background: neutral().dark().step_1(),
terminal_foreground: white().dark().step_12(), terminal_foreground: white().dark().step_12(),

View file

@ -185,6 +185,12 @@ pub(crate) fn zed_default_dark() -> Theme {
), ),
editor_document_highlight_write_background: gpui::red(), editor_document_highlight_write_background: gpui::red(),
editor_document_highlight_bracket_background: gpui::green(), editor_document_highlight_bracket_background: gpui::green(),
editor_diff_hunk_added_background: ADDED_COLOR.opacity(0.12),
editor_diff_hunk_added_hollow_background: ADDED_COLOR.opacity(0.06),
editor_diff_hunk_added_hollow_border: ADDED_COLOR.opacity(0.36),
editor_diff_hunk_deleted_background: REMOVED_COLOR.opacity(0.12),
editor_diff_hunk_deleted_hollow_background: REMOVED_COLOR.opacity(0.06),
editor_diff_hunk_deleted_hollow_border: REMOVED_COLOR.opacity(0.36),
terminal_background: bg, terminal_background: bg,
// todo("Use one colors for terminal") // todo("Use one colors for terminal")

View file

@ -241,6 +241,18 @@ pub struct ThemeColors {
/// ///
/// Matching brackets in the cursor scope are highlighted with this background color. /// Matching brackets in the cursor scope are highlighted with this background color.
pub editor_document_highlight_bracket_background: Hsla, pub editor_document_highlight_bracket_background: Hsla,
/// Filled background color for added diff hunk row highlights in the editor.
pub editor_diff_hunk_added_background: Hsla,
/// Hollow background color for added diff hunk row highlights in the editor.
pub editor_diff_hunk_added_hollow_background: Hsla,
/// Hollow border color for added diff hunk row highlights in the editor.
pub editor_diff_hunk_added_hollow_border: Hsla,
/// Filled background color for deleted diff hunk row highlights in the editor.
pub editor_diff_hunk_deleted_background: Hsla,
/// Hollow background color for deleted diff hunk row highlights in the editor.
pub editor_diff_hunk_deleted_hollow_background: Hsla,
/// Hollow border color for deleted diff hunk row highlights in the editor.
pub editor_diff_hunk_deleted_hollow_border: Hsla,
// === // ===
// Terminal // Terminal

View file

@ -13,6 +13,13 @@ pub use settings::{FontWeightContent, WindowBackgroundContent};
use theme::{StatusColorsRefinement, ThemeColorsRefinement}; use theme::{StatusColorsRefinement, ThemeColorsRefinement};
const LIGHT_DIFF_HUNK_FILLED_OPACITY: f32 = 0.16;
const LIGHT_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY: f32 = 0.08;
const LIGHT_DIFF_HUNK_HOLLOW_BORDER_OPACITY: f32 = 0.48;
const DARK_DIFF_HUNK_FILLED_OPACITY: f32 = 0.12;
const DARK_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY: f32 = 0.06;
const DARK_DIFF_HUNK_HOLLOW_BORDER_OPACITY: f32 = 0.36;
/// The content of a serialized theme family. /// The content of a serialized theme family.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ThemeFamilyContent { pub struct ThemeFamilyContent {
@ -230,6 +237,7 @@ pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> Statu
pub fn theme_colors_refinement( pub fn theme_colors_refinement(
this: &settings::ThemeColorsContent, this: &settings::ThemeColorsContent,
status_colors: &StatusColorsRefinement, status_colors: &StatusColorsRefinement,
is_light: bool,
) -> ThemeColorsRefinement { ) -> ThemeColorsRefinement {
let border = this let border = this
.border .border
@ -278,6 +286,29 @@ pub fn theme_colors_refinement(
.as_ref() .as_ref()
.and_then(|color| try_parse_color(color).ok()) .and_then(|color| try_parse_color(color).ok())
.or(search_match_background); .or(search_match_background);
let version_control_added = this
.version_control_added
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.created);
let version_control_deleted = this
.version_control_deleted
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.deleted);
let (hunk_fill, hunk_hollow_bg, hunk_hollow_border) = if is_light {
(
LIGHT_DIFF_HUNK_FILLED_OPACITY,
LIGHT_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY,
LIGHT_DIFF_HUNK_HOLLOW_BORDER_OPACITY,
)
} else {
(
DARK_DIFF_HUNK_FILLED_OPACITY,
DARK_DIFF_HUNK_HOLLOW_BACKGROUND_OPACITY,
DARK_DIFF_HUNK_HOLLOW_BORDER_OPACITY,
)
};
ThemeColorsRefinement { ThemeColorsRefinement {
border, border,
border_variant: this border_variant: this
@ -576,6 +607,36 @@ pub fn theme_colors_refinement(
.as_ref() .as_ref()
.and_then(|color| try_parse_color(color).ok()) .and_then(|color| try_parse_color(color).ok())
.or(editor_document_highlight_read_background), .or(editor_document_highlight_read_background),
editor_diff_hunk_added_background: this
.editor_diff_hunk_added_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_added.map(|c| c.opacity(hunk_fill))),
editor_diff_hunk_added_hollow_background: this
.editor_diff_hunk_added_hollow_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_added.map(|c| c.opacity(hunk_hollow_bg))),
editor_diff_hunk_added_hollow_border: this
.editor_diff_hunk_added_hollow_border
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_added.map(|c| c.opacity(hunk_hollow_border))),
editor_diff_hunk_deleted_background: this
.editor_diff_hunk_deleted_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_deleted.map(|c| c.opacity(hunk_fill))),
editor_diff_hunk_deleted_hollow_background: this
.editor_diff_hunk_deleted_hollow_background
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_deleted.map(|c| c.opacity(hunk_hollow_bg))),
editor_diff_hunk_deleted_hollow_border: this
.editor_diff_hunk_deleted_hollow_border
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or_else(|| version_control_deleted.map(|c| c.opacity(hunk_hollow_border))),
terminal_background: this terminal_background: this
.terminal_background .terminal_background
.as_ref() .as_ref()
@ -696,16 +757,8 @@ pub fn theme_colors_refinement(
.link_text_hover .link_text_hover
.as_ref() .as_ref()
.and_then(|color| try_parse_color(color).ok()), .and_then(|color| try_parse_color(color).ok()),
version_control_added: this version_control_added,
.version_control_added version_control_deleted,
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.created),
version_control_deleted: this
.version_control_deleted
.as_ref()
.and_then(|color| try_parse_color(color).ok())
.or(status_colors.deleted),
version_control_modified: this version_control_modified: this
.version_control_modified .version_control_modified
.as_ref() .as_ref()
@ -856,7 +909,165 @@ fn try_parse_color(color: &str) -> anyhow::Result<Hsla> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use theme::StatusColorsRefinement;
use super::{
StatusColorsContent, ThemeColorsContent, status_colors_refinement, theme_colors_refinement,
try_parse_color,
};
#[test]
fn explicit_diff_hunk_colors_take_precedence_over_fallbacks() {
let mut colors = ThemeColorsContent::default();
colors.editor_diff_hunk_added_background = Some("#112233".to_string());
colors.editor_diff_hunk_added_hollow_background = Some("#223344".to_string());
colors.editor_diff_hunk_added_hollow_border = Some("#334455".to_string());
colors.editor_diff_hunk_deleted_background = Some("#445566".to_string());
colors.editor_diff_hunk_deleted_hollow_background = Some("#556677".to_string());
colors.editor_diff_hunk_deleted_hollow_border = Some("#667788".to_string());
colors.version_control_added = Some("#00ff00".to_string());
colors.version_control_deleted = Some("#ff0000".to_string());
let refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
assert_eq!(
refinement.editor_diff_hunk_added_background,
Some(parse_color("#112233"))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_background,
Some(parse_color("#223344"))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_border,
Some(parse_color("#334455"))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_background,
Some(parse_color("#445566"))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_background,
Some(parse_color("#556677"))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_border,
Some(parse_color("#667788"))
);
}
#[test]
fn diff_hunk_colors_fallback_to_version_control_colors() {
let mut colors = ThemeColorsContent::default();
colors.version_control_added = Some("#00ff00".to_string());
colors.version_control_deleted = Some("#ff0000".to_string());
let refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
let added = parse_color("#00ff00");
let deleted = parse_color("#ff0000");
assert_eq!(
refinement.editor_diff_hunk_added_background,
Some(added.opacity(0.16))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_background,
Some(added.opacity(0.08))
);
assert_eq!(
refinement.editor_diff_hunk_added_hollow_border,
Some(added.opacity(0.48))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_background,
Some(deleted.opacity(0.16))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_background,
Some(deleted.opacity(0.08))
);
assert_eq!(
refinement.editor_diff_hunk_deleted_hollow_border,
Some(deleted.opacity(0.48))
);
}
#[test]
fn diff_hunk_opacity_fallbacks_use_correct_values_for_light_and_dark_themes() {
let mut colors = ThemeColorsContent::default();
colors.version_control_added = Some("#00ff00".to_string());
let light_refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
let dark_refinement = theme_colors_refinement(
&colors,
&status_colors_refinement(&StatusColorsContent::default()),
false,
);
let added = parse_color("#00ff00");
assert_eq!(
light_refinement.editor_diff_hunk_added_background,
Some(added.opacity(0.16))
);
assert_eq!(
light_refinement.editor_diff_hunk_added_hollow_background,
Some(added.opacity(0.08))
);
assert_eq!(
light_refinement.editor_diff_hunk_added_hollow_border,
Some(added.opacity(0.48))
);
assert_eq!(
dark_refinement.editor_diff_hunk_added_background,
Some(added.opacity(0.12))
);
assert_eq!(
dark_refinement.editor_diff_hunk_added_hollow_background,
Some(added.opacity(0.06))
);
assert_eq!(
dark_refinement.editor_diff_hunk_added_hollow_border,
Some(added.opacity(0.36))
);
}
#[test]
fn diff_hunk_fallbacks_are_absent_when_status_and_version_control_colors_are_missing() {
let refinement = theme_colors_refinement(
&ThemeColorsContent::default(),
&status_colors_refinement(&StatusColorsContent::default()),
true,
);
assert_eq!(refinement.editor_diff_hunk_added_background, None);
assert_eq!(refinement.editor_diff_hunk_added_hollow_background, None);
assert_eq!(refinement.editor_diff_hunk_added_hollow_border, None);
assert_eq!(refinement.editor_diff_hunk_deleted_background, None);
assert_eq!(refinement.editor_diff_hunk_deleted_hollow_background, None);
assert_eq!(refinement.editor_diff_hunk_deleted_hollow_border, None);
}
fn parse_color(color: &str) -> gpui::Hsla {
match try_parse_color(color) {
Ok(color) => color,
Err(error) => panic!("failed to parse color {color}: {error}"),
}
}
#[test] #[test]
fn helix_jump_label_color_uses_theme_color_or_status_error() { fn helix_jump_label_color_uses_theme_color_or_status_error() {
@ -867,8 +1078,11 @@ mod tests {
..Default::default() ..Default::default()
}; };
let fallback_refinement = let fallback_refinement = theme_colors_refinement(
theme_colors_refinement(&ThemeColorsContent::default(), &status_colors); &ThemeColorsContent::default(),
&status_colors,
Default::default(),
);
assert_eq!( assert_eq!(
fallback_refinement.vim_helix_jump_label_foreground, fallback_refinement.vim_helix_jump_label_foreground,
@ -881,6 +1095,7 @@ mod tests {
..Default::default() ..Default::default()
}, },
&status_colors, &status_colors,
Default::default(),
); );
assert_eq!( assert_eq!(

View file

@ -476,10 +476,12 @@ impl ThemeSettings {
} }
let status_color_refinement = status_colors_refinement(&theme_overrides.status); let status_color_refinement = status_colors_refinement(&theme_overrides.status);
base_theme.styles.colors.refine(&theme_colors_refinement( let theme_color_refinement = theme_colors_refinement(
&theme_overrides.colors, &theme_overrides.colors,
&status_color_refinement, &status_color_refinement,
)); base_theme.appearance.is_light(),
);
base_theme.styles.colors.refine(&theme_color_refinement);
base_theme.styles.status.refine(&status_color_refinement); base_theme.styles.status.refine(&status_color_refinement);
merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players); merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players);
merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents); merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents);

View file

@ -296,8 +296,11 @@ pub fn refine_theme(theme: &ThemeContent) -> Theme {
AppearanceContent::Light => ThemeColors::light(), AppearanceContent::Light => ThemeColors::light(),
AppearanceContent::Dark => ThemeColors::dark(), AppearanceContent::Dark => ThemeColors::dark(),
}; };
let mut theme_colors_refinement = let mut theme_colors_refinement = theme_colors_refinement(
theme_colors_refinement(&theme.style.colors, &status_colors_refinement); &theme.style.colors,
&status_colors_refinement,
theme.appearance == AppearanceContent::Light,
);
theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors); theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors);
refined_theme_colors.refine(&theme_colors_refinement); refined_theme_colors.refine(&theme_colors_refinement);

View file

@ -2452,7 +2452,7 @@ fn find_matching_bracket_text_based(
.find_map(|(ch, char_offset)| get_bracket_pair(ch).map(|info| (info, char_offset))); .find_map(|(ch, char_offset)| get_bracket_pair(ch).map(|info| (info, char_offset)));
if bracket_info.is_none() { if bracket_info.is_none() {
return find_matching_c_preprocessor_directive(map, line_range); return find_matching_c_preprocessor_directive(map, line_range, offset);
} }
let (open, close, is_opening) = bracket_info?.0; let (open, close, is_opening) = bracket_info?.0;
@ -2489,18 +2489,20 @@ fn find_matching_bracket_text_based(
fn find_matching_c_preprocessor_directive( fn find_matching_c_preprocessor_directive(
map: &DisplaySnapshot, map: &DisplaySnapshot,
line_range: Range<MultiBufferOffset>, line_range: Range<MultiBufferOffset>,
offset: MultiBufferOffset,
) -> Option<MultiBufferOffset> { ) -> Option<MultiBufferOffset> {
let line_start = map let line_start = map
.buffer_chars_at(line_range.start) .buffer_chars_at(line_range.start)
.skip_while(|(c, _)| *c == ' ' || *c == '\t') .skip_while(|(c, _)| *c == ' ' || *c == '\t')
.take_while(|(c, char_offset)| *char_offset < line_range.end && !c.is_whitespace())
.map(|(c, _)| c) .map(|(c, _)| c)
.take(6)
.collect::<String>(); .collect::<String>();
if line_start.starts_with("#if") if line_range.start + line_start.len() < offset {
|| line_start.starts_with("#else") return None;
|| line_start.starts_with("#elif") }
{
if line_start.starts_with("#if") || line_start.starts_with("#el") {
let mut depth = 0i32; let mut depth = 0i32;
for (ch, char_offset) in map.buffer_chars_at(line_range.end) { for (ch, char_offset) in map.buffer_chars_at(line_range.end) {
if ch != '\n' { if ch != '\n' {
@ -2618,8 +2620,30 @@ fn matching(
// Ensure the range is contained by the current line. // Ensure the range is contained by the current line.
let mut line_end = map.next_line_boundary(point).0; let mut line_end = map.next_line_boundary(point).0;
if line_end == point { let max_point = map.max_point().to_point(map);
line_end = map.max_point().to_point(map);
// Only widen to EOF when the cursor is actually at EOF.
// This avoids expanding a blank current line into start..EOF.
if line_end == point && point == max_point {
line_end = max_point;
}
let line_range = map.prev_line_boundary(point).0..line_end;
let line_range = line_range.start.to_offset(&map.buffer_snapshot())
..line_range.end.to_offset(&map.buffer_snapshot());
if let Some(preproc_range) = find_matching_c_preprocessor_directive(map, line_range, offset) {
return preproc_range.to_display_point(map);
}
if let Some((open_range, close_range)) = comment_delimiter_pair(map, offset) {
if open_range.contains(&offset) {
return close_range.start.to_display_point(map);
}
if close_range.contains(&offset) {
return open_range.start.to_display_point(map);
}
} }
let is_quote_char = |ch: char| matches!(ch, '\'' | '"' | '`'); let is_quote_char = |ch: char| matches!(ch, '\'' | '"' | '`');
@ -2729,32 +2753,6 @@ fn matching(
continue; continue;
} }
if let Some((open_range, close_range)) = comment_delimiter_pair(map, offset) {
if open_range.contains(&offset) {
return close_range.start.to_display_point(map);
}
if close_range.contains(&offset) {
return open_range.start.to_display_point(map);
}
let open_candidate = (open_range.start >= offset
&& line_range.contains(&open_range.start))
.then_some((open_range.start.saturating_sub(offset), close_range.start));
let close_candidate = (close_range.start >= offset
&& line_range.contains(&close_range.start))
.then_some((close_range.start.saturating_sub(offset), open_range.start));
if let Some((_, destination)) = [open_candidate, close_candidate]
.into_iter()
.flatten()
.min_by_key(|(distance, _)| *distance)
{
return destination.to_display_point(map);
}
}
closest_pair_destination closest_pair_destination
.map(|destination| destination.to_display_point(map)) .map(|destination| destination.to_display_point(map))
.unwrap_or_else(|| { .unwrap_or_else(|| {
@ -3663,6 +3661,10 @@ mod test {
cx.shared_state().await.assert_eq(indoc! {r"/* cx.shared_state().await.assert_eq(indoc! {r"/*
this is a comment this is a comment
ˇ*/"}); ˇ*/"});
cx.simulate_shared_keystrokes("k %").await;
cx.shared_state().await.assert_eq(indoc! {r"/*
ˇ this is a comment
*/"});
cx.set_shared_state("ˇ// comment").await; cx.set_shared_state("ˇ// comment").await;
cx.simulate_shared_keystrokes("%").await; cx.simulate_shared_keystrokes("%").await;
@ -3673,48 +3675,53 @@ mod test {
async fn test_matching_preprocessor_directives(cx: &mut gpui::TestAppContext) { async fn test_matching_preprocessor_directives(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await; let mut cx = NeovimBackedTestContext::new(cx).await;
cx.set_shared_state(indoc! {r"#ˇif cx.set_shared_state(indoc! {r"
#ˇif
#else #else
#endif #endif
"}) "})
.await; .await;
cx.simulate_shared_keystrokes("%").await; cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"#if cx.shared_state().await.assert_eq(indoc! {r"
#if
ˇ#else ˇ#else
#endif #endif
"}); "});
cx.simulate_shared_keystrokes("%").await; cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"#if cx.shared_state().await.assert_eq(indoc! {r"
#if
#else #else
ˇ#endif ˇ#endif
"}); "});
cx.simulate_shared_keystrokes("%").await; cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"ˇ#if cx.shared_state().await.assert_eq(indoc! {r"
ˇ#if
#else #else
#endif #endif
"}); "});
cx.set_shared_state(indoc! {r" cx.set_shared_state(indoc! {r"
#ˇif #ˇif
#if #if
#else
#endif
#else #else
#endif #endif
"})
#else
#endif
"})
.await; .await;
cx.simulate_shared_keystrokes("%").await; cx.simulate_shared_keystrokes("%").await;
@ -3727,8 +3734,9 @@ mod test {
#endif #endif
ˇ#else ˇ#else
#endif #endif
"}); "});
cx.simulate_shared_keystrokes("% %").await; cx.simulate_shared_keystrokes("% %").await;
cx.shared_state().await.assert_eq(indoc! {r" cx.shared_state().await.assert_eq(indoc! {r"
@ -3740,8 +3748,9 @@ mod test {
#endif #endif
#else #else
#endif #endif
"}); "});
cx.simulate_shared_keystrokes("j % % %").await; cx.simulate_shared_keystrokes("j % % %").await;
cx.shared_state().await.assert_eq(indoc! {r" cx.shared_state().await.assert_eq(indoc! {r"
#if #if
@ -3752,8 +3761,28 @@ mod test {
#endif #endif
#else #else
#endif #endif
"}); "});
cx.set_shared_state(indoc! {r"
#if definedˇ(something)
#endif
"})
.await;
cx.simulate_shared_keystrokes("%").await;
cx.shared_state().await.assert_eq(indoc! {r"
#if defined(somethingˇ)
#endif
"});
cx.simulate_shared_keystrokes("0 %").await;
cx.shared_state().await.assert_eq(indoc! {r"
#if defined(something)
ˇ#endif
"});
} }
#[gpui::test] #[gpui::test]

View file

@ -22,7 +22,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
vim.update_editor(cx, |vim, editor, cx| { vim.update_editor(cx, |vim, editor, cx| {
editor.transact(window, cx, |editor, window, cx| { editor.transact(window, cx, |editor, window, cx| {
let mut positions = vim.save_selection_starts(editor, cx); let mut positions = vim.save_selection_starts(editor, cx);
editor.rewrap_impl( editor.rewrap(
RewrapOptions { RewrapOptions {
override_language_settings: true, override_language_settings: true,
line_length: action.line_length, line_length: action.line_length,
@ -74,7 +74,7 @@ impl Vim {
); );
}); });
}); });
editor.rewrap_impl( editor.rewrap(
RewrapOptions { RewrapOptions {
override_language_settings: true, override_language_settings: true,
..Default::default() ..Default::default()
@ -112,7 +112,7 @@ impl Vim {
object.expand_selection(map, selection, around, times); object.expand_selection(map, selection, around, times);
}); });
}); });
editor.rewrap_impl( editor.rewrap(
RewrapOptions { RewrapOptions {
override_language_settings: true, override_language_settings: true,
..Default::default() ..Default::default()

View file

@ -5,6 +5,9 @@
{"Get":{"state":"ˇ/*\n this is a comment\n*/","mode":"Normal"}} {"Get":{"state":"ˇ/*\n this is a comment\n*/","mode":"Normal"}}
{"Key":"%"} {"Key":"%"}
{"Get":{"state":"/*\n this is a comment\nˇ*/","mode":"Normal"}} {"Get":{"state":"/*\n this is a comment\nˇ*/","mode":"Normal"}}
{"Key":"k"}
{"Key":"%"}
{"Get":{"state":"/*\nˇ this is a comment\n*/","mode":"Normal"}}
{"Put":{"state":"ˇ// comment"}} {"Put":{"state":"ˇ// comment"}}
{"Key":"%"} {"Key":"%"}
{"Get":{"state":"ˇ// comment","mode":"Normal"}} {"Get":{"state":"ˇ// comment","mode":"Normal"}}

View file

@ -5,14 +5,20 @@
{"Get":{"state":"#if\n\n#else\n\nˇ#endif\n","mode":"Normal"}} {"Get":{"state":"#if\n\n#else\n\nˇ#endif\n","mode":"Normal"}}
{"Key":"%"} {"Key":"%"}
{"Get":{"state":"ˇ#if\n\n#else\n\n#endif\n","mode":"Normal"}} {"Get":{"state":"ˇ#if\n\n#else\n\n#endif\n","mode":"Normal"}}
{"Put":{"state":"#ˇif\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n"}} {"Put":{"state":"#ˇif\n #if\n\n #else\n\n #endif\n\n#else\n\n#endif\n"}}
{"Key":"%"} {"Key":"%"}
{"Get":{"state":"#if\n #if\n\n #else\n\n #endif\n\nˇ#else\n#endif\n","mode":"Normal"}} {"Get":{"state":"#if\n #if\n\n #else\n\n #endif\n\nˇ#else\n\n#endif\n","mode":"Normal"}}
{"Key":"%"} {"Key":"%"}
{"Key":"%"} {"Key":"%"}
{"Get":{"state":"ˇ#if\n #if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}} {"Get":{"state":"ˇ#if\n #if\n\n #else\n\n #endif\n\n#else\n\n#endif\n","mode":"Normal"}}
{"Key":"j"} {"Key":"j"}
{"Key":"%"} {"Key":"%"}
{"Key":"%"} {"Key":"%"}
{"Key":"%"} {"Key":"%"}
{"Get":{"state":"#if\n ˇ#if\n\n #else\n\n #endif\n\n#else\n#endif\n","mode":"Normal"}} {"Get":{"state":"#if\n ˇ#if\n\n #else\n\n #endif\n\n#else\n\n#endif\n","mode":"Normal"}}
{"Put":{"state":"#if definedˇ(something)\n\n#endif\n"}}
{"Key":"%"}
{"Get":{"state":"#if defined(somethingˇ)\n\n#endif\n","mode":"Normal"}}
{"Key":"0"}
{"Key":"%"}
{"Get":{"state":"#if defined(something)\n\nˇ#endif\n","mode":"Normal"}}

View file

@ -121,7 +121,7 @@ impl Workspace {
let save_action = match save_strategy { let save_action = match save_strategy {
SaveStrategy::All => { SaveStrategy::All => {
let save_all = workspace.update_in(cx, |workspace, window, cx| { let save_all = workspace.update_in(cx, |workspace, window, cx| {
let task = workspace.save_all_internal(SaveIntent::SaveAll, window, cx); let task = workspace.save_all_internal(SaveIntent::SaveAll, true, window, cx);
cx.background_spawn(async { task.await.map(|_| ()) }) cx.background_spawn(async { task.await.map(|_| ()) })
}); });
save_all.ok() save_all.ok()

View file

@ -3305,9 +3305,30 @@ impl Workspace {
} }
} }
// Hot-exit silently writes dirty buffers to the DB; only allow it
// if the workspace will be reachable again, either via session
// restore or by reopening its folder paths. Otherwise prompt, so
// we don't orphan the buffers.
let allow_hot_exit_serialization = close_intent == CloseIntent::Quit
|| save_last_workspace
|| this
.read_with(cx, |workspace, cx| {
workspace
.project
.read(cx)
.visible_worktrees(cx)
.next()
.is_some()
})
.unwrap_or(false);
let save_result = this let save_result = this
.update_in(cx, |this, window, cx| { .update_in(cx, |this, window, cx| {
this.save_all_internal(SaveIntent::Close, window, cx) this.save_all_internal(
SaveIntent::Close,
allow_hot_exit_serialization,
window,
cx,
)
})? })?
.await; .await;
@ -3328,6 +3349,7 @@ impl Workspace {
fn save_all(&mut self, action: &SaveAll, window: &mut Window, cx: &mut Context<Self>) { fn save_all(&mut self, action: &SaveAll, window: &mut Window, cx: &mut Context<Self>) {
self.save_all_internal( self.save_all_internal(
action.save_intent.unwrap_or(SaveIntent::SaveAll), action.save_intent.unwrap_or(SaveIntent::SaveAll),
true,
window, window,
cx, cx,
) )
@ -3425,12 +3447,13 @@ impl Workspace {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<bool>> { ) -> Task<Result<bool>> {
self.save_all_internal(SaveIntent::Close, window, cx) self.save_all_internal(SaveIntent::Close, true, window, cx)
} }
fn save_all_internal( fn save_all_internal(
&mut self, &mut self,
mut save_intent: SaveIntent, mut save_intent: SaveIntent,
allow_hot_exit_serialization: bool,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<bool>> { ) -> Task<Result<bool>> {
@ -3457,23 +3480,27 @@ impl Workspace {
let dirty_items = if save_intent == SaveIntent::Close && !dirty_items.is_empty() { let dirty_items = if save_intent == SaveIntent::Close && !dirty_items.is_empty() {
let mut serialize_tasks = Vec::new(); let mut serialize_tasks = Vec::new();
let mut remaining_dirty_items = Vec::new(); let mut remaining_dirty_items = Vec::new();
workspace.update_in(cx, |workspace, window, cx| { if allow_hot_exit_serialization {
for (pane, item) in dirty_items { workspace.update_in(cx, |workspace, window, cx| {
if let Some(task) = item for (pane, item) in dirty_items {
.to_serializable_item_handle(cx) if let Some(task) = item
.and_then(|handle| handle.serialize(workspace, true, window, cx)) .to_serializable_item_handle(cx)
{ .and_then(|handle| handle.serialize(workspace, true, window, cx))
serialize_tasks.push((pane, item, task)); {
} else { serialize_tasks.push((pane, item, task));
} else {
remaining_dirty_items.push((pane, item));
}
}
})?;
for (pane, item, task) in serialize_tasks {
if task.await.log_err().is_none() {
remaining_dirty_items.push((pane, item)); remaining_dirty_items.push((pane, item));
} }
} }
})?; } else {
remaining_dirty_items = dirty_items;
for (pane, item, task) in serialize_tasks {
if task.await.log_err().is_none() {
remaining_dirty_items.push((pane, item));
}
} }
if !remaining_dirty_items.is_empty() { if !remaining_dirty_items.is_empty() {
@ -11473,7 +11500,7 @@ mod tests {
} }
#[gpui::test] #[gpui::test]
async fn test_close_window_with_serializable_items(cx: &mut TestAppContext) { async fn test_close_window_with_worktrees_hot_exits(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
// Register TestItem as a serializable item // Register TestItem as a serializable item
@ -11510,8 +11537,163 @@ mod tests {
assert!(task.await.unwrap()); assert!(task.await.unwrap());
} }
// See https://github.com/zed-industries/zed/issues/55726.
//
// macOS only: on Linux/Windows, closing the last window sets
// `save_last_workspace`, which preserves the session (same as `Quit`),
// so hot-exit is safe there.
#[cfg(target_os = "macos")]
#[gpui::test] #[gpui::test]
async fn test_close_window_with_failing_serialization(cx: &mut TestAppContext) { async fn test_close_window_without_worktrees_prompts(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
register_serializable_item::<TestItem>(cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let item = cx.new(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_serialize(|| Some(Task::ready(Ok(()))))
});
workspace.update_in(cx, |w, window, cx| {
w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx);
});
let task = workspace.update_in(cx, |w, window, cx| {
w.prepare_to_close(CloseIntent::CloseWindow, window, cx)
});
cx.executor().run_until_parked();
assert!(
cx.has_pending_prompt(),
"closing a no-folder workspace with a dirty serializable item should prompt, \
since the workspace will not be reachable after close"
);
cx.simulate_prompt_answer("Don't Save");
cx.executor().run_until_parked();
assert!(task.await.unwrap());
}
#[gpui::test]
async fn test_quit_without_worktrees_hot_exits(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
register_serializable_item::<TestItem>(cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let item = cx.new(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_serialize(|| Some(Task::ready(Ok(()))))
});
workspace.update_in(cx, |w, window, cx| {
w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx);
});
let task = workspace.update_in(cx, |w, window, cx| {
w.prepare_to_close(CloseIntent::Quit, window, cx)
});
cx.executor().run_until_parked();
assert!(
!cx.has_pending_prompt(),
"quitting should hot-exit silently; the session restore on next \
launch will bring the dirty buffer back"
);
assert!(task.await.unwrap());
}
// See https://github.com/zed-industries/zed/issues/55726.
#[gpui::test]
async fn test_replace_window_without_worktrees_prompts(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
register_serializable_item::<TestItem>(cx);
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let item = cx.new(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_serialize(|| Some(Task::ready(Ok(()))))
});
workspace.update_in(cx, |w, window, cx| {
w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx);
});
let task = workspace.update_in(cx, |w, window, cx| {
w.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
});
cx.executor().run_until_parked();
assert!(
cx.has_pending_prompt(),
"replacing a workspace with a dirty serializable item should prompt, \
since the workspace will be detached afterwards"
);
cx.simulate_prompt_answer("Don't Save");
cx.executor().run_until_parked();
assert!(task.await.unwrap());
}
#[gpui::test]
async fn test_replace_window_with_worktrees_hot_exits(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
register_serializable_item::<TestItem>(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({ "one": "" })).await;
let project = Project::test(fs, ["root".as_ref()], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let item = cx.new(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_serialize(|| Some(Task::ready(Ok(()))))
});
workspace.update_in(cx, |w, window, cx| {
w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx);
});
let task = workspace.update_in(cx, |w, window, cx| {
w.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
});
cx.executor().run_until_parked();
assert!(
!cx.has_pending_prompt(),
"replacing a workspace with folder paths should hot-exit silently; \
the buffer is recoverable by reopening the project"
);
assert!(task.await.unwrap());
}
#[gpui::test]
async fn test_close_window_with_failing_serialize_prompts(cx: &mut TestAppContext) {
init_test(cx); init_test(cx);
cx.update(|cx| { cx.update(|cx| {

View file

@ -2,7 +2,7 @@
description = "The fast, collaborative code editor." description = "The fast, collaborative code editor."
edition.workspace = true edition.workspace = true
name = "zed" name = "zed"
version = "1.2.0" version = "1.3.0"
publish.workspace = true publish.workspace = true
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Zed Team <hi@zed.dev>"] authors = ["Zed Team <hi@zed.dev>"]

View file

@ -4,6 +4,16 @@
mod reliability; mod reliability;
mod zed; mod zed;
// Ensure the binary name stays in sync with APP_NAME so that the paths used
// at runtime (data dir, config dir, etc.) match what the binary is called.
const _: () = assert!(
paths::APP_NAME_LOWERCASE
.as_bytes()
.eq_ignore_ascii_case(env!("CARGO_BIN_NAME").as_bytes()),
"paths::APP_NAME_LOWERCASE must match the binary name. \
Forks: update APP_NAME in crates/paths/src/paths.rs when renaming the binary.",
);
use agent::{SharedThread, ThreadStore}; use agent::{SharedThread, ThreadStore};
use agent_client_protocol::schema as acp; use agent_client_protocol::schema as acp;
use agent_ui::AgentPanel; use agent_ui::AgentPanel;

Some files were not shown because too many files have changed in this diff Show more