mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Merge branch 'main' into fix-worktree-drag-reorder
This commit is contained in:
commit
9a4b56edaa
158 changed files with 8333 additions and 4277 deletions
5
Cargo.lock
generated
5
Cargo.lock
generated
|
|
@ -3571,7 +3571,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel 2.5.0",
|
||||
"async-process",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"collections",
|
||||
|
|
@ -5397,8 +5396,8 @@ dependencies = [
|
|||
name = "edit_prediction_metrics"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"imara-diff",
|
||||
"indoc",
|
||||
"language",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -22395,7 +22394,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
dependencies = [
|
||||
"acp_thread",
|
||||
"acp_tools",
|
||||
|
|
|
|||
|
|
@ -1499,6 +1499,7 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
|
||||
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
|
||||
"ctrl-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1552,6 +1552,7 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-shift-backspace": "branch_picker::DeleteBranch",
|
||||
"cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
|
||||
"cmd-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1479,6 +1479,7 @@
|
|||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
|
||||
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
|
||||
"ctrl-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -527,6 +527,7 @@
|
|||
"space w d": "pane::SplitDown", // not a helix default
|
||||
|
||||
// Space mode
|
||||
"space b": "tab_switcher::ToggleAll",
|
||||
"space f": "file_finder::Toggle",
|
||||
"space k": "editor::Hover",
|
||||
"space s": "outline::Toggle",
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,7 @@
|
|||
"diagnostics": true,
|
||||
"apply_code_action": true,
|
||||
"edit_file": true,
|
||||
"write_file": true,
|
||||
"fetch": true,
|
||||
"find_path": true,
|
||||
"find_references": true,
|
||||
|
|
|
|||
|
|
@ -2294,10 +2294,6 @@ impl AcpThread {
|
|||
this.project
|
||||
.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
|
||||
.running_turn
|
||||
|
|
@ -2306,11 +2302,18 @@ impl AcpThread {
|
|||
|
||||
// If the user submitted a follow up message, running_turn might
|
||||
// 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 {
|
||||
this.running_turn.take();
|
||||
}
|
||||
|
||||
let Ok(response) = response else {
|
||||
// tx dropped, just return
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match response {
|
||||
Ok(r) => {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ pub trait AgentConnection {
|
|||
|
||||
fn telemetry_id(&self) -> SharedString;
|
||||
|
||||
fn agent_version(&self) -> Option<SharedString> {
|
||||
None
|
||||
}
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
|
|
|
|||
|
|
@ -6062,9 +6062,7 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
|
|||
tool.run(
|
||||
ToolInput::resolved(crate::EditFileToolInput {
|
||||
path: "root/sensitive_config.txt".into(),
|
||||
mode: crate::EditFileMode::Edit,
|
||||
content: None,
|
||||
edits: Some(vec![]),
|
||||
edits: vec![],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -6496,9 +6494,7 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
|
|||
tool.run(
|
||||
ToolInput::resolved(crate::EditFileToolInput {
|
||||
path: "root/README.md".into(),
|
||||
mode: crate::EditFileMode::Edit,
|
||||
content: None,
|
||||
edits: Some(vec![]),
|
||||
edits: vec![],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
@ -6568,9 +6564,7 @@ async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut Tes
|
|||
tool.run(
|
||||
ToolInput::resolved(crate::EditFileToolInput {
|
||||
path: "root/.zed/settings.json".into(),
|
||||
mode: crate::EditFileMode::Edit,
|
||||
content: None,
|
||||
edits: Some(vec![]),
|
||||
edits: vec![],
|
||||
}),
|
||||
event_stream,
|
||||
cx,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::{
|
|||
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
|
||||
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool,
|
||||
RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template,
|
||||
Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool,
|
||||
Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool,
|
||||
decide_permission_from_settings,
|
||||
};
|
||||
use acp_thread::{MentionUri, UserMessageId};
|
||||
|
|
@ -822,6 +822,7 @@ impl ToolPermissionContext {
|
|||
} else if tool_name == CopyPathTool::NAME
|
||||
|| tool_name == MovePathTool::NAME
|
||||
|| tool_name == EditFileTool::NAME
|
||||
|| tool_name == WriteFileTool::NAME
|
||||
|| tool_name == DeletePathTool::NAME
|
||||
|| tool_name == CreateDirectoryTool::NAME
|
||||
|| tool_name == SaveFileTool::NAME
|
||||
|
|
@ -1544,6 +1545,12 @@ impl Thread {
|
|||
self.action_log.clone(),
|
||||
));
|
||||
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(),
|
||||
cx.weak_entity(),
|
||||
self.action_log.clone(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mod create_directory_tool;
|
|||
mod delete_path_tool;
|
||||
mod diagnostics_tool;
|
||||
mod edit_file_tool;
|
||||
mod edit_session;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
mod evals;
|
||||
mod fetch_tool;
|
||||
|
|
@ -27,6 +28,7 @@ mod terminal_tool;
|
|||
mod tool_permissions;
|
||||
mod update_plan_tool;
|
||||
mod web_search_tool;
|
||||
mod write_file_tool;
|
||||
|
||||
use crate::AgentTool;
|
||||
use language_model::{LanguageModelRequestTool, LanguageModelToolSchemaFormat};
|
||||
|
|
@ -85,6 +87,7 @@ pub use terminal_tool::*;
|
|||
pub use tool_permissions::*;
|
||||
pub use update_plan_tool::*;
|
||||
pub use web_search_tool::*;
|
||||
pub use write_file_tool::*;
|
||||
|
||||
macro_rules! tools {
|
||||
($($tool:ty),* $(,)?) => {
|
||||
|
|
@ -179,4 +182,5 @@ tools! {
|
|||
TerminalTool,
|
||||
UpdatePlanTool,
|
||||
WebSearchTool,
|
||||
WriteFileTool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ impl AgentTool for CreateDirectoryTool {
|
|||
const NAME: &'static str = "create_directory";
|
||||
|
||||
fn kind() -> acp::ToolKind {
|
||||
acp::ToolKind::Read
|
||||
acp::ToolKind::Edit
|
||||
}
|
||||
|
||||
fn initial_title(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1067
crates/agent/src/tools/edit_session.rs
Normal file
1067
crates/agent/src/tools/edit_session.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{Edit, PartialEdit};
|
||||
use super::{Edit, PartialEdit};
|
||||
|
||||
/// Events emitted by `StreamingParser` for edit-mode input.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
|
|
@ -2,3 +2,5 @@
|
|||
mod edit_file;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
mod terminal_tool;
|
||||
#[cfg(all(test, feature = "unit-eval"))]
|
||||
mod write_file;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
use crate::tools::edit_file_tool::*;
|
||||
use crate::{
|
||||
AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ListDirectoryTool,
|
||||
ListDirectoryToolInput, ReadFileTool, ReadFileToolInput, Template, Templates, Thread,
|
||||
ToolCallEventStream, ToolInput,
|
||||
AgentTool, ContextServerRegistry, EditFileTool, GrepTool, GrepToolInput, ReadFileTool,
|
||||
ReadFileToolInput, Template, Templates, Thread, ToolCallEventStream, ToolInput,
|
||||
};
|
||||
use Role::*;
|
||||
use anyhow::{Context as _, Result};
|
||||
|
|
@ -124,20 +123,6 @@ impl EvalAssertion {
|
|||
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 {
|
||||
let expected_diffs: Vec<String> = expected_diffs.into_iter().map(Into::into).collect();
|
||||
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()),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
|
|
|||
561
crates/agent/src/tools/evals/write_file.rs
Normal file
561
crates/agent/src/tools/evals/write_file.rs
Normal 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(),
|
||||
))
|
||||
});
|
||||
}
|
||||
|
|
@ -184,6 +184,13 @@ impl AgentTool for ReadFileTool {
|
|||
anyhow::Ok(())
|
||||
}).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 {
|
||||
let authorize = cx.update(|cx| {
|
||||
authorize_symlink_access(
|
||||
|
|
@ -356,6 +363,39 @@ mod test {
|
|||
use std::sync::Arc;
|
||||
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]
|
||||
async fn test_read_nonexistent_file(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
|
|||
1190
crates/agent/src/tools/write_file_tool.rs
Normal file
1190
crates/agent/src/tools/write_file_tool.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -413,6 +413,7 @@ fn enqueue_notification<Notif>(
|
|||
pub struct AcpConnection {
|
||||
id: AgentId,
|
||||
telemetry_id: SharedString,
|
||||
agent_version: Option<SharedString>,
|
||||
connection: ConnectionTo<Agent>,
|
||||
sessions: Rc<RefCell<HashMap<acp::SessionId, AcpSession>>>,
|
||||
pending_sessions: Rc<RefCell<HashMap<acp::SessionId, PendingAcpSession>>>,
|
||||
|
|
@ -900,12 +901,15 @@ impl AcpConnection {
|
|||
}
|
||||
});
|
||||
|
||||
let telemetry_id = response
|
||||
.agent_info
|
||||
let agent_info = response.agent_info;
|
||||
let telemetry_id = agent_info
|
||||
.as_ref()
|
||||
// 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
|
||||
.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
|
||||
.agent_capabilities
|
||||
|
|
@ -945,6 +949,7 @@ impl AcpConnection {
|
|||
agent_server_store,
|
||||
connection,
|
||||
telemetry_id,
|
||||
agent_version,
|
||||
sessions,
|
||||
pending_sessions: Rc::new(RefCell::new(HashMap::default())),
|
||||
agent_capabilities: response.agent_capabilities,
|
||||
|
|
@ -978,6 +983,7 @@ impl AcpConnection {
|
|||
Self {
|
||||
id: AgentId::new("test"),
|
||||
telemetry_id: "test".into(),
|
||||
agent_version: None,
|
||||
connection,
|
||||
sessions,
|
||||
pending_sessions: Rc::new(RefCell::new(HashMap::default())),
|
||||
|
|
@ -1319,6 +1325,10 @@ impl AgentConnection for AcpConnection {
|
|||
self.telemetry_id.clone()
|
||||
}
|
||||
|
||||
fn agent_version(&self) -> Option<SharedString> {
|
||||
self.agent_version.clone()
|
||||
}
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
|
|
@ -1984,6 +1994,10 @@ pub mod test_support {
|
|||
self.inner.telemetry_id()
|
||||
}
|
||||
|
||||
fn agent_version(&self) -> Option<SharedString> {
|
||||
self.inner.agent_version()
|
||||
}
|
||||
|
||||
fn new_session(
|
||||
self: Rc<Self>,
|
||||
project: Entity<Project>,
|
||||
|
|
|
|||
|
|
@ -1135,10 +1135,13 @@ impl AgentConfiguration {
|
|||
id: agent_server_name.clone(),
|
||||
};
|
||||
|
||||
let connection_status = self
|
||||
.agent_connection_store
|
||||
.read(cx)
|
||||
.connection_status(&agent, cx);
|
||||
let (connection_status, running_version) = {
|
||||
let connection_store = self.agent_connection_store.read(cx);
|
||||
(
|
||||
connection_store.connection_status(&agent, cx),
|
||||
connection_store.agent_version(&agent, cx),
|
||||
)
|
||||
};
|
||||
|
||||
let restart_button = matches!(
|
||||
connection_status,
|
||||
|
|
@ -1252,6 +1255,7 @@ impl AgentConfiguration {
|
|||
|
||||
AiSettingItem::new(id, display_name, status, source_kind)
|
||||
.icon(icon)
|
||||
.when_some(running_version, |this, version| this.detail_label(version))
|
||||
.when_some(restart_button, |this, button| this.action(button))
|
||||
.when_some(uninstall_button, |this, button| this.action(button))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ impl AgentConnectionStore {
|
|||
.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> {
|
||||
self.entries
|
||||
.values()
|
||||
|
|
|
|||
|
|
@ -677,7 +677,7 @@ impl MessageEditor {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id"
|
|||
|
||||
CREATE TABLE "contacts" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id_a" INTEGER REFERENCES users (id) NOT NULL,
|
||||
"user_id_b" INTEGER REFERENCES users (id) NOT NULL,
|
||||
"user_id_a" INTEGER NOT NULL,
|
||||
"user_id_b" INTEGER NOT NULL,
|
||||
"a_to_b" BOOLEAN NOT NULL,
|
||||
"should_notify" 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" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"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_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
|
||||
"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" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"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_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
|
||||
"answering_connection_lost" BOOLEAN NOT NULL,
|
||||
"location_kind" INTEGER,
|
||||
"location_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_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL,
|
||||
"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" (
|
||||
"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,
|
||||
"connection_id" INTEGER NOT NULL,
|
||||
"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" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"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,
|
||||
"accepted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT now
|
||||
|
|
@ -332,7 +332,7 @@ CREATE TABLE "channel_buffer_collaborators" (
|
|||
"connection_id" INTEGER NOT NULL,
|
||||
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
|
||||
"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
|
||||
);
|
||||
|
||||
|
|
@ -351,7 +351,7 @@ CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection
|
|||
);
|
||||
|
||||
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,
|
||||
"epoch" 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" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"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),
|
||||
"entity_id" INTEGER,
|
||||
"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 TABLE contributors (
|
||||
user_id INTEGER REFERENCES users (id),
|
||||
user_id INTEGER,
|
||||
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
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" (
|
||||
"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,
|
||||
"data" BLOB NOT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
|
|||
|
|
@ -684,6 +684,26 @@ impl Database {
|
|||
.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.
|
||||
pub async fn get_channel_participant_details(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ impl From<Model> for crate::entities::User {
|
|||
crate::entities::User {
|
||||
id: user.id,
|
||||
github_login: user.github_login,
|
||||
github_user_id: user.github_user_id,
|
||||
name: user.name,
|
||||
admin: user.admin,
|
||||
connected_once: user.connected_once,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ use crate::db::UserId;
|
|||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub github_login: String,
|
||||
pub github_user_id: i32,
|
||||
pub name: Option<String>,
|
||||
pub admin: bool,
|
||||
pub connected_once: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ pub mod env;
|
|||
pub mod executor;
|
||||
pub mod rpc;
|
||||
pub mod seed;
|
||||
pub mod services;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use aws_config::{BehaviorVersion, Region};
|
||||
|
|
@ -19,6 +20,8 @@ use serde::Deserialize;
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::services::{DatabaseUserService, UserService};
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
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 executor: Executor,
|
||||
pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
|
||||
pub user_service: Arc<dyn UserService>,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +263,7 @@ impl AppState {
|
|||
} else {
|
||||
None
|
||||
},
|
||||
user_service: Arc::new(DatabaseUserService::new(db)),
|
||||
config,
|
||||
};
|
||||
Ok(Arc::new(this))
|
||||
|
|
|
|||
|
|
@ -2541,10 +2541,11 @@ async fn get_users(
|
|||
.map(UserId::from_proto)
|
||||
.collect();
|
||||
let users = session
|
||||
.db()
|
||||
.await
|
||||
.app_state
|
||||
.user_service
|
||||
.get_users_by_ids(user_ids)
|
||||
.await?
|
||||
.await?;
|
||||
let users = users
|
||||
.into_iter()
|
||||
.map(|user| proto::User {
|
||||
id: user.id.to_proto(),
|
||||
|
|
@ -2567,13 +2568,19 @@ async fn fuzzy_search_users(
|
|||
let users = match query.len() {
|
||||
0 => vec![],
|
||||
1 | 2 => session
|
||||
.db()
|
||||
.await
|
||||
.app_state
|
||||
.user_service
|
||||
.get_user_by_github_login(&query)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => session.db().await.fuzzy_search_users(&query, 10).await?,
|
||||
_ => {
|
||||
session
|
||||
.app_state
|
||||
.user_service
|
||||
.fuzzy_search_users(&query, 10)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let users = users
|
||||
.into_iter()
|
||||
|
|
@ -3163,13 +3170,11 @@ async fn get_channel_members(
|
|||
|
||||
let channel = db.get_channel(channel_id, session.user_id()).await?;
|
||||
|
||||
let (members, users) = db
|
||||
.get_channel_participant_details(&channel, &request.query, limit)
|
||||
let (members, users) = session
|
||||
.app_state
|
||||
.user_service
|
||||
.search_channel_members(&channel, &request.query, limit as u32)
|
||||
.await?;
|
||||
let members = members
|
||||
.into_iter()
|
||||
.map(proto::ChannelMember::from)
|
||||
.collect();
|
||||
let users = users.into_iter().map(proto::User::from).collect();
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
crates/collab/src/services.rs
Normal file
3
crates/collab/src/services.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod user_service;
|
||||
|
||||
pub use user_service::*;
|
||||
243
crates/collab/src/services/user_service.rs
Normal file
243
crates/collab/src/services/user_service.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
let user_b = server
|
||||
.app_state
|
||||
.db
|
||||
.user_service
|
||||
.get_user_by_github_login("user_b")
|
||||
.await
|
||||
.unwrap()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ use client::{
|
|||
proto::PeerId,
|
||||
};
|
||||
use clock::FakeSystemClock;
|
||||
use collab::services::{FakeUserService, NewUserParams};
|
||||
use collab::{
|
||||
AppState, Config,
|
||||
db::{NewUserParams, UserId},
|
||||
db::UserId,
|
||||
executor::Executor,
|
||||
rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion},
|
||||
};
|
||||
|
|
@ -179,14 +180,19 @@ impl TestServer {
|
|||
|
||||
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
|
||||
} else {
|
||||
let github_user_id = self.next_github_user_id;
|
||||
self.next_github_user_id += 1;
|
||||
self.app_state
|
||||
.db
|
||||
.user_service
|
||||
.as_fake()
|
||||
.create_user(
|
||||
&format!("{name}@example.com"),
|
||||
None,
|
||||
|
|
@ -197,8 +203,6 @@ impl TestServer {
|
|||
},
|
||||
)
|
||||
.await
|
||||
.expect("creating user failed")
|
||||
.user_id
|
||||
};
|
||||
|
||||
let http = FakeHttpClient::create({
|
||||
|
|
@ -244,7 +248,7 @@ impl TestServer {
|
|||
let client_name = name.to_string();
|
||||
let client = cx.update(|cx| Client::new(clock, http.clone(), cx));
|
||||
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 forbid_connections = self.forbid_connections.clone();
|
||||
|
||||
|
|
@ -268,7 +272,7 @@ impl TestServer {
|
|||
);
|
||||
|
||||
let server = server.clone();
|
||||
let db = db.clone();
|
||||
let user_service = user_service.clone();
|
||||
let connection_killers = connection_killers.clone();
|
||||
let forbid_connections = forbid_connections.clone();
|
||||
let client_name = client_name.clone();
|
||||
|
|
@ -281,7 +285,8 @@ impl TestServer {
|
|||
let (client_conn, server_conn, killed) =
|
||||
Connection::in_memory(cx.background_executor().clone());
|
||||
let (connection_id_tx, connection_id_rx) = oneshot::channel();
|
||||
let user = db
|
||||
let user = user_service
|
||||
.as_fake()
|
||||
.get_user_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
|
|
@ -294,7 +299,7 @@ impl TestServer {
|
|||
cx.background_spawn(server.handle_connection(
|
||||
server_conn,
|
||||
client_name,
|
||||
Principal::User(user.into()),
|
||||
Principal::User(user),
|
||||
ZedVersion(semver::Version::new(1, 0, 0)),
|
||||
Some("test".to_string()),
|
||||
None,
|
||||
|
|
@ -576,6 +581,7 @@ impl TestServer {
|
|||
blob_store_client: None,
|
||||
executor,
|
||||
kinesis_client: None,
|
||||
user_service: FakeUserService::new(test_db.db().clone()),
|
||||
config: Config {
|
||||
http_port: 0,
|
||||
database_url: "".into(),
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ test-support = ["gpui/test-support"]
|
|||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-channel.workspace = true
|
||||
async-process.workspace = true
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
collections.workspace = true
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ struct Notification<'a, T> {
|
|||
jsonrpc: &'static str,
|
||||
#[serde(borrow)]
|
||||
method: &'a str,
|
||||
#[serde(skip_serializing_if = "is_null_value")]
|
||||
params: T,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use async_process::Child;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use futures::io::{BufReader, BufWriter};
|
||||
use futures::{
|
||||
AsyncBufReadExt as _, AsyncRead, AsyncWrite, AsyncWriteExt as _, Stream, StreamExt as _,
|
||||
};
|
||||
use gpui::AsyncApp;
|
||||
|
||||
use util::TryFutureExt as _;
|
||||
use util::process::Child;
|
||||
use util::shell::Shell;
|
||||
use util::shell_builder::ShellBuilder;
|
||||
|
||||
|
|
@ -31,22 +32,20 @@ impl StdioTransport {
|
|||
) -> Result<Self> {
|
||||
let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive();
|
||||
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
|
||||
.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);
|
||||
command.envs(binary.env.unwrap_or_default());
|
||||
|
||||
if let Some(working_directory) = working_directory {
|
||||
command.current_dir(working_directory);
|
||||
}
|
||||
|
||||
let mut server = command
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn command {command:?})",))?;
|
||||
let mut server = Child::spawn(
|
||||
command,
|
||||
std::process::Stdio::piped(),
|
||||
std::process::Stdio::piped(),
|
||||
std::process::Stdio::piped(),
|
||||
)?;
|
||||
|
||||
let stdin = server.stdin.take().unwrap();
|
||||
let stdout = server.stdout.take().unwrap();
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ use zeta_prompt::{ParsedOutput, ZetaPromptInput};
|
|||
|
||||
use std::{env, ops::Range, path::Path, sync::Arc};
|
||||
use zeta_prompt::{
|
||||
ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output,
|
||||
prompt_input_contains_special_tokens, stop_tokens_for_format,
|
||||
ZetaFormat, format_zeta_prompt, get_prefill, parse_zeta2_model_output, stop_tokens_for_format,
|
||||
zeta1::{self, EDITABLE_REGION_END_MARKER},
|
||||
};
|
||||
|
||||
|
|
@ -120,10 +119,6 @@ pub fn request_prediction_with_zeta(
|
|||
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);
|
||||
|
||||
if let Some(debug_tx) = &debug_tx {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ workspace = true
|
|||
path = "src/edit_prediction_metrics.rs"
|
||||
|
||||
[dependencies]
|
||||
language.workspace = true
|
||||
imara-diff.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = "1.0"
|
||||
similar = "2.7.0"
|
||||
|
|
|
|||
|
|
@ -218,7 +218,9 @@ pub fn score_prediction(input: PredictionScoringInput<'_>) -> PredictionScore {
|
|||
|
||||
for expected in input.expected_patches {
|
||||
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_expected_cursor = expected.cursor_editable_region_offset;
|
||||
best_expected_text = Some(expected.text.as_str());
|
||||
|
|
@ -317,3 +319,33 @@ fn compute_cursor_metrics(
|
|||
(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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,155 @@
|
|||
use std::iter;
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
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;
|
||||
|
||||
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 {
|
||||
let hunks = parse_diff_hunks(diff_str);
|
||||
let mut result = text.to_string();
|
||||
|
|
@ -651,7 +796,7 @@ pub fn compute_prediction_reversal_ratio_from_history(
|
|||
mod tests {
|
||||
use super::*;
|
||||
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};
|
||||
|
||||
fn compute_prediction_reversal_ratio(
|
||||
|
|
@ -1008,8 +1153,8 @@ mod tests {
|
|||
last line
|
||||
"};
|
||||
|
||||
// unified_diff doesn't include file headers, but apply_diff_to_string needs them
|
||||
let diff_body = language::unified_diff(original, modified);
|
||||
// unified_diff_with_context doesn't include file headers, but apply_diff_to_string needs them
|
||||
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 reversed_diff = reverse_diff(&forward_diff);
|
||||
|
||||
|
|
|
|||
352
crates/editor/src/config.rs
Normal file
352
crates/editor/src/config.rs
Normal 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
|
|
@ -8173,7 +8173,7 @@ async fn test_rewrap(cx: &mut TestAppContext) {
|
|||
) {
|
||||
cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -8604,7 +8604,7 @@ async fn test_rewrap_line_comment_in_go(cx: &mut TestAppContext) {
|
|||
cx.set_state(indoc! {"
|
||||
// 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! {"
|
||||
// Lorem ipsum dolor sit amet,
|
||||
// consectetur adipiscing elit.ˇ
|
||||
|
|
@ -8632,7 +8632,7 @@ async fn test_rewrap_line_comment_in_c(cx: &mut TestAppContext) {
|
|||
cx.set_state(indoc! {"
|
||||
// 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! {"
|
||||
// Lorem ipsum dolor sit amet,
|
||||
// consectetur adipiscing elit.ˇ
|
||||
|
|
|
|||
|
|
@ -570,7 +570,9 @@ impl EditorElement {
|
|||
register_action(editor, window, Editor::move_line_up);
|
||||
register_action(editor, window, Editor::move_line_down);
|
||||
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::kill_ring_cut);
|
||||
register_action(editor, window, Editor::kill_ring_yank);
|
||||
|
|
@ -10081,8 +10083,6 @@ impl Element for EditorElement {
|
|||
.editor
|
||||
.update(cx, |editor, cx| editor.highlighted_display_rows(window, cx));
|
||||
|
||||
let is_light = cx.theme().appearance().is_light();
|
||||
|
||||
let mut highlighted_ranges = self
|
||||
.editor_with_selections(cx)
|
||||
.map(|editor| {
|
||||
|
|
@ -10122,42 +10122,49 @@ impl Element for EditorElement {
|
|||
})
|
||||
.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() {
|
||||
let Some(diff_status) = row_info.diff_status else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let background_color = match diff_status.kind {
|
||||
DiffHunkStatusKind::Added => cx.theme().colors().version_control_added,
|
||||
DiffHunkStatusKind::Deleted => {
|
||||
cx.theme().colors().version_control_deleted
|
||||
}
|
||||
let diff_hunk_colors = match diff_status.kind {
|
||||
DiffHunkStatusKind::Added => &added_diff_hunk_colors,
|
||||
DiffHunkStatusKind::Deleted => &deleted_diff_hunk_colors,
|
||||
DiffHunkStatusKind::Modified => {
|
||||
debug_panic!("modified diff status for row info");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let hunk_opacity = if is_light { 0.16 } else { 0.12 };
|
||||
|
||||
let hollow_highlight = LineHighlight {
|
||||
background: (background_color.opacity(if is_light {
|
||||
0.08
|
||||
} else {
|
||||
0.06
|
||||
}))
|
||||
.into(),
|
||||
border: Some(if is_light {
|
||||
background_color.opacity(0.48)
|
||||
} else {
|
||||
background_color.opacity(0.36)
|
||||
}),
|
||||
background: diff_hunk_colors.hollow_background.into(),
|
||||
border: Some(diff_hunk_colors.hollow_border),
|
||||
include_gutter: true,
|
||||
type_id: None,
|
||||
};
|
||||
|
||||
let filled_highlight = LineHighlight {
|
||||
background: solid_background(background_color.opacity(hunk_opacity)),
|
||||
background: solid_background(diff_hunk_colors.filled_background),
|
||||
border: None,
|
||||
include_gutter: true,
|
||||
type_id: None,
|
||||
|
|
@ -10182,11 +10189,9 @@ impl Element for EditorElement {
|
|||
let range = drag_state.row_range(&snapshot.display_snapshot);
|
||||
let start_row = range.start().0;
|
||||
let end_row = range.end().0;
|
||||
let drag_highlight_color =
|
||||
cx.theme().colors().editor_active_line_background;
|
||||
let drag_highlight = LineHighlight {
|
||||
background: solid_background(drag_highlight_color),
|
||||
border: Some(cx.theme().colors().border_focused),
|
||||
border: Some(drag_border_color),
|
||||
include_gutter: true,
|
||||
type_id: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use settings::Settings;
|
|||
use std::{ops::Range, sync::LazyLock};
|
||||
use text::OffsetRangeExt;
|
||||
use theme::ActiveTheme as _;
|
||||
use util::{ResultExt, TryFutureExt as _, maybe};
|
||||
use util::{ResultExt, TryFutureExt as _, maybe, paths::PathWithPosition};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HoveredLinkState {
|
||||
|
|
@ -63,7 +63,7 @@ impl RangeInEditor {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum HoverLink {
|
||||
Url(String),
|
||||
File(ResolvedPath),
|
||||
File(ResolvedFileTarget),
|
||||
Text(LocationLink),
|
||||
InlayHint(lsp::Location, LanguageServerId),
|
||||
}
|
||||
|
|
@ -376,7 +376,7 @@ pub fn show_link_definition(
|
|||
(range, vec![HoverLink::Url(url)])
|
||||
})
|
||||
.ok()
|
||||
} else if let Some((filename_range, filename)) =
|
||||
} else if let Some((filename_range, file_target)) =
|
||||
find_file(&buffer, project.clone(), anchor, cx).await
|
||||
{
|
||||
let range = maybe!({
|
||||
|
|
@ -385,7 +385,7 @@ pub fn show_link_definition(
|
|||
Some(RangeInEditor::Text(range))
|
||||
});
|
||||
|
||||
Some((range, vec![HoverLink::File(filename)]))
|
||||
Some((range, vec![HoverLink::File(file_target)]))
|
||||
} else if let Some(provider) = provider {
|
||||
let task = cx.update(|_, cx| {
|
||||
provider.definitions(&buffer, anchor, preferred_kind, cx)
|
||||
|
|
@ -608,12 +608,49 @@ pub(crate) fn find_url_from_range(
|
|||
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(
|
||||
buffer: &Entity<language::Buffer>,
|
||||
project: Option<Entity<Project>>,
|
||||
position: text::Anchor,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) -> Option<(Range<text::Anchor>, ResolvedPath)> {
|
||||
) -> Option<(Range<text::Anchor>, ResolvedFileTarget)> {
|
||||
let project = project?;
|
||||
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
|
||||
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);
|
||||
|
||||
// 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 {
|
||||
// Try the raw candidate first.
|
||||
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((
|
||||
snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end),
|
||||
existing_path,
|
||||
make_range(pattern_range),
|
||||
ResolvedFileTarget {
|
||||
resolved_path: existing_path,
|
||||
row: None,
|
||||
column: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(scope) = scope {
|
||||
for (pattern_candidate, pattern_range) in pattern_candidates {
|
||||
|
||||
// Parse row:col suffix once per candidate for use in fallback attempts.
|
||||
// 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() {
|
||||
if pattern_candidate.ends_with(format!(".{suffix}").as_str()) {
|
||||
continue;
|
||||
|
|
@ -658,15 +729,39 @@ pub(crate) async fn find_file(
|
|||
if let Some(existing_path) =
|
||||
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((
|
||||
snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end),
|
||||
existing_path,
|
||||
make_range(pattern_range),
|
||||
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
|
||||
|
|
@ -721,7 +816,7 @@ fn surrounding_filename(
|
|||
found_start = true;
|
||||
break;
|
||||
}
|
||||
if (ch == '"' || ch == '\'') && !inside_quotes {
|
||||
if (ch == '"' || ch == '\'' || ch == '`') && !inside_quotes {
|
||||
found_start = true;
|
||||
inside_quotes = true;
|
||||
break;
|
||||
|
|
@ -754,7 +849,7 @@ fn surrounding_filename(
|
|||
found_end = true;
|
||||
break;
|
||||
}
|
||||
if ch == '"' || ch == '\'' {
|
||||
if ch == '"' || ch == '\'' || ch == '`' {
|
||||
// If we're inside quotes, we stop when we come across the next quote
|
||||
if inside_quotes {
|
||||
found_end = true;
|
||||
|
|
@ -1576,6 +1671,16 @@ mod tests {
|
|||
(" ˇ\"常\"", 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 {
|
||||
|
|
@ -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]
|
||||
async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
|
|
|||
|
|
@ -101,6 +101,10 @@ impl FollowableItem for Editor {
|
|||
.await
|
||||
.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 multibuffer = cx.new(|cx| {
|
||||
let mut multibuffer;
|
||||
|
|
@ -108,27 +112,13 @@ impl FollowableItem for Editor {
|
|||
multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx)
|
||||
} else {
|
||||
multibuffer = MultiBuffer::new(project.read(cx).capability());
|
||||
for path_with_ranges in state.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;
|
||||
};
|
||||
for (path_key, buffer_id, ranges) in path_excerpts {
|
||||
let Some(buffer) =
|
||||
buffers.iter().find(|b| b.read(cx).remote_id() == buffer_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
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(
|
||||
path_key,
|
||||
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)))
|
||||
.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.
|
||||
let buffer_snapshot = this.update(cx, |editor, cx| {
|
||||
editor.buffer.update(cx, |multibuffer, cx| {
|
||||
for path_with_excerpts in message.updated_paths {
|
||||
let Some(path_key) = path_with_excerpts.path_key.and_then(deserialize_path_key)
|
||||
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 {
|
||||
for (path_key, buffer_id, ranges) in updated_paths {
|
||||
let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else {
|
||||
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(
|
||||
excerpt_range: proto::ExcerptRange,
|
||||
) -> Option<ExcerptRange<language::Anchor>> {
|
||||
|
|
|
|||
782
crates/editor/src/rewrap.rs
Normal file
782
crates/editor/src/rewrap.rs
Normal 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(¤t_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}')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ pub fn init(cx: &mut App) -> Arc<AgentCliAppState> {
|
|||
git_binary_path,
|
||||
cx.background_executor().clone(),
|
||||
));
|
||||
<dyn fs::Fs>::set_global(fs.clone(), cx);
|
||||
|
||||
let mut languages = LanguageRegistry::new(cx.background_executor().clone());
|
||||
languages.set_language_server_download_dir(paths::languages_dir().clone());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// to download this.
|
||||
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"))
|
||||
{
|
||||
Some("wasi-sdk-25.0-x86_64-macos.tar.gz")
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
Some("wasi-sdk-25.0-arm64-macos.tar.gz")
|
||||
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
|
||||
Some("wasi-sdk-25.0-x86_64-linux.tar.gz")
|
||||
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
||||
Some("wasi-sdk-25.0-arm64-linux.tar.gz")
|
||||
} 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
|
||||
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"),
|
||||
all(target_os = "macos", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-macos.tar.gz"),
|
||||
all(target_os = "linux", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"),
|
||||
all(target_os = "linux", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"),
|
||||
all(target_os = "freebsd", target_arch = "x86_64") => 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"),
|
||||
all(target_os = "windows", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-windows.tar.gz"),
|
||||
_ => None
|
||||
};
|
||||
|
||||
pub struct ExtensionBuilder {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use extension::{
|
|||
ExtensionGrammarProxy, ExtensionHostProxy, ExtensionLanguageProxy,
|
||||
ExtensionLanguageServerProxy, ExtensionSnippetProxy, ExtensionThemeProxy,
|
||||
};
|
||||
use fs::{Fs, RemoveOptions};
|
||||
use fs::{Fs, RemoveOptions, RenameOptions};
|
||||
use futures::future::join_all;
|
||||
use futures::{
|
||||
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
|
||||
/// 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.
|
||||
pub fn schema_version_range() -> RangeInclusive<SchemaVersion> {
|
||||
|
|
@ -726,41 +726,67 @@ impl ExtensionStore {
|
|||
}
|
||||
});
|
||||
|
||||
let mut response = http_client
|
||||
.get(url.as_ref(), Default::default(), true)
|
||||
.await
|
||||
.context("downloading extension")?;
|
||||
cx.background_spawn(async move {
|
||||
let mut response = http_client
|
||||
.get(url.as_ref(), Default::default(), true)
|
||||
.await
|
||||
.context("downloading extension")?;
|
||||
|
||||
fs.remove_dir(
|
||||
&extension_dir,
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
ignore_if_not_exists: true,
|
||||
},
|
||||
)
|
||||
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!(
|
||||
"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?;
|
||||
|
||||
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))?
|
||||
.await;
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ pub struct FakeGitRepositoryState {
|
|||
pub simulated_index_write_error_message: Option<String>,
|
||||
pub simulated_create_worktree_error: Option<String>,
|
||||
pub simulated_graph_error: Option<String>,
|
||||
pub branches_requiring_force_delete: HashSet<String>,
|
||||
pub refs: HashMap<String, String>,
|
||||
pub graph_commits: Vec<Arc<InitialGraphCommitData>>,
|
||||
pub commit_data: HashMap<Oid, FakeCommitDataEntry>,
|
||||
|
|
@ -91,6 +92,7 @@ impl FakeGitRepositoryState {
|
|||
simulated_index_write_error_message: Default::default(),
|
||||
simulated_create_worktree_error: Default::default(),
|
||||
simulated_graph_error: None,
|
||||
branches_requiring_force_delete: Default::default(),
|
||||
refs: HashMap::from_iter([("HEAD".into(), "abc".into())]),
|
||||
merge_base_contents: 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| {
|
||||
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) {
|
||||
bail!("no such branch: {name}");
|
||||
}
|
||||
state.branches_requiring_force_delete.remove(&name);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use notify::EventKind;
|
||||
use notify::{Event, EventKind};
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
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>) {
|
||||
if matches!(
|
||||
event,
|
||||
Ok(Event {
|
||||
kind: EventKind::Access(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
log::trace!("global handle event for {mode:?}: {event:?}");
|
||||
|
||||
let callbacks = {
|
||||
|
|
@ -472,9 +482,6 @@ fn handle_event(mode: WatcherMode, event: Result<notify::Event, notify::Error>)
|
|||
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if matches!(event.kind, EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
for callback in callbacks {
|
||||
callback(Ok(&event));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ mod matcher;
|
|||
mod paths;
|
||||
mod strings;
|
||||
|
||||
use fuzzy::CharBag;
|
||||
use nucleo::pattern::{AtomKind, CaseMatching, Normalization, Pattern};
|
||||
|
||||
pub use paths::{
|
||||
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
|
||||
/// that is already sorted ascending and deduplicated.
|
||||
pub(crate) fn positions_from_sorted(s: &str, sorted_char_indices: &[u32]) -> Vec<usize> {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ use std::{
|
|||
use util::{paths::PathStyle, rel_path::RelPath};
|
||||
|
||||
use nucleo::Utf32Str;
|
||||
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
|
||||
use nucleo::pattern::Pattern;
|
||||
|
||||
use fuzzy::CharBag;
|
||||
|
||||
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)]
|
||||
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 {
|
||||
let mut path_components = path.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
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_filename_match_bonus(
|
||||
candidate_buf: &str,
|
||||
query_atoms: &[Atom],
|
||||
pattern: &Pattern,
|
||||
matcher: &mut nucleo::Matcher,
|
||||
) -> f64 {
|
||||
let filename = match std::path::Path::new(candidate_buf).file_name() {
|
||||
Some(f) => f.to_str().unwrap_or(""),
|
||||
None => return 0.0,
|
||||
};
|
||||
if filename.is_empty() || query_atoms.is_empty() {
|
||||
let Some(filename) = std::path::Path::new(candidate_buf)
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.filter(|f| !f.is_empty())
|
||||
else {
|
||||
return 0.0;
|
||||
}
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
let haystack = Utf32Str::new(filename, &mut buf);
|
||||
let mut total_score = 0u32;
|
||||
for atom in query_atoms {
|
||||
if let Some(score) = atom.score(haystack, matcher) {
|
||||
total_score = total_score.saturating_add(score as u32);
|
||||
}
|
||||
}
|
||||
total_score as f64 / filename.len().max(1) as f64
|
||||
let score: u32 = pattern
|
||||
.atoms
|
||||
.iter()
|
||||
.filter_map(|atom| atom.score(haystack, matcher))
|
||||
.map(|s| s as u32)
|
||||
.sum();
|
||||
|
||||
score as f64 / filename.len().max(1) as f64
|
||||
}
|
||||
|
||||
fn path_match_helper<'a>(
|
||||
matcher: &mut nucleo::Matcher,
|
||||
atoms: &[Atom],
|
||||
source_words: Option<&[Vec<char>]>,
|
||||
query_bag: CharBag,
|
||||
query: &Query,
|
||||
candidates: impl Iterator<Item = PathMatchCandidate<'a>>,
|
||||
results: &mut Vec<PathMatch>,
|
||||
worktree_id: usize,
|
||||
|
|
@ -197,7 +156,6 @@ fn path_match_helper<'a>(
|
|||
let path_prefix_len = candidate_buf.len();
|
||||
let mut buf = 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();
|
||||
for candidate in candidates {
|
||||
buf.clear();
|
||||
|
|
@ -206,7 +164,7 @@ fn path_match_helper<'a>(
|
|||
return Err(Cancelled);
|
||||
}
|
||||
|
||||
if !candidate.char_bag.is_superset(query_bag) {
|
||||
if !candidate.char_bag.is_superset(query.char_bag) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -219,70 +177,45 @@ fn path_match_helper<'a>(
|
|||
|
||||
let haystack = Utf32Str::new(&candidate_buf, &mut buf);
|
||||
|
||||
if source_words.is_some() {
|
||||
candidate_chars.clear();
|
||||
candidate_chars.extend(candidate_buf.chars());
|
||||
}
|
||||
let Some(score) = query.pattern.indices(haystack, matcher, &mut matched_chars) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut total_score: u32 = 0;
|
||||
let mut case_mismatches: u32 = 0;
|
||||
let mut all_matched = true;
|
||||
let case_mismatches = count_case_mismatches(
|
||||
query.query_chars.as_deref(),
|
||||
&matched_chars,
|
||||
&candidate_buf,
|
||||
&mut candidate_chars,
|
||||
);
|
||||
|
||||
for (atom_idx, atom) in atoms.iter().enumerate() {
|
||||
atom_matched_chars.clear();
|
||||
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);
|
||||
}
|
||||
matched_chars.sort_unstable();
|
||||
matched_chars.dedup();
|
||||
|
||||
if all_matched && !atoms.is_empty() {
|
||||
matched_chars.sort_unstable();
|
||||
matched_chars.dedup();
|
||||
let length_penalty = candidate_buf.len() as f64 * LENGTH_PENALTY;
|
||||
let filename_bonus = get_filename_match_bonus(&candidate_buf, &query.pattern, matcher);
|
||||
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;
|
||||
let filename_bonus = get_filename_match_bonus(&candidate_buf, atoms, matcher);
|
||||
let positive = (total_score as f64 + filename_bonus) * case_penalty(case_mismatches);
|
||||
let adjusted_score = positive - length_penalty;
|
||||
let positions = positions_from_sorted(&candidate_buf, &matched_chars);
|
||||
|
||||
results.push(PathMatch {
|
||||
score: adjusted_score,
|
||||
positions,
|
||||
worktree_id,
|
||||
path: if root_is_file {
|
||||
Arc::clone(path_prefix)
|
||||
} else {
|
||||
candidate.path.into()
|
||||
},
|
||||
path_prefix: if root_is_file {
|
||||
RelPath::empty().into()
|
||||
} 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())
|
||||
}),
|
||||
});
|
||||
}
|
||||
results.push(PathMatch {
|
||||
score: adjusted_score,
|
||||
positions,
|
||||
worktree_id,
|
||||
path: if root_is_file {
|
||||
Arc::clone(path_prefix)
|
||||
} else {
|
||||
candidate.path.into()
|
||||
},
|
||||
path_prefix: if root_is_file {
|
||||
RelPath::empty().into()
|
||||
} 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(())
|
||||
}
|
||||
|
|
@ -296,14 +229,14 @@ pub fn match_fixed_path_set(
|
|||
max_results: usize,
|
||||
path_style: PathStyle,
|
||||
) -> Vec<PathMatch> {
|
||||
let Some(query) = Query::build(query, case) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut config = nucleo::Config::DEFAULT;
|
||||
config.set_match_paths();
|
||||
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 path_prefix = worktree_root_name.unwrap_or_else(|| RelPath::empty().into());
|
||||
|
|
@ -312,9 +245,7 @@ pub fn match_fixed_path_set(
|
|||
|
||||
path_match_helper(
|
||||
&mut matcher,
|
||||
&atoms,
|
||||
source_words.as_deref(),
|
||||
query_bag,
|
||||
&query,
|
||||
candidates.into_iter(),
|
||||
&mut results,
|
||||
worktree_id,
|
||||
|
|
@ -352,9 +283,9 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
|
|||
query.to_owned()
|
||||
};
|
||||
|
||||
let atoms = make_atoms(&query);
|
||||
let source_words = make_source_words(&query, case);
|
||||
let query_bag = CharBag::from(query.as_str());
|
||||
let Some(query) = Query::build(&query, case) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let num_cpus = executor.num_cpus().min(path_count);
|
||||
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())
|
||||
.enumerate()
|
||||
{
|
||||
let atoms = atoms.clone();
|
||||
let source_words = source_words.clone();
|
||||
let query = &query;
|
||||
let relative_to = relative_to.clone();
|
||||
scope.spawn(async move {
|
||||
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(
|
||||
matcher,
|
||||
&atoms,
|
||||
source_words.as_deref(),
|
||||
query_bag,
|
||||
query,
|
||||
candidates,
|
||||
results,
|
||||
candidate_set.id(),
|
||||
|
|
|
|||
|
|
@ -8,61 +8,14 @@ use std::{
|
|||
|
||||
use gpui::{BackgroundExecutor, SharedString};
|
||||
use nucleo::Utf32Str;
|
||||
use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
|
||||
|
||||
use crate::{
|
||||
Cancelled, Case, LengthPenalty,
|
||||
Cancelled, Case, LengthPenalty, Query, case_penalty, count_case_mismatches,
|
||||
matcher::{self, LENGTH_PENALTY},
|
||||
positions_from_sorted,
|
||||
};
|
||||
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)]
|
||||
pub struct StringMatchCandidate {
|
||||
pub id: usize,
|
||||
|
|
@ -281,7 +234,6 @@ where
|
|||
{
|
||||
let mut buf = 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();
|
||||
|
||||
for candidate in candidates {
|
||||
|
|
@ -297,69 +249,37 @@ where
|
|||
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() {
|
||||
candidate_chars.clear();
|
||||
candidate_chars.extend(borrowed.string.chars());
|
||||
}
|
||||
let Some(score) = query.pattern.indices(haystack, matcher, &mut matched_chars) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut total_score: u32 = 0;
|
||||
let mut case_mismatches: u32 = 0;
|
||||
let mut all_matched = true;
|
||||
let case_mismatches = count_case_mismatches(
|
||||
query.query_chars.as_deref(),
|
||||
&matched_chars,
|
||||
borrowed.string.as_ref(),
|
||||
&mut candidate_chars,
|
||||
);
|
||||
|
||||
for (atom_idx, atom) in query.atoms.iter().enumerate() {
|
||||
atom_matched_chars.clear();
|
||||
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);
|
||||
}
|
||||
matched_chars.sort_unstable();
|
||||
matched_chars.dedup();
|
||||
|
||||
if all_matched {
|
||||
matched_chars.sort_unstable();
|
||||
matched_chars.dedup();
|
||||
let positive = score as f64 * case_penalty(case_mismatches);
|
||||
let adjusted_score =
|
||||
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);
|
||||
let adjusted_score =
|
||||
positive - length_penalty_for(borrowed.string.as_ref(), length_penalty);
|
||||
let positions = positions_from_sorted(borrowed.string.as_ref(), &matched_chars);
|
||||
|
||||
results.push(StringMatch {
|
||||
candidate_id: borrowed.id,
|
||||
score: adjusted_score,
|
||||
positions,
|
||||
string: borrowed.string.clone(),
|
||||
});
|
||||
}
|
||||
results.push(StringMatch {
|
||||
candidate_id: borrowed.id,
|
||||
score: adjusted_score,
|
||||
positions,
|
||||
string: borrowed.string.clone(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn case_penalty(mismatches: u32) -> f64 {
|
||||
if mismatches == 0 {
|
||||
1.0
|
||||
} else {
|
||||
SMART_CASE_PENALTY_PER_MISMATCH.powi(mismatches as i32)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn length_penalty_for(s: &str, length_penalty: LengthPenalty) -> f64 {
|
||||
if length_penalty.is_on() {
|
||||
|
|
|
|||
|
|
@ -721,6 +721,15 @@ pub struct SearchCommitArgs {
|
|||
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 {
|
||||
fn reload_index(&self);
|
||||
|
||||
|
|
@ -775,7 +784,12 @@ pub trait GitRepository: Send + Sync {
|
|||
-> 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>>>;
|
||||
|
||||
|
|
@ -2033,14 +2047,18 @@ impl GitRepository for RealGitRepository {
|
|||
.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();
|
||||
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
git_binary?
|
||||
.run(&["branch", if is_remote { "-dr" } else { "-d" }, &name])
|
||||
.await?;
|
||||
let flag = delete_branch_flag(is_remote, force);
|
||||
git_binary?.run(&["branch", flag, &name]).await?;
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.boxed()
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ use editor::Editor;
|
|||
use fuzzy_nucleo::StringMatchCandidate;
|
||||
|
||||
use collections::HashSet;
|
||||
use git::repository::Branch;
|
||||
use git::repository::{Branch, delete_branch_flag};
|
||||
use gpui::http_client::Url;
|
||||
use gpui::{
|
||||
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
|
||||
SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
|
||||
InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
|
||||
Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate, PickerEditorPosition};
|
||||
use project::git_store::{Repository, RepositoryEvent};
|
||||
|
|
@ -29,6 +29,8 @@ actions!(
|
|||
[
|
||||
/// Deletes the selected git branch or remote.
|
||||
DeleteBranch,
|
||||
/// Force deletes the selected git branch or remote.
|
||||
ForceDeleteBranch,
|
||||
/// Filter the list of remotes
|
||||
FilterRemotes
|
||||
]
|
||||
|
|
@ -254,8 +256,10 @@ impl BranchList {
|
|||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.picker
|
||||
.update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
|
||||
self.picker.update(cx, |picker, cx| {
|
||||
picker.delegate.modifiers = ev.modifiers;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_delete(
|
||||
|
|
@ -267,7 +271,20 @@ impl BranchList {
|
|||
self.picker.update(cx, |picker, cx| {
|
||||
picker
|
||||
.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)
|
||||
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
|
||||
.on_action(cx.listener(Self::handle_delete))
|
||||
.on_action(cx.listener(Self::handle_force_delete))
|
||||
.on_action(cx.listener(Self::handle_filter))
|
||||
.child(self.picker.clone())
|
||||
.when(!self.embedded, |this| {
|
||||
|
|
@ -393,6 +411,7 @@ pub struct BranchListDelegate {
|
|||
focus_handle: FocusHandle,
|
||||
restore_selected_branch: Option<SharedString>,
|
||||
show_footer: bool,
|
||||
hovered_delete_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -407,6 +426,77 @@ enum PickerState {
|
|||
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> {
|
||||
let remote_upstreams: HashSet<_> = branches
|
||||
.iter()
|
||||
|
|
@ -460,9 +550,14 @@ impl BranchListDelegate {
|
|||
focus_handle: cx.focus_handle(),
|
||||
restore_selected_branch: None,
|
||||
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(
|
||||
&self,
|
||||
from_branch: Option<SharedString>,
|
||||
|
|
@ -509,7 +604,13 @@ impl BranchListDelegate {
|
|||
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 {
|
||||
return;
|
||||
};
|
||||
|
|
@ -520,49 +621,75 @@ impl BranchListDelegate {
|
|||
let workspace = self.workspace.clone();
|
||||
|
||||
cx.spawn_in(window, async move |picker, cx| {
|
||||
let is_remote;
|
||||
let result = match &entry {
|
||||
Entry::Branch { branch, .. } => {
|
||||
if branch.is_head {
|
||||
return Ok(());
|
||||
let Entry::Branch { branch, .. } = &entry else {
|
||||
log::error!("Failed to delete entry: wrong entry to delete");
|
||||
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();
|
||||
repo.update(cx, |repo, _| {
|
||||
repo.delete_branch(is_remote, branch.name().to_string())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
_ => {
|
||||
log::error!("Failed to delete entry: wrong entry to delete");
|
||||
return Ok(());
|
||||
if force || !is_unmerged_branch_delete_error(&error) {
|
||||
(Err(error), force)
|
||||
} else {
|
||||
let answer = cx.update(|window, cx| {
|
||||
window.prompt(
|
||||
PromptLevel::Warning,
|
||||
&format!(
|
||||
"Branch \"{}\" is not fully merged. Force delete it?",
|
||||
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 is_remote {
|
||||
log::error!("Failed to delete remote branch: {}", e);
|
||||
} else {
|
||||
log::error!("Failed to delete branch: {}", e);
|
||||
}
|
||||
|
||||
if let Err(error) = result {
|
||||
if let Some(workspace) = workspace.upgrade() {
|
||||
cx.update(|_window, cx| {
|
||||
if is_remote {
|
||||
show_error_toast(
|
||||
workspace,
|
||||
format!("branch -dr {}", entry.name()),
|
||||
e,
|
||||
cx,
|
||||
)
|
||||
} else {
|
||||
show_error_toast(
|
||||
workspace,
|
||||
format!("branch -d {}", entry.name()),
|
||||
e,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
show_error_toast(
|
||||
workspace,
|
||||
delete_branch_command(is_remote, entry.name(), attempted_force),
|
||||
error,
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
|
|
@ -585,6 +712,8 @@ impl BranchListDelegate {
|
|||
picker.delegate.selected_index = picker.delegate.matches.len() - 1;
|
||||
}
|
||||
|
||||
picker.delegate.hovered_delete_index = None;
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
|
|
@ -980,6 +1109,7 @@ impl PickerDelegate for BranchListDelegate {
|
|||
};
|
||||
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
let picker = cx.entity();
|
||||
let is_new_items = matches!(
|
||||
entry,
|
||||
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 deleted_branch_icon = |entry_ix: usize| {
|
||||
IconButton::new(("delete", entry_ix), IconName::Trash)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(move |_, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Delete Branch",
|
||||
&branch_picker::DeleteBranch,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.delegate.delete_at(entry_ix, window, cx);
|
||||
let picker = picker.clone();
|
||||
let focus_handle = focus_handle.clone();
|
||||
let force_delete = self.is_force_delete_hovering_index(entry_ix);
|
||||
|
||||
div()
|
||||
.id(("delete-hover", entry_ix))
|
||||
.on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
|
||||
if *hovered {
|
||||
this.delegate.hovered_delete_index = Some(entry_ix);
|
||||
} else if this.delegate.hovered_delete_index == Some(entry_ix) {
|
||||
this.delegate.hovered_delete_index = None;
|
||||
}
|
||||
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| {
|
||||
|
|
@ -1480,9 +1635,9 @@ mod tests {
|
|||
(branch_list, cx)
|
||||
}
|
||||
|
||||
async fn init_fake_repository(
|
||||
async fn init_fake_repository_with_fs(
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Project>, Entity<Repository>) {
|
||||
) -> (Arc<FakeFs>, Entity<Project>, Entity<Repository>) {
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/dir"),
|
||||
|
|
@ -1505,7 +1660,14 @@ mod tests {
|
|||
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
|
||||
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]
|
||||
|
|
@ -1597,7 +1759,7 @@ mod tests {
|
|||
branch_list.picker.update(cx, |picker, cx| {
|
||||
assert_eq!(picker.delegate.matches.len(), 4);
|
||||
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
|
||||
})
|
||||
});
|
||||
|
|
@ -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]
|
||||
async fn test_delete_remote_branch(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
@ -1683,7 +2077,7 @@ mod tests {
|
|||
branch_list.picker.update(cx, |picker, cx| {
|
||||
assert_eq!(picker.delegate.matches.len(), 4);
|
||||
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
|
||||
})
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2244,7 +2244,7 @@ impl GitPanel {
|
|||
let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
|
||||
let wrapped_message = editor.update(cx, |editor, cx| {
|
||||
editor.select_all(&Default::default(), window, cx);
|
||||
editor.rewrap_impl(
|
||||
editor.rewrap(
|
||||
RewrapOptions {
|
||||
override_language_settings: false,
|
||||
preserve_existing_whitespace: true,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use ui::{
|
|||
};
|
||||
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};
|
||||
|
||||
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(
|
||||
&mut self,
|
||||
_: &FilterRemotes,
|
||||
|
|
@ -407,6 +420,7 @@ impl Render for GitPicker {
|
|||
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
|
||||
.when(self.tab == GitPickerTab::Branches, |el| {
|
||||
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))
|
||||
})
|
||||
.when(self.tab == GitPickerTab::Stash, |el| {
|
||||
|
|
|
|||
|
|
@ -71,12 +71,27 @@ struct StateInner {
|
|||
scroll_handler: Option<Box<dyn FnMut(&ListScrollEvent, &mut Window, &mut App)>>,
|
||||
scrollbar_drag_start_height: Option<Pixels>,
|
||||
measuring_behavior: ListMeasuringBehavior,
|
||||
pending_scroll: Option<PendingScrollFraction>,
|
||||
pending_scroll: Option<PendingScroll>,
|
||||
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
|
||||
/// after remeasurement.
|
||||
#[derive(Clone)]
|
||||
struct PendingScrollFraction {
|
||||
/// The index of the item to scroll within.
|
||||
item_ix: usize,
|
||||
|
|
@ -84,6 +99,15 @@ struct PendingScrollFraction {
|
|||
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.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum FollowMode {
|
||||
|
|
@ -271,6 +295,7 @@ struct ListItemSummary {
|
|||
unrendered_count: usize,
|
||||
height: Pixels,
|
||||
has_focus_handles: bool,
|
||||
has_unknown_height: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||
|
|
@ -335,7 +360,7 @@ impl ListState {
|
|||
/// but the number and identity of items remains the same.
|
||||
pub fn remeasure(&self) {
|
||||
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
|
||||
|
|
@ -346,31 +371,47 @@ impl ListState {
|
|||
/// height may be different (e.g., streaming text, tool results
|
||||
/// loading), but the item itself still exists at the same index.
|
||||
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();
|
||||
|
||||
// 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 range.contains(&scroll_top.item_ix) {
|
||||
let mut cursor = state.items.cursor::<Count>(());
|
||||
cursor.seek(&Count(scroll_top.item_ix), Bias::Right);
|
||||
state.pending_scroll = match scroll_anchor {
|
||||
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() {
|
||||
if let Some(size) = item.size() {
|
||||
let fraction = if size.height.0 > 0.0 {
|
||||
(scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
cursor
|
||||
.item()
|
||||
.and_then(|item| {
|
||||
item.size().map(|size| {
|
||||
let fraction = if size.height.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 {
|
||||
item_ix: scroll_top.item_ix,
|
||||
fraction,
|
||||
});
|
||||
PendingScroll::Proportional(PendingScrollFraction {
|
||||
item_ix: scroll_top.item_ix,
|
||||
fraction,
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| state.pending_scroll.clone())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +440,25 @@ impl ListState {
|
|||
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
|
||||
/// by `count` new items that must be recalculated.
|
||||
pub fn splice(&self, old_range: Range<usize>, count: usize) {
|
||||
|
|
@ -874,14 +934,26 @@ impl StateInner {
|
|||
size = Some(element_size);
|
||||
|
||||
// If there's a pending scroll adjustment for the scroll-top
|
||||
// item, apply it, ensuring proportional scroll position is
|
||||
// maintained after re-measuring.
|
||||
// item, apply it.
|
||||
if ix == 0 {
|
||||
if let Some(pending_scroll) = self.pending_scroll.take() {
|
||||
if pending_scroll.item_ix == scroll_top.item_ix {
|
||||
scroll_top.offset_in_item =
|
||||
Pixels(pending_scroll.fraction * element_size.height.0);
|
||||
self.logical_scroll_top = Some(scroll_top);
|
||||
match pending_scroll {
|
||||
PendingScroll::Absolute { item_ix, offset }
|
||||
if item_ix == scroll_top.item_ix =>
|
||||
{
|
||||
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.)
|
||||
},
|
||||
has_focus_handles: focus_handle.is_some(),
|
||||
has_unknown_height: size_hint.is_none(),
|
||||
},
|
||||
ListItem::Measured {
|
||||
size, focus_handle, ..
|
||||
|
|
@ -1394,6 +1467,7 @@ impl sum_tree::Item for ListItem {
|
|||
unrendered_count: 0,
|
||||
height: size.height,
|
||||
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.height += summary.height;
|
||||
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.));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) {
|
||||
let cx = cx.add_empty_window();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use crate::{
|
|||
AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity,
|
||||
GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement,
|
||||
IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size,
|
||||
StyleRefinement, Styled, Window, point, size,
|
||||
StyleRefinement, Styled, Window, point, px, size,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
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.
|
||||
pub fn scroll_to_bottom(&self) {
|
||||
self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom);
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@ where
|
|||
.unwrap();
|
||||
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();
|
||||
let result = func(&mut 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)]
|
||||
|
||||
async fn test_language_loading(cx: &mut TestAppContext) {
|
||||
|
|
|
|||
|
|
@ -1021,7 +1021,7 @@ mod tests {
|
|||
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!(matches!(
|
||||
|
|
|
|||
|
|
@ -381,11 +381,14 @@ impl OllamaLanguageModel {
|
|||
}
|
||||
}
|
||||
Role::Assistant => {
|
||||
let content = msg.string_contents();
|
||||
let mut text_content = String::new();
|
||||
let mut thinking = None;
|
||||
let mut tool_calls = Vec::new();
|
||||
for content in msg.content.into_iter() {
|
||||
match content {
|
||||
MessageContent::Text(text) => {
|
||||
text_content.push_str(&text);
|
||||
}
|
||||
MessageContent::Thinking { text, .. } if !text.is_empty() => {
|
||||
thinking = Some(text)
|
||||
}
|
||||
|
|
@ -402,7 +405,7 @@ impl OllamaLanguageModel {
|
|||
}
|
||||
}
|
||||
messages.push(ChatMessage::Assistant {
|
||||
content,
|
||||
content: text_content,
|
||||
tool_calls: Some(tool_calls),
|
||||
images: if images.is_empty() {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -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 semver::Version;
|
||||
use std::{path::PathBuf, vec};
|
||||
use task::{TaskTemplate, TaskTemplates, VariableName};
|
||||
use util::{ResultExt, maybe};
|
||||
|
||||
pub(super) fn bash_task_context() -> ContextProviderWithTasks {
|
||||
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)]
|
||||
mod tests {
|
||||
use gpui::{AppContext as _, BorrowAppContext, Context, TestAppContext};
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ pub fn init(languages: Arc<LanguageRegistry>, fs: Arc<dyn Fs>, node: NodeRuntime
|
|||
#[cfg(feature = "load-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 css_lsp_adapter = Arc::new(css::CssLspAdapter::new(node.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 {
|
||||
name: "bash",
|
||||
context: Some(Arc::new(bash::bash_task_context())),
|
||||
adapters: vec![bash_lsp_adapter],
|
||||
..Default::default()
|
||||
},
|
||||
LanguageInfo {
|
||||
|
|
|
|||
|
|
@ -58,23 +58,19 @@ pub enum Model {
|
|||
|
||||
#[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")]
|
||||
MagistralMediumLatest,
|
||||
#[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")]
|
||||
MagistralSmallLatest,
|
||||
|
||||
#[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")]
|
||||
OpenMistralNemo,
|
||||
#[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")]
|
||||
OpenCodestralMamba,
|
||||
|
||||
#[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")]
|
||||
DevstralMediumLatest,
|
||||
#[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")]
|
||||
DevstralSmallLatest,
|
||||
|
||||
#[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")]
|
||||
Pixtral12BLatest,
|
||||
#[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")]
|
||||
PixtralLargeLatest,
|
||||
#[serde(rename = "ministral-3b-latest", alias = "ministral-3b-latest")]
|
||||
Ministral3bLatest,
|
||||
#[serde(rename = "ministral-8b-latest", alias = "ministral-8b-latest")]
|
||||
Ministral8bLatest,
|
||||
#[serde(rename = "ministral-14b-latest", alias = "ministral-14b-latest")]
|
||||
Ministral14bLatest,
|
||||
|
||||
#[serde(rename = "custom")]
|
||||
Custom {
|
||||
|
|
@ -102,13 +98,8 @@ impl Model {
|
|||
"mistral-medium-latest" => Ok(Self::MistralMediumLatest),
|
||||
"mistral-small-latest" => Ok(Self::MistralSmallLatest),
|
||||
"magistral-medium-latest" => Ok(Self::MagistralMediumLatest),
|
||||
"magistral-small-latest" => Ok(Self::MagistralSmallLatest),
|
||||
"open-mistral-nemo" => Ok(Self::OpenMistralNemo),
|
||||
"open-codestral-mamba" => Ok(Self::OpenCodestralMamba),
|
||||
"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}'"),
|
||||
}
|
||||
}
|
||||
|
|
@ -120,13 +111,11 @@ impl Model {
|
|||
Self::MistralMediumLatest => "mistral-medium-latest",
|
||||
Self::MistralSmallLatest => "mistral-small-latest",
|
||||
Self::MagistralMediumLatest => "magistral-medium-latest",
|
||||
Self::MagistralSmallLatest => "magistral-small-latest",
|
||||
Self::OpenMistralNemo => "open-mistral-nemo",
|
||||
Self::OpenCodestralMamba => "open-codestral-mamba",
|
||||
Self::DevstralMediumLatest => "devstral-medium-latest",
|
||||
Self::DevstralSmallLatest => "devstral-small-latest",
|
||||
Self::Pixtral12BLatest => "pixtral-12b-latest",
|
||||
Self::PixtralLargeLatest => "pixtral-large-latest",
|
||||
Self::Ministral3bLatest => "ministral-3b-latest",
|
||||
Self::Ministral8bLatest => "ministral-8b-latest",
|
||||
Self::Ministral14bLatest => "ministral-14b-latest",
|
||||
Self::Custom { name, .. } => name,
|
||||
}
|
||||
}
|
||||
|
|
@ -138,13 +127,11 @@ impl Model {
|
|||
Self::MistralMediumLatest => "mistral-medium-latest",
|
||||
Self::MistralSmallLatest => "mistral-small-latest",
|
||||
Self::MagistralMediumLatest => "magistral-medium-latest",
|
||||
Self::MagistralSmallLatest => "magistral-small-latest",
|
||||
Self::OpenMistralNemo => "open-mistral-nemo",
|
||||
Self::OpenCodestralMamba => "open-codestral-mamba",
|
||||
Self::DevstralMediumLatest => "devstral-medium-latest",
|
||||
Self::DevstralSmallLatest => "devstral-small-latest",
|
||||
Self::Pixtral12BLatest => "pixtral-12b-latest",
|
||||
Self::PixtralLargeLatest => "pixtral-large-latest",
|
||||
Self::Ministral3bLatest => "ministral-3b-latest",
|
||||
Self::Ministral8bLatest => "ministral-8b-latest",
|
||||
Self::Ministral14bLatest => "ministral-14b-latest",
|
||||
Self::Custom {
|
||||
name, display_name, ..
|
||||
} => display_name.as_ref().unwrap_or(name),
|
||||
|
|
@ -153,18 +140,16 @@ impl Model {
|
|||
|
||||
pub fn max_token_count(&self) -> u64 {
|
||||
match self {
|
||||
Self::CodestralLatest => 256000,
|
||||
Self::CodestralLatest => 128000,
|
||||
Self::MistralLargeLatest => 256000,
|
||||
Self::MistralMediumLatest => 128000,
|
||||
Self::MistralSmallLatest => 32000,
|
||||
Self::MistralSmallLatest => 256000,
|
||||
Self::MagistralMediumLatest => 128000,
|
||||
Self::MagistralSmallLatest => 128000,
|
||||
Self::OpenMistralNemo => 131000,
|
||||
Self::OpenCodestralMamba => 256000,
|
||||
Self::OpenMistralNemo => 128000,
|
||||
Self::DevstralMediumLatest => 256000,
|
||||
Self::DevstralSmallLatest => 256000,
|
||||
Self::Pixtral12BLatest => 128000,
|
||||
Self::PixtralLargeLatest => 128000,
|
||||
Self::Ministral3bLatest => 256000,
|
||||
Self::Ministral8bLatest => 256000,
|
||||
Self::Ministral14bLatest => 256000,
|
||||
Self::Custom { max_tokens, .. } => *max_tokens,
|
||||
}
|
||||
}
|
||||
|
|
@ -185,31 +170,25 @@ impl Model {
|
|||
| Self::MistralMediumLatest
|
||||
| Self::MistralSmallLatest
|
||||
| Self::MagistralMediumLatest
|
||||
| Self::MagistralSmallLatest
|
||||
| Self::OpenMistralNemo
|
||||
| Self::OpenCodestralMamba
|
||||
| Self::DevstralMediumLatest
|
||||
| Self::DevstralSmallLatest
|
||||
| Self::Pixtral12BLatest
|
||||
| Self::PixtralLargeLatest => true,
|
||||
| Self::Ministral3bLatest
|
||||
| Self::Ministral8bLatest
|
||||
| Self::Ministral14bLatest => true,
|
||||
Self::Custom { supports_tools, .. } => supports_tools.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_images(&self) -> bool {
|
||||
match self {
|
||||
Self::Pixtral12BLatest
|
||||
| Self::PixtralLargeLatest
|
||||
Self::MistralLargeLatest
|
||||
| Self::MistralMediumLatest
|
||||
| Self::MistralSmallLatest => true,
|
||||
Self::CodestralLatest
|
||||
| Self::MistralLargeLatest
|
||||
| Self::MistralSmallLatest
|
||||
| Self::MagistralMediumLatest
|
||||
| Self::MagistralSmallLatest
|
||||
| Self::OpenMistralNemo
|
||||
| Self::OpenCodestralMamba
|
||||
| Self::DevstralMediumLatest
|
||||
| Self::DevstralSmallLatest => false,
|
||||
| Self::Ministral3bLatest
|
||||
| Self::Ministral8bLatest
|
||||
| Self::Ministral14bLatest => true,
|
||||
Self::CodestralLatest | Self::OpenMistralNemo | Self::DevstralMediumLatest => false,
|
||||
Self::Custom {
|
||||
supports_images, ..
|
||||
} => supports_images.unwrap_or(false),
|
||||
|
|
@ -218,7 +197,7 @@ impl Model {
|
|||
|
||||
pub fn supports_thinking(&self) -> bool {
|
||||
match self {
|
||||
Self::MagistralMediumLatest | Self::MagistralSmallLatest => true,
|
||||
Self::MagistralMediumLatest => true,
|
||||
Self::Custom {
|
||||
supports_thinking, ..
|
||||
} => supports_thinking.unwrap_or(false),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,41 @@ use util::rel_path::RelPath;
|
|||
/// A default editorconfig file name to use when resolving project settings.
|
||||
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`.
|
||||
/// This is used to override the default data directory location.
|
||||
/// 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") {
|
||||
dirs::config_dir()
|
||||
.expect("failed to determine RoamingAppData directory")
|
||||
.join("Zed")
|
||||
.join(APP_NAME)
|
||||
} else if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
if let Ok(flatpak_xdg_config) = std::env::var("FLATPAK_XDG_CONFIG_HOME") {
|
||||
flatpak_xdg_config.into()
|
||||
} else {
|
||||
dirs::config_dir().expect("failed to determine XDG_CONFIG_HOME directory")
|
||||
}
|
||||
.join("zed")
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} 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() {
|
||||
custom_dir.clone()
|
||||
} 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")) {
|
||||
if let Ok(flatpak_xdg_data) = std::env::var("FLATPAK_XDG_DATA_HOME") {
|
||||
flatpak_xdg_data.into()
|
||||
} else {
|
||||
dirs::data_local_dir().expect("failed to determine XDG_DATA_HOME directory")
|
||||
}
|
||||
.join("zed")
|
||||
.join(APP_NAME_LOWERCASE)
|
||||
} else if cfg!(target_os = "windows") {
|
||||
dirs::data_local_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join("Zed")
|
||||
.join(APP_NAME)
|
||||
} else {
|
||||
config_dir().clone() // Fallback
|
||||
}
|
||||
|
|
@ -133,7 +170,7 @@ pub fn state_dir() -> &'static PathBuf {
|
|||
static STATE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
STATE_DIR.get_or_init(|| {
|
||||
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")) {
|
||||
|
|
@ -142,12 +179,12 @@ pub fn state_dir() -> &'static PathBuf {
|
|||
} else {
|
||||
dirs::state_dir().expect("failed to determine XDG_STATE_HOME directory")
|
||||
}
|
||||
.join("zed");
|
||||
.join(APP_NAME_LOWERCASE);
|
||||
} else {
|
||||
// Windows
|
||||
return dirs::data_local_dir()
|
||||
.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") {
|
||||
return dirs::cache_dir()
|
||||
.expect("failed to determine cachesDirectory directory")
|
||||
.join("Zed");
|
||||
.join(APP_NAME);
|
||||
}
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
return dirs::cache_dir()
|
||||
.expect("failed to determine LocalAppData directory")
|
||||
.join("Zed");
|
||||
.join(APP_NAME);
|
||||
}
|
||||
|
||||
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
|
||||
|
|
@ -174,10 +211,10 @@ pub fn temp_dir() -> &'static PathBuf {
|
|||
} else {
|
||||
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();
|
||||
LOGS_DIR.get_or_init(|| {
|
||||
if cfg!(target_os = "macos") {
|
||||
home_dir().join("Library/Logs/Zed")
|
||||
home_dir().join("Library/Logs").join(APP_NAME)
|
||||
} else {
|
||||
data_dir().join("logs")
|
||||
}
|
||||
|
|
@ -208,13 +245,13 @@ pub fn remote_server_state_dir() -> &'static PathBuf {
|
|||
/// Returns the path to the `Zed.log` file.
|
||||
pub fn log_file() -> &'static PathBuf {
|
||||
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.
|
||||
pub fn old_log_file() -> &'static PathBuf {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ pub enum Direction {
|
|||
Down,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ScrollBehavior {
|
||||
RevealSelected,
|
||||
PreserveOffset,
|
||||
}
|
||||
|
||||
actions!(
|
||||
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>) {
|
||||
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);
|
||||
|
||||
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
|
||||
// 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
|
||||
|
|
@ -709,19 +725,40 @@ impl<D: PickerDelegate> Picker<D> {
|
|||
})?;
|
||||
delegate_pending_update_matches.await;
|
||||
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>) {
|
||||
if let ElementContainer::List(state) = &mut self.element_container {
|
||||
state.reset(self.delegate.match_count());
|
||||
fn matches_updated(
|
||||
&mut self,
|
||||
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;
|
||||
if let Some(secondary) = self.confirm_on_update.take() {
|
||||
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(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// at some point, but until then, this is a best-effort workaround that hopefully solves the issue
|
||||
/// 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 {
|
||||
let Some((package_name, version)) = package_spec.rsplit_once('@') else {
|
||||
return package_spec.to_string();
|
||||
|
|
@ -1614,7 +1621,7 @@ fn bounded_npm_package_spec(package_spec: &str) -> String {
|
|||
return package_spec.to_string();
|
||||
}
|
||||
|
||||
format!("{package_name}@<={version}")
|
||||
format!("{package_name}@0.0.0 - {version}")
|
||||
}
|
||||
|
||||
struct LocalCustomAgent {
|
||||
|
|
@ -2025,11 +2032,11 @@ mod tests {
|
|||
fn builds_bounded_npm_package_specs() {
|
||||
assert_eq!(
|
||||
bounded_npm_package_spec("agent-package@1.2.3"),
|
||||
"agent-package@<=1.2.3"
|
||||
"agent-package@0.0.0 - 1.2.3"
|
||||
);
|
||||
assert_eq!(
|
||||
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!(
|
||||
bounded_npm_package_spec("@scope/agent-package"),
|
||||
|
|
|
|||
|
|
@ -607,10 +607,7 @@ impl ContextServerStore {
|
|||
|
||||
let server = state.server();
|
||||
let configuration = state.configuration();
|
||||
let mut result = Ok(());
|
||||
if let ContextServerState::Running { server, .. } = &state {
|
||||
result = server.stop();
|
||||
}
|
||||
let result = server.stop();
|
||||
drop(state);
|
||||
|
||||
self.update_server_state(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ use git::{
|
|||
CreateWorktreeTarget, DiffType, FetchOptions, GitCommitTemplate, GitRepository,
|
||||
GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
|
||||
RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus,
|
||||
Worktree as GitWorktree,
|
||||
Worktree as GitWorktree, delete_branch_flag,
|
||||
},
|
||||
stash::{GitStash, StashEntry},
|
||||
status::{
|
||||
|
|
@ -2981,10 +2981,11 @@ impl GitStore {
|
|||
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
|
||||
let is_remote = envelope.payload.is_remote;
|
||||
let branch_name = envelope.payload.branch_name;
|
||||
let force = envelope.payload.force;
|
||||
|
||||
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??;
|
||||
|
||||
|
|
@ -7367,21 +7368,19 @@ impl Repository {
|
|||
&mut self,
|
||||
is_remote: bool,
|
||||
branch_name: String,
|
||||
force: bool,
|
||||
) -> oneshot::Receiver<Result<()>> {
|
||||
let id = self.id;
|
||||
let flag = delete_branch_flag(is_remote, force);
|
||||
self.send_job(
|
||||
Some(
|
||||
format!(
|
||||
"git branch {} {}",
|
||||
if is_remote { "-dr" } else { "-d" },
|
||||
branch_name
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
Some(format!("git branch {flag} {branch_name}").into()),
|
||||
move |repo, _cx| async move {
|
||||
match repo {
|
||||
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 }) => {
|
||||
client
|
||||
|
|
@ -7390,6 +7389,7 @@ impl Repository {
|
|||
repository_id: id.to_proto(),
|
||||
is_remote,
|
||||
branch_name,
|
||||
force,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ use ui::{
|
|||
StickyCandidate, Tooltip, WithScrollbar, prelude::*, v_flex,
|
||||
};
|
||||
use util::{
|
||||
ResultExt, TakeUntilExt, TryFutureExt, maybe,
|
||||
ResultExt, TakeUntilExt, TryFutureExt,
|
||||
markdown::MarkdownInlineCode,
|
||||
maybe,
|
||||
paths::{PathStyle, compare_paths},
|
||||
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;
|
||||
|
|
@ -2365,7 +2370,7 @@ impl ProjectPanel {
|
|||
let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
|
||||
let mut paths = file_paths
|
||||
.iter()
|
||||
.map(|(_, _, path)| path.clone())
|
||||
.map(|(_, _, path)| MarkdownInlineCode(path).to_string())
|
||||
.take(CUTOFF_POINT)
|
||||
.collect::<Vec<_>>();
|
||||
paths.truncate(CUTOFF_POINT);
|
||||
|
|
@ -2376,7 +2381,10 @@ impl ProjectPanel {
|
|||
}
|
||||
paths
|
||||
} 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 {
|
||||
String::new()
|
||||
|
|
|
|||
|
|
@ -11381,3 +11381,39 @@ impl Render for TestProjectItemView {
|
|||
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__`?"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ message GitDeleteBranch {
|
|||
uint64 repository_id = 2;
|
||||
string branch_name = 3;
|
||||
bool is_remote = 4;
|
||||
bool force = 5;
|
||||
}
|
||||
|
||||
message GitDiff {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ fs.workspace = true
|
|||
gpui = { workspace = true, features = ["test-support"] }
|
||||
http_client.workspace = true
|
||||
language = { workspace = true, features = ["test-support"] }
|
||||
picker = { workspace = true, features = ["test-support"] }
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
release_channel.workspace = true
|
||||
remote = { workspace = true, features = ["test-support"] }
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use gpui::{
|
|||
};
|
||||
|
||||
use picker::{
|
||||
Picker, PickerDelegate,
|
||||
Picker, PickerDelegate, ScrollBehavior,
|
||||
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
|
||||
};
|
||||
use project::{Worktree, git_store::Repository};
|
||||
|
|
@ -83,6 +83,15 @@ enum ProjectPickerEntry {
|
|||
RecentProject(StringMatch),
|
||||
}
|
||||
|
||||
fn is_selectable_entry(entry: &ProjectPickerEntry) -> bool {
|
||||
matches!(
|
||||
entry,
|
||||
ProjectPickerEntry::OpenFolder { .. }
|
||||
| ProjectPickerEntry::ProjectGroup(_)
|
||||
| ProjectPickerEntry::RecentProject(_)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProjectPickerStyle {
|
||||
Modal,
|
||||
|
|
@ -814,8 +823,7 @@ pub struct RecentProjectsDelegate {
|
|||
selected_index: usize,
|
||||
render_paths: bool,
|
||||
create_new_window: bool,
|
||||
// Flag to reset index when there is a new query vs not reset index when user delete an item
|
||||
reset_selected_match_index: bool,
|
||||
snap_selection_to_first_non_header_match: bool,
|
||||
has_any_non_local_projects: bool,
|
||||
project_connection_options: Option<RemoteConnectionOptions>,
|
||||
focus_handle: FocusHandle,
|
||||
|
|
@ -843,7 +851,7 @@ impl RecentProjectsDelegate {
|
|||
selected_index: 0,
|
||||
create_new_window,
|
||||
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(),
|
||||
project_connection_options,
|
||||
focus_handle,
|
||||
|
|
@ -1067,14 +1075,14 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
|
||||
self.filtered_entries = entries;
|
||||
|
||||
if self.reset_selected_match_index {
|
||||
if self.snap_selection_to_first_non_header_match {
|
||||
self.selected_index = self
|
||||
.filtered_entries
|
||||
.iter()
|
||||
.position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
|
||||
.unwrap_or(0);
|
||||
}
|
||||
self.reset_selected_match_index = true;
|
||||
self.snap_selection_to_first_non_header_match = true;
|
||||
Task::ready(())
|
||||
}
|
||||
|
||||
|
|
@ -2106,6 +2114,69 @@ impl RecentProjectsDelegate {
|
|||
.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(
|
||||
&self,
|
||||
ix: usize,
|
||||
|
|
@ -2115,7 +2186,10 @@ impl RecentProjectsDelegate {
|
|||
if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
|
||||
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
|
||||
.workspace
|
||||
.upgrade()
|
||||
|
|
@ -2133,12 +2207,9 @@ impl RecentProjectsDelegate {
|
|||
.await
|
||||
.unwrap_or_default();
|
||||
this.update_in(cx, move |picker, window, cx| {
|
||||
picker.delegate.set_workspaces(workspaces);
|
||||
picker
|
||||
.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);
|
||||
Self::update_picker_after_recent_project_deletion(
|
||||
picker, ix, workspaces, window, cx,
|
||||
);
|
||||
// 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`.
|
||||
if let Some(history_manager) = HistoryManager::global(cx) {
|
||||
|
|
@ -2234,7 +2305,7 @@ impl RecentProjectsDelegate {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::{TestAppContext, UpdateGlobal};
|
||||
use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
|
||||
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
|
|
@ -2243,6 +2314,220 @@ mod tests {
|
|||
|
||||
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]
|
||||
async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
|
|
|
|||
|
|
@ -429,6 +429,7 @@ impl JsonSchema for LanguageModelProviderSetting {
|
|||
"mistral",
|
||||
"ollama",
|
||||
"openai",
|
||||
"opencode",
|
||||
"openrouter",
|
||||
"vercel_ai_gateway",
|
||||
"x_ai",
|
||||
|
|
|
|||
|
|
@ -854,6 +854,30 @@ pub struct ThemeColorsContent {
|
|||
#[serde(rename = "editor.document_highlight.bracket_background")]
|
||||
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.
|
||||
#[serde(rename = "terminal.background")]
|
||||
pub terminal_background: Option<String>,
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ pub use tool_permissions_setup::{
|
|||
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_save_file_tool_config, render_terminal_tool_config, render_web_search_tool_config,
|
||||
render_write_file_tool_config,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ const TOOLS: &[ToolInfo] = &[
|
|||
description: "File editing operations",
|
||||
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 {
|
||||
id: "delete_path",
|
||||
name: "Delete Path",
|
||||
|
|
@ -303,6 +309,7 @@ fn get_tool_render_fn(
|
|||
match tool_id {
|
||||
"terminal" => render_terminal_tool_config,
|
||||
"edit_file" => render_edit_file_tool_config,
|
||||
"write_file" => render_write_file_tool_config,
|
||||
"delete_path" => render_delete_path_tool_config,
|
||||
"copy_path" => render_copy_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_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_copy_path_tool_config, "copy_path");
|
||||
tool_config_page_fn!(render_move_path_tool_config, "move_path");
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@ impl ThemeColors {
|
|||
editor_document_highlight_read_background: neutral().light_alpha().step_3(),
|
||||
editor_document_highlight_write_background: neutral().light_alpha().step_4(),
|
||||
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_foreground: black().light().step_12(),
|
||||
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_write_background: neutral().dark_alpha().step_4(),
|
||||
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_ansi_background: neutral().dark().step_1(),
|
||||
terminal_foreground: white().dark().step_12(),
|
||||
|
|
|
|||
|
|
@ -185,6 +185,12 @@ pub(crate) fn zed_default_dark() -> Theme {
|
|||
),
|
||||
editor_document_highlight_write_background: gpui::red(),
|
||||
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,
|
||||
// todo("Use one colors for terminal")
|
||||
|
|
|
|||
|
|
@ -241,6 +241,18 @@ pub struct ThemeColors {
|
|||
///
|
||||
/// Matching brackets in the cursor scope are highlighted with this background color.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ pub use settings::{FontWeightContent, WindowBackgroundContent};
|
|||
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ThemeFamilyContent {
|
||||
|
|
@ -230,6 +237,7 @@ pub fn status_colors_refinement(colors: &settings::StatusColorsContent) -> Statu
|
|||
pub fn theme_colors_refinement(
|
||||
this: &settings::ThemeColorsContent,
|
||||
status_colors: &StatusColorsRefinement,
|
||||
is_light: bool,
|
||||
) -> ThemeColorsRefinement {
|
||||
let border = this
|
||||
.border
|
||||
|
|
@ -278,6 +286,29 @@ pub fn theme_colors_refinement(
|
|||
.as_ref()
|
||||
.and_then(|color| try_parse_color(color).ok())
|
||||
.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 {
|
||||
border,
|
||||
border_variant: this
|
||||
|
|
@ -576,6 +607,36 @@ pub fn theme_colors_refinement(
|
|||
.as_ref()
|
||||
.and_then(|color| try_parse_color(color).ok())
|
||||
.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
|
||||
.as_ref()
|
||||
|
|
@ -696,16 +757,8 @@ pub fn theme_colors_refinement(
|
|||
.link_text_hover
|
||||
.as_ref()
|
||||
.and_then(|color| try_parse_color(color).ok()),
|
||||
version_control_added: this
|
||||
.version_control_added
|
||||
.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_added,
|
||||
version_control_deleted,
|
||||
version_control_modified: this
|
||||
.version_control_modified
|
||||
.as_ref()
|
||||
|
|
@ -856,7 +909,165 @@ fn try_parse_color(color: &str) -> anyhow::Result<Hsla> {
|
|||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn helix_jump_label_color_uses_theme_color_or_status_error() {
|
||||
|
|
@ -867,8 +1078,11 @@ mod tests {
|
|||
..Default::default()
|
||||
};
|
||||
|
||||
let fallback_refinement =
|
||||
theme_colors_refinement(&ThemeColorsContent::default(), &status_colors);
|
||||
let fallback_refinement = theme_colors_refinement(
|
||||
&ThemeColorsContent::default(),
|
||||
&status_colors,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fallback_refinement.vim_helix_jump_label_foreground,
|
||||
|
|
@ -881,6 +1095,7 @@ mod tests {
|
|||
..Default::default()
|
||||
},
|
||||
&status_colors,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -476,10 +476,12 @@ impl ThemeSettings {
|
|||
}
|
||||
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,
|
||||
&status_color_refinement,
|
||||
));
|
||||
base_theme.appearance.is_light(),
|
||||
);
|
||||
base_theme.styles.colors.refine(&theme_color_refinement);
|
||||
base_theme.styles.status.refine(&status_color_refinement);
|
||||
merge_player_colors(&mut base_theme.styles.player, &theme_overrides.players);
|
||||
merge_accent_colors(&mut base_theme.styles.accents, &theme_overrides.accents);
|
||||
|
|
|
|||
|
|
@ -296,8 +296,11 @@ pub fn refine_theme(theme: &ThemeContent) -> Theme {
|
|||
AppearanceContent::Light => ThemeColors::light(),
|
||||
AppearanceContent::Dark => ThemeColors::dark(),
|
||||
};
|
||||
let mut theme_colors_refinement =
|
||||
theme_colors_refinement(&theme.style.colors, &status_colors_refinement);
|
||||
let mut theme_colors_refinement = theme_colors_refinement(
|
||||
&theme.style.colors,
|
||||
&status_colors_refinement,
|
||||
theme.appearance == AppearanceContent::Light,
|
||||
);
|
||||
theme::apply_theme_color_defaults(&mut theme_colors_refinement, &refined_player_colors);
|
||||
refined_theme_colors.refine(&theme_colors_refinement);
|
||||
|
||||
|
|
|
|||
|
|
@ -2452,7 +2452,7 @@ fn find_matching_bracket_text_based(
|
|||
.find_map(|(ch, char_offset)| get_bracket_pair(ch).map(|info| (info, char_offset)));
|
||||
|
||||
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;
|
||||
|
|
@ -2489,18 +2489,20 @@ fn find_matching_bracket_text_based(
|
|||
fn find_matching_c_preprocessor_directive(
|
||||
map: &DisplaySnapshot,
|
||||
line_range: Range<MultiBufferOffset>,
|
||||
offset: MultiBufferOffset,
|
||||
) -> Option<MultiBufferOffset> {
|
||||
let line_start = map
|
||||
.buffer_chars_at(line_range.start)
|
||||
.skip_while(|(c, _)| *c == ' ' || *c == '\t')
|
||||
.take_while(|(c, char_offset)| *char_offset < line_range.end && !c.is_whitespace())
|
||||
.map(|(c, _)| c)
|
||||
.take(6)
|
||||
.collect::<String>();
|
||||
|
||||
if line_start.starts_with("#if")
|
||||
|| line_start.starts_with("#else")
|
||||
|| line_start.starts_with("#elif")
|
||||
{
|
||||
if line_range.start + line_start.len() < offset {
|
||||
return None;
|
||||
}
|
||||
|
||||
if line_start.starts_with("#if") || line_start.starts_with("#el") {
|
||||
let mut depth = 0i32;
|
||||
for (ch, char_offset) in map.buffer_chars_at(line_range.end) {
|
||||
if ch != '\n' {
|
||||
|
|
@ -2618,8 +2620,30 @@ fn matching(
|
|||
|
||||
// Ensure the range is contained by the current line.
|
||||
let mut line_end = map.next_line_boundary(point).0;
|
||||
if line_end == point {
|
||||
line_end = map.max_point().to_point(map);
|
||||
let max_point = 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, '\'' | '"' | '`');
|
||||
|
|
@ -2729,32 +2753,6 @@ fn matching(
|
|||
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
|
||||
.map(|destination| destination.to_display_point(map))
|
||||
.unwrap_or_else(|| {
|
||||
|
|
@ -3663,6 +3661,10 @@ mod test {
|
|||
cx.shared_state().await.assert_eq(indoc! {r"/*
|
||||
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.simulate_shared_keystrokes("%").await;
|
||||
|
|
@ -3673,48 +3675,53 @@ mod test {
|
|||
async fn test_matching_preprocessor_directives(cx: &mut gpui::TestAppContext) {
|
||||
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;
|
||||
cx.simulate_shared_keystrokes("%").await;
|
||||
cx.shared_state().await.assert_eq(indoc! {r"#if
|
||||
cx.shared_state().await.assert_eq(indoc! {r"
|
||||
#if
|
||||
|
||||
ˇ#else
|
||||
|
||||
#endif
|
||||
"});
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes("%").await;
|
||||
cx.shared_state().await.assert_eq(indoc! {r"#if
|
||||
cx.shared_state().await.assert_eq(indoc! {r"
|
||||
#if
|
||||
|
||||
#else
|
||||
|
||||
ˇ#endif
|
||||
"});
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes("%").await;
|
||||
cx.shared_state().await.assert_eq(indoc! {r"ˇ#if
|
||||
cx.shared_state().await.assert_eq(indoc! {r"
|
||||
ˇ#if
|
||||
|
||||
#else
|
||||
|
||||
#endif
|
||||
"});
|
||||
"});
|
||||
|
||||
cx.set_shared_state(indoc! {r"
|
||||
#ˇif
|
||||
#if
|
||||
|
||||
#else
|
||||
|
||||
#endif
|
||||
#ˇif
|
||||
#if
|
||||
|
||||
#else
|
||||
|
||||
#endif
|
||||
"})
|
||||
|
||||
#else
|
||||
|
||||
#endif
|
||||
"})
|
||||
.await;
|
||||
|
||||
cx.simulate_shared_keystrokes("%").await;
|
||||
|
|
@ -3727,8 +3734,9 @@ mod test {
|
|||
#endif
|
||||
|
||||
ˇ#else
|
||||
|
||||
#endif
|
||||
"});
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes("% %").await;
|
||||
cx.shared_state().await.assert_eq(indoc! {r"
|
||||
|
|
@ -3740,8 +3748,9 @@ mod test {
|
|||
#endif
|
||||
|
||||
#else
|
||||
|
||||
#endif
|
||||
"});
|
||||
"});
|
||||
cx.simulate_shared_keystrokes("j % % %").await;
|
||||
cx.shared_state().await.assert_eq(indoc! {r"
|
||||
#if
|
||||
|
|
@ -3752,8 +3761,28 @@ mod test {
|
|||
#endif
|
||||
|
||||
#else
|
||||
|
||||
#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]
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
|||
vim.update_editor(cx, |vim, editor, cx| {
|
||||
editor.transact(window, cx, |editor, window, cx| {
|
||||
let mut positions = vim.save_selection_starts(editor, cx);
|
||||
editor.rewrap_impl(
|
||||
editor.rewrap(
|
||||
RewrapOptions {
|
||||
override_language_settings: true,
|
||||
line_length: action.line_length,
|
||||
|
|
@ -74,7 +74,7 @@ impl Vim {
|
|||
);
|
||||
});
|
||||
});
|
||||
editor.rewrap_impl(
|
||||
editor.rewrap(
|
||||
RewrapOptions {
|
||||
override_language_settings: true,
|
||||
..Default::default()
|
||||
|
|
@ -112,7 +112,7 @@ impl Vim {
|
|||
object.expand_selection(map, selection, around, times);
|
||||
});
|
||||
});
|
||||
editor.rewrap_impl(
|
||||
editor.rewrap(
|
||||
RewrapOptions {
|
||||
override_language_settings: true,
|
||||
..Default::default()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
{"Get":{"state":"ˇ/*\n this is a comment\n*/","mode":"Normal"}}
|
||||
{"Key":"%"}
|
||||
{"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"}}
|
||||
{"Key":"%"}
|
||||
{"Get":{"state":"ˇ// comment","mode":"Normal"}}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,20 @@
|
|||
{"Get":{"state":"#if\n\n#else\n\nˇ#endif\n","mode":"Normal"}}
|
||||
{"Key":"%"}
|
||||
{"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":"%"}
|
||||
{"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":"%"}
|
||||
{"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":"%"}
|
||||
{"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"}}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ impl Workspace {
|
|||
let save_action = match save_strategy {
|
||||
SaveStrategy::All => {
|
||||
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(|_| ()) })
|
||||
});
|
||||
save_all.ok()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.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;
|
||||
|
||||
|
|
@ -3328,6 +3349,7 @@ impl Workspace {
|
|||
fn save_all(&mut self, action: &SaveAll, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.save_all_internal(
|
||||
action.save_intent.unwrap_or(SaveIntent::SaveAll),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
|
|
@ -3425,12 +3447,13 @@ impl Workspace {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<bool>> {
|
||||
self.save_all_internal(SaveIntent::Close, window, cx)
|
||||
self.save_all_internal(SaveIntent::Close, true, window, cx)
|
||||
}
|
||||
|
||||
fn save_all_internal(
|
||||
&mut self,
|
||||
mut save_intent: SaveIntent,
|
||||
allow_hot_exit_serialization: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<bool>> {
|
||||
|
|
@ -3457,23 +3480,27 @@ impl Workspace {
|
|||
let dirty_items = if save_intent == SaveIntent::Close && !dirty_items.is_empty() {
|
||||
let mut serialize_tasks = Vec::new();
|
||||
let mut remaining_dirty_items = Vec::new();
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
for (pane, item) in dirty_items {
|
||||
if let Some(task) = item
|
||||
.to_serializable_item_handle(cx)
|
||||
.and_then(|handle| handle.serialize(workspace, true, window, cx))
|
||||
{
|
||||
serialize_tasks.push((pane, item, task));
|
||||
} else {
|
||||
if allow_hot_exit_serialization {
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
for (pane, item) in dirty_items {
|
||||
if let Some(task) = item
|
||||
.to_serializable_item_handle(cx)
|
||||
.and_then(|handle| handle.serialize(workspace, true, window, cx))
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
for (pane, item, task) in serialize_tasks {
|
||||
if task.await.log_err().is_none() {
|
||||
remaining_dirty_items.push((pane, item));
|
||||
}
|
||||
} else {
|
||||
remaining_dirty_items = dirty_items;
|
||||
}
|
||||
|
||||
if !remaining_dirty_items.is_empty() {
|
||||
|
|
@ -11473,7 +11500,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[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);
|
||||
|
||||
// Register TestItem as a serializable item
|
||||
|
|
@ -11510,8 +11537,163 @@ mod tests {
|
|||
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]
|
||||
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);
|
||||
|
||||
cx.update(|cx| {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
description = "The fast, collaborative code editor."
|
||||
edition.workspace = true
|
||||
name = "zed"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Zed Team <hi@zed.dev>"]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@
|
|||
mod reliability;
|
||||
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_client_protocol::schema as acp;
|
||||
use agent_ui::AgentPanel;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue