Compare commits
16 Commits
v0.202.8
...
v0.196.3-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9b9b3194e | ||
|
|
eeb9e242b4 | ||
|
|
f9c498318d | ||
|
|
cb40bb755e | ||
|
|
991887a3ea | ||
|
|
f249ee481d | ||
|
|
484e39dcba | ||
|
|
ec7d6631a4 | ||
|
|
27691613c1 | ||
|
|
5f11e09a4b | ||
|
|
34e63f9e55 | ||
|
|
cbdca4e090 | ||
|
|
92105e92c3 | ||
|
|
632f09efd6 | ||
|
|
192e0e32dd | ||
|
|
30cc8bd824 |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -14720,6 +14720,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"telemetry",
|
||||
"theme",
|
||||
"tree-sitter-json",
|
||||
"tree-sitter-rust",
|
||||
@@ -16451,6 +16452,7 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"settings",
|
||||
"settings_ui",
|
||||
"smallvec",
|
||||
"story",
|
||||
"telemetry",
|
||||
@@ -20095,7 +20097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.196.0"
|
||||
version = "0.196.3"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"agent",
|
||||
|
||||
@@ -1118,7 +1118,9 @@
|
||||
"ctrl-f": "search::FocusSearch",
|
||||
"alt-find": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"alt-c": "keymap_editor::ToggleConflictFilter"
|
||||
"alt-c": "keymap_editor::ToggleConflictFilter",
|
||||
"enter": "keymap_editor::EditBinding",
|
||||
"alt-enter": "keymap_editor::CreateBinding"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1217,7 +1217,9 @@
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch",
|
||||
"cmd-alt-c": "keymap_editor::ToggleConflictFilter"
|
||||
"cmd-alt-c": "keymap_editor::ToggleConflictFilter",
|
||||
"enter": "keymap_editor::EditBinding",
|
||||
"alt-enter": "keymap_editor::CreateBinding"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -817,7 +817,7 @@
|
||||
"edit_file": true,
|
||||
"fetch": true,
|
||||
"list_directory": true,
|
||||
"project_notifications": true,
|
||||
"project_notifications": false,
|
||||
"move_path": true,
|
||||
"now": true,
|
||||
"find_path": true,
|
||||
@@ -837,7 +837,7 @@
|
||||
"diagnostics": true,
|
||||
"fetch": true,
|
||||
"list_directory": true,
|
||||
"project_notifications": true,
|
||||
"project_notifications": false,
|
||||
"now": true,
|
||||
"find_path": true,
|
||||
"read_file": true,
|
||||
|
||||
@@ -396,6 +396,7 @@ pub struct Thread {
|
||||
remaining_turns: u32,
|
||||
configured_model: Option<ConfiguredModel>,
|
||||
profile: AgentProfile,
|
||||
last_error_context: Option<(Arc<dyn LanguageModel>, CompletionIntent)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -489,10 +490,11 @@ impl Thread {
|
||||
retry_state: None,
|
||||
message_feedback: HashMap::default(),
|
||||
last_auto_capture_at: None,
|
||||
last_error_context: None,
|
||||
last_received_chunk_at: None,
|
||||
request_callback: None,
|
||||
remaining_turns: u32::MAX,
|
||||
configured_model,
|
||||
configured_model: configured_model.clone(),
|
||||
profile: AgentProfile::new(profile_id, tools),
|
||||
}
|
||||
}
|
||||
@@ -613,6 +615,7 @@ impl Thread {
|
||||
feedback: None,
|
||||
message_feedback: HashMap::default(),
|
||||
last_auto_capture_at: None,
|
||||
last_error_context: None,
|
||||
last_received_chunk_at: None,
|
||||
request_callback: None,
|
||||
remaining_turns: u32::MAX,
|
||||
@@ -1264,9 +1267,58 @@ impl Thread {
|
||||
|
||||
self.flush_notifications(model.clone(), intent, cx);
|
||||
|
||||
let request = self.to_completion_request(model.clone(), intent, cx);
|
||||
let _checkpoint = self.finalize_pending_checkpoint(cx);
|
||||
self.stream_completion(
|
||||
self.to_completion_request(model.clone(), intent, cx),
|
||||
model,
|
||||
intent,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
self.stream_completion(request, model, intent, window, cx);
|
||||
pub fn retry_last_completion(
|
||||
&mut self,
|
||||
window: Option<AnyWindowHandle>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Clear any existing error state
|
||||
self.retry_state = None;
|
||||
|
||||
// Use the last error context if available, otherwise fall back to configured model
|
||||
let (model, intent) = if let Some((model, intent)) = self.last_error_context.take() {
|
||||
(model, intent)
|
||||
} else if let Some(configured_model) = self.configured_model.as_ref() {
|
||||
let model = configured_model.model.clone();
|
||||
let intent = if self.has_pending_tool_uses() {
|
||||
CompletionIntent::ToolResults
|
||||
} else {
|
||||
CompletionIntent::UserPrompt
|
||||
};
|
||||
(model, intent)
|
||||
} else if let Some(configured_model) = self.get_or_init_configured_model(cx) {
|
||||
let model = configured_model.model.clone();
|
||||
let intent = if self.has_pending_tool_uses() {
|
||||
CompletionIntent::ToolResults
|
||||
} else {
|
||||
CompletionIntent::UserPrompt
|
||||
};
|
||||
(model, intent)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.send_to_model(model, intent, window, cx);
|
||||
}
|
||||
|
||||
pub fn enable_burn_mode_and_retry(
|
||||
&mut self,
|
||||
window: Option<AnyWindowHandle>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.completion_mode = CompletionMode::Burn;
|
||||
cx.emit(ThreadEvent::ProfileChanged);
|
||||
self.retry_last_completion(window, cx);
|
||||
}
|
||||
|
||||
pub fn used_tools_since_last_user_message(&self) -> bool {
|
||||
@@ -2146,6 +2198,35 @@ impl Thread {
|
||||
max_attempts: MAX_RETRY_ATTEMPTS,
|
||||
})
|
||||
}
|
||||
UpstreamProviderError {
|
||||
status,
|
||||
retry_after,
|
||||
..
|
||||
} => match *status {
|
||||
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => {
|
||||
Some(RetryStrategy::Fixed {
|
||||
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
||||
max_attempts: MAX_RETRY_ATTEMPTS,
|
||||
})
|
||||
}
|
||||
StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed {
|
||||
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
||||
// Internal Server Error could be anything, so only retry once.
|
||||
max_attempts: 1,
|
||||
}),
|
||||
status => {
|
||||
// There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"),
|
||||
// but we frequently get them in practice. See https://http.dev/529
|
||||
if status.as_u16() == 529 {
|
||||
Some(RetryStrategy::Fixed {
|
||||
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
||||
max_attempts: MAX_RETRY_ATTEMPTS,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
ApiInternalServerError { .. } => Some(RetryStrategy::Fixed {
|
||||
delay: BASE_RETRY_DELAY,
|
||||
max_attempts: 1,
|
||||
@@ -2193,6 +2274,23 @@ impl Thread {
|
||||
window: Option<AnyWindowHandle>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
// Store context for the Retry button
|
||||
self.last_error_context = Some((model.clone(), intent));
|
||||
|
||||
// Only auto-retry if Burn Mode is enabled
|
||||
if self.completion_mode != CompletionMode::Burn {
|
||||
// Show error with retry options
|
||||
cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError {
|
||||
message: format!(
|
||||
"{}\n\nTo automatically retry when similar errors happen, enable Burn Mode.",
|
||||
error
|
||||
)
|
||||
.into(),
|
||||
can_enable_burn_mode: true,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(strategy) = strategy.or_else(|| Self::get_retry_strategy(error)) else {
|
||||
return false;
|
||||
};
|
||||
@@ -2273,6 +2371,13 @@ impl Thread {
|
||||
// Stop generating since we're giving up on retrying.
|
||||
self.pending_completions.clear();
|
||||
|
||||
// Show error alongside a Retry button, but no
|
||||
// Enable Burn Mode button (since it's already enabled)
|
||||
cx.emit(ThreadEvent::ShowError(ThreadError::RetryableError {
|
||||
message: format!("Failed after retrying: {}", error).into(),
|
||||
can_enable_burn_mode: false,
|
||||
}));
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -3183,6 +3288,11 @@ pub enum ThreadError {
|
||||
header: SharedString,
|
||||
message: SharedString,
|
||||
},
|
||||
#[error("Retryable error: {message}")]
|
||||
RetryableError {
|
||||
message: SharedString,
|
||||
can_enable_burn_mode: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -3583,6 +3693,7 @@ fn main() {{
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
#[ignore] // turn this test on when project_notifications tool is re-enabled
|
||||
async fn test_stale_buffer_notification(cx: &mut TestAppContext) {
|
||||
init_test_settings(cx);
|
||||
|
||||
@@ -4137,6 +4248,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// Create model that returns overloaded error
|
||||
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
|
||||
|
||||
@@ -4210,6 +4326,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// Create model that returns internal server error
|
||||
let model = Arc::new(ErrorInjector::new(TestError::InternalServerError));
|
||||
|
||||
@@ -4286,6 +4407,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// Create model that returns internal server error
|
||||
let model = Arc::new(ErrorInjector::new(TestError::InternalServerError));
|
||||
|
||||
@@ -4393,6 +4519,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// Create model that returns overloaded error
|
||||
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
|
||||
|
||||
@@ -4479,6 +4610,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// We'll use a wrapper to switch behavior after first failure
|
||||
struct RetryTestModel {
|
||||
inner: Arc<FakeLanguageModel>,
|
||||
@@ -4647,6 +4783,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// Create a model that fails once then succeeds
|
||||
struct FailOnceModel {
|
||||
inner: Arc<FakeLanguageModel>,
|
||||
@@ -4808,6 +4949,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// Create a model that returns rate limit error with retry_after
|
||||
struct RateLimitModel {
|
||||
inner: Arc<FakeLanguageModel>,
|
||||
@@ -5081,6 +5227,79 @@ fn main() {{
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_no_retry_without_burn_mode(cx: &mut TestAppContext) {
|
||||
init_test_settings(cx);
|
||||
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Ensure we're in Normal mode (not Burn mode)
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Normal);
|
||||
});
|
||||
|
||||
// Track error events
|
||||
let error_events = Arc::new(Mutex::new(Vec::new()));
|
||||
let error_events_clone = error_events.clone();
|
||||
|
||||
let _subscription = thread.update(cx, |_, cx| {
|
||||
cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| {
|
||||
if let ThreadEvent::ShowError(error) = event {
|
||||
error_events_clone.lock().push(error.clone());
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Create model that returns overloaded error
|
||||
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
|
||||
|
||||
// Insert a user message
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.insert_user_message("Hello!", ContextLoadResult::default(), None, vec![], cx);
|
||||
});
|
||||
|
||||
// Start completion
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.send_to_model(model.clone(), CompletionIntent::UserPrompt, None, cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify no retry state was created
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert!(
|
||||
thread.retry_state.is_none(),
|
||||
"Should not have retry state in Normal mode"
|
||||
);
|
||||
});
|
||||
|
||||
// Check that a retryable error was reported
|
||||
let errors = error_events.lock();
|
||||
assert!(!errors.is_empty(), "Should have received an error event");
|
||||
|
||||
if let ThreadError::RetryableError {
|
||||
message: _,
|
||||
can_enable_burn_mode,
|
||||
} = &errors[0]
|
||||
{
|
||||
assert!(
|
||||
*can_enable_burn_mode,
|
||||
"Error should indicate burn mode can be enabled"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected RetryableError, got {:?}", errors[0]);
|
||||
}
|
||||
|
||||
// Verify the thread is no longer generating
|
||||
thread.read_with(cx, |thread, _| {
|
||||
assert!(
|
||||
!thread.is_generating(),
|
||||
"Should not be generating after error without retry"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_retry_cancelled_on_stop(cx: &mut TestAppContext) {
|
||||
init_test_settings(cx);
|
||||
@@ -5088,6 +5307,11 @@ fn main() {{
|
||||
let project = create_test_project(cx, json!({})).await;
|
||||
let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await;
|
||||
|
||||
// Enable Burn Mode to allow retries
|
||||
thread.update(cx, |thread, _| {
|
||||
thread.set_completion_mode(CompletionMode::Burn);
|
||||
});
|
||||
|
||||
// Create model that returns overloaded error
|
||||
let model = Arc::new(ErrorInjector::new(TestError::Overloaded));
|
||||
|
||||
|
||||
@@ -1036,7 +1036,7 @@ impl ActiveThread {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.last_error = Some(ThreadError::Message {
|
||||
header: "Error interacting with language model".into(),
|
||||
header: "Error".into(),
|
||||
message: error_message.into(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,8 +64,9 @@ use theme::ThemeSettings;
|
||||
use time::UtcOffset;
|
||||
use ui::utils::WithRemSize;
|
||||
use ui::{
|
||||
Banner, Callout, CheckboxWithLabel, ContextMenu, ElevationIndex, KeyBinding, PopoverMenu,
|
||||
PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName, prelude::*,
|
||||
Banner, Button, Callout, CheckboxWithLabel, ContextMenu, ElevationIndex, IconPosition,
|
||||
KeyBinding, PopoverMenu, PopoverMenuHandle, ProgressBar, Tab, Tooltip, Vector, VectorName,
|
||||
prelude::*,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
use workspace::{
|
||||
@@ -2913,6 +2914,21 @@ impl AgentPanel {
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error);
|
||||
|
||||
let retry_button = Button::new("retry", "Retry")
|
||||
.icon(IconName::RotateCw)
|
||||
.icon_position(IconPosition::Start)
|
||||
.on_click({
|
||||
let thread = thread.clone();
|
||||
move |_, window, cx| {
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.clear_last_error();
|
||||
thread.thread().update(cx, |thread, cx| {
|
||||
thread.retry_last_completion(Some(window.window_handle()), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
div()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
@@ -2921,13 +2937,72 @@ impl AgentPanel {
|
||||
.icon(icon)
|
||||
.title(header)
|
||||
.description(message.clone())
|
||||
.primary_action(self.dismiss_error_button(thread, cx))
|
||||
.secondary_action(self.create_copy_button(message_with_header))
|
||||
.primary_action(retry_button)
|
||||
.secondary_action(self.dismiss_error_button(thread, cx))
|
||||
.tertiary_action(self.create_copy_button(message_with_header))
|
||||
.bg_color(self.error_callout_bg(cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_retryable_error(
|
||||
&self,
|
||||
message: SharedString,
|
||||
can_enable_burn_mode: bool,
|
||||
thread: &Entity<ActiveThread>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let icon = Icon::new(IconName::XCircle)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error);
|
||||
|
||||
let retry_button = Button::new("retry", "Retry")
|
||||
.icon(IconName::RotateCw)
|
||||
.icon_position(IconPosition::Start)
|
||||
.on_click({
|
||||
let thread = thread.clone();
|
||||
move |_, window, cx| {
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.clear_last_error();
|
||||
thread.thread().update(cx, |thread, cx| {
|
||||
thread.retry_last_completion(Some(window.window_handle()), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let mut callout = Callout::new()
|
||||
.icon(icon)
|
||||
.title("Error")
|
||||
.description(message.clone())
|
||||
.bg_color(self.error_callout_bg(cx))
|
||||
.primary_action(retry_button);
|
||||
|
||||
if can_enable_burn_mode {
|
||||
let burn_mode_button = Button::new("enable_burn_retry", "Enable Burn Mode and Retry")
|
||||
.icon(IconName::ZedBurnMode)
|
||||
.icon_position(IconPosition::Start)
|
||||
.on_click({
|
||||
let thread = thread.clone();
|
||||
move |_, window, cx| {
|
||||
thread.update(cx, |thread, cx| {
|
||||
thread.clear_last_error();
|
||||
thread.thread().update(cx, |thread, cx| {
|
||||
thread.enable_burn_mode_and_retry(Some(window.window_handle()), cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
callout = callout.secondary_action(burn_mode_button);
|
||||
}
|
||||
|
||||
div()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(callout)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_prompt_editor(
|
||||
&self,
|
||||
context_editor: &Entity<TextThreadEditor>,
|
||||
@@ -3169,6 +3244,15 @@ impl Render for AgentPanel {
|
||||
ThreadError::Message { header, message } => {
|
||||
self.render_error_message(header, message, thread, cx)
|
||||
}
|
||||
ThreadError::RetryableError {
|
||||
message,
|
||||
can_enable_burn_mode,
|
||||
} => self.render_retryable_error(
|
||||
message,
|
||||
can_enable_burn_mode,
|
||||
thread,
|
||||
cx,
|
||||
),
|
||||
})
|
||||
.into_any(),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ use collections::HashMap;
|
||||
use fs::FakeFs;
|
||||
use futures::{FutureExt, future::LocalBoxFuture};
|
||||
use gpui::{AppContext, TestAppContext, Timer};
|
||||
use http_client::StatusCode;
|
||||
use indoc::{formatdoc, indoc};
|
||||
use language_model::{
|
||||
LanguageModelRegistry, LanguageModelRequestTool, LanguageModelToolResult,
|
||||
@@ -1675,6 +1676,30 @@ async fn retry_on_rate_limit<R>(mut request: impl AsyncFnMut() -> Result<R>) ->
|
||||
Timer::after(retry_after + jitter).await;
|
||||
continue;
|
||||
}
|
||||
LanguageModelCompletionError::UpstreamProviderError {
|
||||
status,
|
||||
retry_after,
|
||||
..
|
||||
} => {
|
||||
// Only retry for specific status codes
|
||||
let should_retry = matches!(
|
||||
*status,
|
||||
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE
|
||||
) || status.as_u16() == 529;
|
||||
|
||||
if !should_retry {
|
||||
return Err(err.into());
|
||||
}
|
||||
|
||||
// Use server-provided retry_after if available, otherwise use default
|
||||
let retry_after = retry_after.unwrap_or(Duration::from_secs(5));
|
||||
let jitter = retry_after.mul_f64(rand::thread_rng().gen_range(0.0..1.0));
|
||||
eprintln!(
|
||||
"Attempt #{attempt}: {err}. Retry after {retry_after:?} + jitter of {jitter:?}"
|
||||
);
|
||||
Timer::after(retry_after + jitter).await;
|
||||
continue;
|
||||
}
|
||||
_ => return Err(err.into()),
|
||||
},
|
||||
Err(err) => return Err(err),
|
||||
|
||||
@@ -822,11 +822,28 @@ impl crate::Keystroke {
|
||||
Keysym::underscore => "_".to_owned(),
|
||||
Keysym::equal => "=".to_owned(),
|
||||
Keysym::plus => "+".to_owned(),
|
||||
Keysym::space => "space".to_owned(),
|
||||
Keysym::BackSpace => "backspace".to_owned(),
|
||||
Keysym::Tab => "tab".to_owned(),
|
||||
Keysym::Delete => "delete".to_owned(),
|
||||
Keysym::Escape => "escape".to_owned(),
|
||||
|
||||
_ => {
|
||||
let name = xkb::keysym_get_name(key_sym).to_lowercase();
|
||||
if key_sym.is_keypad_key() {
|
||||
name.replace("kp_", "")
|
||||
} else if let Some(key) = key_utf8.chars().next()
|
||||
&& key_utf8.len() == 1
|
||||
&& key.is_ascii()
|
||||
{
|
||||
if key.is_ascii_graphic() {
|
||||
key_utf8.to_lowercase()
|
||||
// map ctrl-a to a
|
||||
} else if key_utf32 <= 0x1f {
|
||||
((key_utf32 as u8 + 0x60) as char).to_string()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
} else if let Some(key_en) = guess_ascii(keycode, modifiers.shift) {
|
||||
String::from(key_en)
|
||||
} else {
|
||||
|
||||
@@ -116,6 +116,12 @@ pub enum LanguageModelCompletionError {
|
||||
provider: LanguageModelProviderName,
|
||||
message: String,
|
||||
},
|
||||
#[error("{message}")]
|
||||
UpstreamProviderError {
|
||||
message: String,
|
||||
status: StatusCode,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
#[error("HTTP response error from {provider}'s API: status {status_code} - {message:?}")]
|
||||
HttpResponseError {
|
||||
provider: LanguageModelProviderName,
|
||||
|
||||
@@ -644,8 +644,62 @@ struct ApiError {
|
||||
headers: HeaderMap<HeaderValue>,
|
||||
}
|
||||
|
||||
/// Represents error responses from Zed's cloud API.
|
||||
///
|
||||
/// Example JSON for an upstream HTTP error:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "code": "upstream_http_error",
|
||||
/// "message": "Received an error from the Anthropic API: upstream connect error or disconnect/reset before headers, reset reason: connection timeout",
|
||||
/// "upstream_status": 503
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CloudApiError {
|
||||
code: String,
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
#[serde(deserialize_with = "deserialize_optional_status_code")]
|
||||
upstream_status: Option<StatusCode>,
|
||||
#[serde(default)]
|
||||
retry_after: Option<f64>,
|
||||
}
|
||||
|
||||
fn deserialize_optional_status_code<'de, D>(deserializer: D) -> Result<Option<StatusCode>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let opt: Option<u16> = Option::deserialize(deserializer)?;
|
||||
Ok(opt.and_then(|code| StatusCode::from_u16(code).ok()))
|
||||
}
|
||||
|
||||
impl From<ApiError> for LanguageModelCompletionError {
|
||||
fn from(error: ApiError) -> Self {
|
||||
if let Ok(cloud_error) = serde_json::from_str::<CloudApiError>(&error.body) {
|
||||
if cloud_error.code.starts_with("upstream_http_") {
|
||||
let status = if let Some(status) = cloud_error.upstream_status {
|
||||
status
|
||||
} else if cloud_error.code.ends_with("_error") {
|
||||
error.status
|
||||
} else {
|
||||
// If there's a status code in the code string (e.g. "upstream_http_429")
|
||||
// then use that; otherwise, see if the JSON contains a status code.
|
||||
cloud_error
|
||||
.code
|
||||
.strip_prefix("upstream_http_")
|
||||
.and_then(|code_str| code_str.parse::<u16>().ok())
|
||||
.and_then(|code| StatusCode::from_u16(code).ok())
|
||||
.unwrap_or(error.status)
|
||||
};
|
||||
|
||||
return LanguageModelCompletionError::UpstreamProviderError {
|
||||
message: cloud_error.message,
|
||||
status,
|
||||
retry_after: cloud_error.retry_after.map(Duration::from_secs_f64),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let retry_after = None;
|
||||
LanguageModelCompletionError::from_http_status(
|
||||
PROVIDER_NAME,
|
||||
@@ -1279,3 +1333,155 @@ impl Component for ZedAiConfiguration {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_client::http::{HeaderMap, StatusCode};
|
||||
use language_model::LanguageModelCompletionError;
|
||||
|
||||
#[test]
|
||||
fn test_api_error_conversion_with_upstream_http_error() {
|
||||
// upstream_http_error with 503 status should become ServerOverloaded
|
||||
let error_body = r#"{"code":"upstream_http_error","message":"Received an error from the Anthropic API: upstream connect error or disconnect/reset before headers, reset reason: connection timeout","upstream_status":503}"#;
|
||||
|
||||
let api_error = ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
body: error_body.to_string(),
|
||||
headers: HeaderMap::new(),
|
||||
};
|
||||
|
||||
let completion_error: LanguageModelCompletionError = api_error.into();
|
||||
|
||||
match completion_error {
|
||||
LanguageModelCompletionError::UpstreamProviderError { message, .. } => {
|
||||
assert_eq!(
|
||||
message,
|
||||
"Received an error from the Anthropic API: upstream connect error or disconnect/reset before headers, reset reason: connection timeout"
|
||||
);
|
||||
}
|
||||
_ => panic!(
|
||||
"Expected UpstreamProviderError for upstream 503, got: {:?}",
|
||||
completion_error
|
||||
),
|
||||
}
|
||||
|
||||
// upstream_http_error with 500 status should become ApiInternalServerError
|
||||
let error_body = r#"{"code":"upstream_http_error","message":"Received an error from the OpenAI API: internal server error","upstream_status":500}"#;
|
||||
|
||||
let api_error = ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
body: error_body.to_string(),
|
||||
headers: HeaderMap::new(),
|
||||
};
|
||||
|
||||
let completion_error: LanguageModelCompletionError = api_error.into();
|
||||
|
||||
match completion_error {
|
||||
LanguageModelCompletionError::UpstreamProviderError { message, .. } => {
|
||||
assert_eq!(
|
||||
message,
|
||||
"Received an error from the OpenAI API: internal server error"
|
||||
);
|
||||
}
|
||||
_ => panic!(
|
||||
"Expected UpstreamProviderError for upstream 500, got: {:?}",
|
||||
completion_error
|
||||
),
|
||||
}
|
||||
|
||||
// upstream_http_error with 429 status should become RateLimitExceeded
|
||||
let error_body = r#"{"code":"upstream_http_error","message":"Received an error from the Google API: rate limit exceeded","upstream_status":429}"#;
|
||||
|
||||
let api_error = ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
body: error_body.to_string(),
|
||||
headers: HeaderMap::new(),
|
||||
};
|
||||
|
||||
let completion_error: LanguageModelCompletionError = api_error.into();
|
||||
|
||||
match completion_error {
|
||||
LanguageModelCompletionError::UpstreamProviderError { message, .. } => {
|
||||
assert_eq!(
|
||||
message,
|
||||
"Received an error from the Google API: rate limit exceeded"
|
||||
);
|
||||
}
|
||||
_ => panic!(
|
||||
"Expected UpstreamProviderError for upstream 429, got: {:?}",
|
||||
completion_error
|
||||
),
|
||||
}
|
||||
|
||||
// Regular 500 error without upstream_http_error should remain ApiInternalServerError for Zed
|
||||
let error_body = "Regular internal server error";
|
||||
|
||||
let api_error = ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
body: error_body.to_string(),
|
||||
headers: HeaderMap::new(),
|
||||
};
|
||||
|
||||
let completion_error: LanguageModelCompletionError = api_error.into();
|
||||
|
||||
match completion_error {
|
||||
LanguageModelCompletionError::ApiInternalServerError { provider, message } => {
|
||||
assert_eq!(provider, PROVIDER_NAME);
|
||||
assert_eq!(message, "Regular internal server error");
|
||||
}
|
||||
_ => panic!(
|
||||
"Expected ApiInternalServerError for regular 500, got: {:?}",
|
||||
completion_error
|
||||
),
|
||||
}
|
||||
|
||||
// upstream_http_429 format should be converted to UpstreamProviderError
|
||||
let error_body = r#"{"code":"upstream_http_429","message":"Upstream Anthropic rate limit exceeded.","retry_after":30.5}"#;
|
||||
|
||||
let api_error = ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
body: error_body.to_string(),
|
||||
headers: HeaderMap::new(),
|
||||
};
|
||||
|
||||
let completion_error: LanguageModelCompletionError = api_error.into();
|
||||
|
||||
match completion_error {
|
||||
LanguageModelCompletionError::UpstreamProviderError {
|
||||
message,
|
||||
status,
|
||||
retry_after,
|
||||
} => {
|
||||
assert_eq!(message, "Upstream Anthropic rate limit exceeded.");
|
||||
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(retry_after, Some(Duration::from_secs_f64(30.5)));
|
||||
}
|
||||
_ => panic!(
|
||||
"Expected UpstreamProviderError for upstream_http_429, got: {:?}",
|
||||
completion_error
|
||||
),
|
||||
}
|
||||
|
||||
// Invalid JSON in error body should fall back to regular error handling
|
||||
let error_body = "Not JSON at all";
|
||||
|
||||
let api_error = ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
body: error_body.to_string(),
|
||||
headers: HeaderMap::new(),
|
||||
};
|
||||
|
||||
let completion_error: LanguageModelCompletionError = api_error.into();
|
||||
|
||||
match completion_error {
|
||||
LanguageModelCompletionError::ApiInternalServerError { provider, .. } => {
|
||||
assert_eq!(provider, PROVIDER_NAME);
|
||||
}
|
||||
_ => panic!(
|
||||
"Expected ApiInternalServerError for invalid JSON, got: {:?}",
|
||||
completion_error
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,6 +847,7 @@ impl KeymapFile {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum KeybindUpdateOperation<'a> {
|
||||
Replace {
|
||||
/// Describes the keybind to create
|
||||
@@ -865,6 +866,47 @@ pub enum KeybindUpdateOperation<'a> {
|
||||
},
|
||||
}
|
||||
|
||||
impl KeybindUpdateOperation<'_> {
|
||||
pub fn generate_telemetry(
|
||||
&self,
|
||||
) -> (
|
||||
// The keybind that is created
|
||||
String,
|
||||
// The keybinding that was removed
|
||||
String,
|
||||
// The source of the keybinding
|
||||
String,
|
||||
) {
|
||||
let (new_binding, removed_binding, source) = match &self {
|
||||
KeybindUpdateOperation::Replace {
|
||||
source,
|
||||
target,
|
||||
target_keybind_source,
|
||||
} => (Some(source), Some(target), Some(*target_keybind_source)),
|
||||
KeybindUpdateOperation::Add { source, .. } => (Some(source), None, None),
|
||||
KeybindUpdateOperation::Remove {
|
||||
target,
|
||||
target_keybind_source,
|
||||
} => (None, Some(target), Some(*target_keybind_source)),
|
||||
};
|
||||
|
||||
let new_binding = new_binding
|
||||
.map(KeybindUpdateTarget::telemetry_string)
|
||||
.unwrap_or("null".to_owned());
|
||||
let removed_binding = removed_binding
|
||||
.map(KeybindUpdateTarget::telemetry_string)
|
||||
.unwrap_or("null".to_owned());
|
||||
|
||||
let source = source
|
||||
.as_ref()
|
||||
.map(KeybindSource::name)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or("null".to_owned());
|
||||
|
||||
(new_binding, removed_binding, source)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> KeybindUpdateOperation<'a> {
|
||||
pub fn add(source: KeybindUpdateTarget<'a>) -> Self {
|
||||
Self::Add { source, from: None }
|
||||
@@ -905,6 +947,16 @@ impl<'a> KeybindUpdateTarget<'a> {
|
||||
keystrokes.pop();
|
||||
keystrokes
|
||||
}
|
||||
|
||||
fn telemetry_string(&self) -> String {
|
||||
format!(
|
||||
"action_name: {}, context: {}, action_arguments: {}, keystrokes: {}",
|
||||
self.action_name,
|
||||
self.context.unwrap_or("global"),
|
||||
self.action_arguments.unwrap_or("none"),
|
||||
self.keystrokes_unparsed()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -34,6 +34,7 @@ search.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
telemetry.workspace = true
|
||||
theme.workspace = true
|
||||
tree-sitter-json.workspace = true
|
||||
tree-sitter-rust.workspace = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{
|
||||
ops::{Not as _, Range},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, anyhow};
|
||||
@@ -12,7 +13,7 @@ use gpui::{
|
||||
Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context,
|
||||
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero,
|
||||
KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy,
|
||||
ScrollWheelEvent, StyledText, Subscription, WeakEntity, actions, anchored, deferred, div,
|
||||
ScrollWheelEvent, StyledText, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
|
||||
};
|
||||
use language::{Language, LanguageConfig, ToOffset as _};
|
||||
use notifications::status_toast::{StatusToast, ToastIcon};
|
||||
@@ -151,6 +152,13 @@ impl SearchMode {
|
||||
SearchMode::KeyStroke { .. } => SearchMode::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_match(&self) -> bool {
|
||||
match self {
|
||||
SearchMode::Normal => false,
|
||||
SearchMode::KeyStroke { exact_match } => *exact_match,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, PartialEq, Copy, Clone)]
|
||||
@@ -249,6 +257,7 @@ struct KeymapEditor {
|
||||
keybinding_conflict_state: ConflictState,
|
||||
filter_state: FilterState,
|
||||
search_mode: SearchMode,
|
||||
search_query_debounce: Option<Task<()>>,
|
||||
// corresponds 1 to 1 with keybindings
|
||||
string_match_candidates: Arc<Vec<StringMatchCandidate>>,
|
||||
matches: Vec<StringMatch>,
|
||||
@@ -259,6 +268,7 @@ struct KeymapEditor {
|
||||
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
|
||||
previous_edit: Option<PreviousEdit>,
|
||||
humanized_action_names: HashMap<&'static str, SharedString>,
|
||||
show_hover_menus: bool,
|
||||
}
|
||||
|
||||
enum PreviousEdit {
|
||||
@@ -294,7 +304,7 @@ impl KeymapEditor {
|
||||
|
||||
let keystroke_editor = cx.new(|cx| {
|
||||
let mut keystroke_editor = KeystrokeInput::new(None, window, cx);
|
||||
keystroke_editor.highlight_on_focus = false;
|
||||
keystroke_editor.search = true;
|
||||
keystroke_editor
|
||||
});
|
||||
|
||||
@@ -347,6 +357,8 @@ impl KeymapEditor {
|
||||
context_menu: None,
|
||||
previous_edit: None,
|
||||
humanized_action_names,
|
||||
search_query_debounce: None,
|
||||
show_hover_menus: true,
|
||||
};
|
||||
|
||||
this.on_keymap_changed(cx);
|
||||
@@ -371,10 +383,32 @@ impl KeymapEditor {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_query_changed(&self, cx: &mut Context<Self>) {
|
||||
fn on_query_changed(&mut self, cx: &mut Context<Self>) {
|
||||
let action_query = self.current_action_query(cx);
|
||||
let keystroke_query = self.current_keystroke_query(cx);
|
||||
let exact_match = self.search_mode.exact_match();
|
||||
|
||||
let timer = cx.background_executor().timer(Duration::from_secs(1));
|
||||
self.search_query_debounce = Some(cx.background_spawn({
|
||||
let action_query = action_query.clone();
|
||||
let keystroke_query = keystroke_query.clone();
|
||||
async move {
|
||||
timer.await;
|
||||
|
||||
let keystroke_query = keystroke_query
|
||||
.into_iter()
|
||||
.map(|keystroke| keystroke.unparse())
|
||||
.collect::<Vec<String>>()
|
||||
.join(" ");
|
||||
|
||||
telemetry::event!(
|
||||
"Keystroke Search Completed",
|
||||
action_query = action_query,
|
||||
keystroke_query = keystroke_query,
|
||||
keystroke_exact_match = exact_match
|
||||
)
|
||||
}
|
||||
}));
|
||||
cx.spawn(async move |this, cx| {
|
||||
Self::update_matches(this.clone(), action_query, keystroke_query, cx).await?;
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -474,6 +508,7 @@ impl KeymapEditor {
|
||||
}
|
||||
this.selected_index.take();
|
||||
this.matches = matches;
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
@@ -650,7 +685,7 @@ impl KeymapEditor {
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn dispatch_context(&self, _window: &Window, _cx: &Context<Self>) -> KeyContext {
|
||||
fn key_context(&self) -> KeyContext {
|
||||
let mut dispatch_context = KeyContext::new_with_defaults();
|
||||
dispatch_context.add("KeymapEditor");
|
||||
dispatch_context.add("menu");
|
||||
@@ -685,14 +720,19 @@ impl KeymapEditor {
|
||||
self.selected_index.take();
|
||||
}
|
||||
|
||||
fn selected_keybind_idx(&self) -> Option<usize> {
|
||||
fn selected_keybind_index(&self) -> Option<usize> {
|
||||
self.selected_index
|
||||
.and_then(|match_index| self.matches.get(match_index))
|
||||
.map(|r#match| r#match.candidate_id)
|
||||
}
|
||||
|
||||
fn selected_keybind_and_index(&self) -> Option<(&ProcessedKeybinding, usize)> {
|
||||
self.selected_keybind_index()
|
||||
.map(|keybind_index| (&self.keybindings[keybind_index], keybind_index))
|
||||
}
|
||||
|
||||
fn selected_binding(&self) -> Option<&ProcessedKeybinding> {
|
||||
self.selected_keybind_idx()
|
||||
self.selected_keybind_index()
|
||||
.and_then(|keybind_index| self.keybindings.get(keybind_index))
|
||||
}
|
||||
|
||||
@@ -724,40 +764,41 @@ impl KeymapEditor {
|
||||
let selected_binding_is_unbound = selected_binding.keystrokes().is_none();
|
||||
|
||||
let context_menu = ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
menu.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Edit",
|
||||
Box::new(EditBinding),
|
||||
)
|
||||
.action("Create", Box::new(CreateBinding))
|
||||
.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Delete",
|
||||
Box::new(DeleteBinding),
|
||||
)
|
||||
.separator()
|
||||
.action("Copy Action", Box::new(CopyAction))
|
||||
.action_disabled_when(
|
||||
selected_binding_has_no_context,
|
||||
"Copy Context",
|
||||
Box::new(CopyContext),
|
||||
)
|
||||
.entry("Show matching keybindings", None, {
|
||||
let weak = weak.clone();
|
||||
let key_strokes = key_strokes.clone();
|
||||
menu.context(self.focus_handle.clone())
|
||||
.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Edit",
|
||||
Box::new(EditBinding),
|
||||
)
|
||||
.action("Create", Box::new(CreateBinding))
|
||||
.action_disabled_when(
|
||||
selected_binding_is_unbound,
|
||||
"Delete",
|
||||
Box::new(DeleteBinding),
|
||||
)
|
||||
.separator()
|
||||
.action("Copy Action", Box::new(CopyAction))
|
||||
.action_disabled_when(
|
||||
selected_binding_has_no_context,
|
||||
"Copy Context",
|
||||
Box::new(CopyContext),
|
||||
)
|
||||
.entry("Show matching keybindings", None, {
|
||||
let weak = weak.clone();
|
||||
let key_strokes = key_strokes.clone();
|
||||
|
||||
move |_, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.filter_state = FilterState::All;
|
||||
this.search_mode = SearchMode::KeyStroke { exact_match: true };
|
||||
move |_, cx| {
|
||||
weak.update(cx, |this, cx| {
|
||||
this.filter_state = FilterState::All;
|
||||
this.search_mode = SearchMode::KeyStroke { exact_match: true };
|
||||
|
||||
this.keystroke_editor.update(cx, |editor, cx| {
|
||||
editor.set_keystrokes(key_strokes.clone(), cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
this.keystroke_editor.update(cx, |editor, cx| {
|
||||
editor.set_keystrokes(key_strokes.clone(), cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let context_menu_handle = context_menu.focus_handle(cx);
|
||||
@@ -786,6 +827,7 @@ impl KeymapEditor {
|
||||
}
|
||||
|
||||
fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.show_hover_menus = false;
|
||||
if let Some(selected) = self.selected_index {
|
||||
let selected = selected + 1;
|
||||
if selected >= self.matches.len() {
|
||||
@@ -806,6 +848,7 @@ impl KeymapEditor {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.show_hover_menus = false;
|
||||
if let Some(selected) = self.selected_index {
|
||||
if selected == 0 {
|
||||
return;
|
||||
@@ -831,6 +874,7 @@ impl KeymapEditor {
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.show_hover_menus = false;
|
||||
if self.matches.get(0).is_some() {
|
||||
self.selected_index = Some(0);
|
||||
self.scroll_to_item(0, ScrollStrategy::Center, cx);
|
||||
@@ -839,6 +883,7 @@ impl KeymapEditor {
|
||||
}
|
||||
|
||||
fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.show_hover_menus = false;
|
||||
if self.matches.last().is_some() {
|
||||
let index = self.matches.len() - 1;
|
||||
self.selected_index = Some(index);
|
||||
@@ -847,23 +892,38 @@ impl KeymapEditor {
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open_edit_keybinding_modal(false, window, cx);
|
||||
}
|
||||
|
||||
fn open_edit_keybinding_modal(
|
||||
&mut self,
|
||||
create: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((keybind_idx, keybind)) = self
|
||||
.selected_keybind_idx()
|
||||
.zip(self.selected_binding().cloned())
|
||||
else {
|
||||
self.show_hover_menus = false;
|
||||
let Some((keybind, keybind_index)) = self.selected_keybind_and_index() else {
|
||||
return;
|
||||
};
|
||||
let keybind = keybind.clone();
|
||||
let keymap_editor = cx.entity();
|
||||
|
||||
let arguments = keybind
|
||||
.action_arguments
|
||||
.as_ref()
|
||||
.map(|arguments| arguments.text.clone());
|
||||
let context = keybind
|
||||
.context
|
||||
.as_ref()
|
||||
.map(|context| context.local_str().unwrap_or("global"));
|
||||
let source = keybind.source.as_ref().map(|source| source.1.clone());
|
||||
|
||||
telemetry::event!(
|
||||
"Edit Keybinding Modal Opened",
|
||||
keystroke = keybind.keystroke_text,
|
||||
action = keybind.action_name,
|
||||
source = source,
|
||||
context = context,
|
||||
arguments = arguments,
|
||||
);
|
||||
|
||||
self.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
let fs = workspace.app_state().fs.clone();
|
||||
@@ -872,7 +932,7 @@ impl KeymapEditor {
|
||||
let modal = KeybindingEditorModal::new(
|
||||
create,
|
||||
keybind,
|
||||
keybind_idx,
|
||||
keybind_index,
|
||||
keymap_editor,
|
||||
workspace_weak,
|
||||
fs,
|
||||
@@ -899,7 +959,7 @@ impl KeymapEditor {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(fs) = self
|
||||
let std::result::Result::Ok(fs) = self
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| workspace.app_state().fs.clone())
|
||||
else {
|
||||
@@ -929,6 +989,8 @@ impl KeymapEditor {
|
||||
let Some(context) = context else {
|
||||
return;
|
||||
};
|
||||
|
||||
telemetry::event!("Keybinding Context Copied", context = context.clone());
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(context.clone()));
|
||||
}
|
||||
|
||||
@@ -944,6 +1006,8 @@ impl KeymapEditor {
|
||||
let Some(action) = action else {
|
||||
return;
|
||||
};
|
||||
|
||||
telemetry::event!("Keybinding Action Copied", action = action.clone());
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone()));
|
||||
}
|
||||
|
||||
@@ -972,18 +1036,16 @@ impl KeymapEditor {
|
||||
self.search_mode = self.search_mode.invert();
|
||||
self.on_query_changed(cx);
|
||||
|
||||
// Update the keystroke editor to turn the `search` bool on
|
||||
self.keystroke_editor.update(cx, |keystroke_editor, cx| {
|
||||
keystroke_editor
|
||||
.set_search_mode(matches!(self.search_mode, SearchMode::KeyStroke { .. }));
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
match self.search_mode {
|
||||
SearchMode::KeyStroke { .. } => {
|
||||
window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx));
|
||||
}
|
||||
SearchMode::Normal => {}
|
||||
SearchMode::Normal => {
|
||||
self.keystroke_editor.update(cx, |editor, cx| {
|
||||
editor.clear_keystrokes(&ClearKeystrokes, window, cx)
|
||||
});
|
||||
window.focus(&self.filter_editor.focus_handle(cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1087,20 +1149,19 @@ impl Item for KeymapEditor {
|
||||
}
|
||||
|
||||
impl Render for KeymapEditor {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut ui::Context<Self>) -> impl ui::IntoElement {
|
||||
let row_count = self.matches.len();
|
||||
let theme = cx.theme();
|
||||
|
||||
v_flex()
|
||||
.id("keymap-editor")
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(self.dispatch_context(window, cx))
|
||||
.key_context(self.key_context())
|
||||
.on_action(cx.listener(Self::select_next))
|
||||
.on_action(cx.listener(Self::select_previous))
|
||||
.on_action(cx.listener(Self::select_first))
|
||||
.on_action(cx.listener(Self::select_last))
|
||||
.on_action(cx.listener(Self::focus_search))
|
||||
.on_action(cx.listener(Self::confirm))
|
||||
.on_action(cx.listener(Self::edit_binding))
|
||||
.on_action(cx.listener(Self::create_binding))
|
||||
.on_action(cx.listener(Self::delete_binding))
|
||||
@@ -1113,6 +1174,9 @@ impl Render for KeymapEditor {
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.bg(theme.colors().editor_background)
|
||||
.on_mouse_move(cx.listener(|this, _, _window, _cx| {
|
||||
this.show_hover_menus = true;
|
||||
}))
|
||||
.child(
|
||||
v_flex()
|
||||
.p_2()
|
||||
@@ -1214,6 +1278,18 @@ impl Render for KeymapEditor {
|
||||
"keystrokes-exact-match",
|
||||
IconName::Equal,
|
||||
)
|
||||
.tooltip(move |window, cx| {
|
||||
Tooltip::for_action(
|
||||
if exact_match {
|
||||
"Partial match mode"
|
||||
} else {
|
||||
"Exact match mode"
|
||||
},
|
||||
&ToggleExactKeystrokeMatching,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.shape(IconButtonShape::Square)
|
||||
.toggle_state(exact_match)
|
||||
.on_click(
|
||||
@@ -1254,16 +1330,16 @@ impl Render for KeymapEditor {
|
||||
let binding = &this.keybindings[candidate_id];
|
||||
let action_name = binding.action_name;
|
||||
|
||||
let icon = (this.filter_state != FilterState::Conflicts
|
||||
&& this.has_conflict(index))
|
||||
.then(|| {
|
||||
let icon = if this.filter_state != FilterState::Conflicts
|
||||
&& this.has_conflict(index)
|
||||
{
|
||||
base_button_style(index, IconName::Warning)
|
||||
.icon_color(Color::Warning)
|
||||
.tooltip(|window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Edit Keybinding",
|
||||
None,
|
||||
"Use alt+click to show conflicts",
|
||||
"View conflicts",
|
||||
Some(&ToggleConflictFilter),
|
||||
"Use alt+click to show all conflicts",
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -1284,18 +1360,34 @@ impl Render for KeymapEditor {
|
||||
}
|
||||
},
|
||||
))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
.into_any_element()
|
||||
} else {
|
||||
base_button_style(index, IconName::Pencil)
|
||||
.visible_on_hover(row_group_id(index))
|
||||
.tooltip(Tooltip::text("Edit Keybinding"))
|
||||
.visible_on_hover(
|
||||
if this.selected_index == Some(index) {
|
||||
"".into()
|
||||
} else if this.show_hover_menus {
|
||||
row_group_id(index)
|
||||
} else {
|
||||
"never-show".into()
|
||||
},
|
||||
)
|
||||
.when(
|
||||
this.show_hover_menus && !context_menu_deployed,
|
||||
|this| {
|
||||
this.tooltip(Tooltip::for_action_title(
|
||||
"Edit Keybinding",
|
||||
&EditBinding,
|
||||
))
|
||||
},
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.select_index(index, cx);
|
||||
this.open_edit_keybinding_modal(false, window, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
})
|
||||
.into_any_element();
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
let action = div()
|
||||
.id(("keymap action", index))
|
||||
@@ -1313,20 +1405,24 @@ impl Render for KeymapEditor {
|
||||
.into_any_element()
|
||||
}
|
||||
})
|
||||
.when(!context_menu_deployed, |this| {
|
||||
this.tooltip({
|
||||
let action_name = binding.action_name;
|
||||
let action_docs = binding.action_docs;
|
||||
move |_, cx| {
|
||||
let action_tooltip = Tooltip::new(action_name);
|
||||
let action_tooltip = match action_docs {
|
||||
Some(docs) => action_tooltip.meta(docs),
|
||||
None => action_tooltip,
|
||||
};
|
||||
cx.new(|_| action_tooltip).into()
|
||||
}
|
||||
})
|
||||
})
|
||||
.when(
|
||||
!context_menu_deployed && this.show_hover_menus,
|
||||
|this| {
|
||||
this.tooltip({
|
||||
let action_name = binding.action_name;
|
||||
let action_docs = binding.action_docs;
|
||||
move |_, cx| {
|
||||
let action_tooltip =
|
||||
Tooltip::new(action_name);
|
||||
let action_tooltip = match action_docs {
|
||||
Some(docs) => action_tooltip.meta(docs),
|
||||
None => action_tooltip,
|
||||
};
|
||||
cx.new(|_| action_tooltip).into()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into_any_element();
|
||||
let keystrokes = binding.ui_key_binding.clone().map_or(
|
||||
binding.keystroke_text.clone().into_any_element(),
|
||||
@@ -1351,13 +1447,18 @@ impl Render for KeymapEditor {
|
||||
div()
|
||||
.id(("keymap context", index))
|
||||
.child(context.clone())
|
||||
.when(is_local && !context_menu_deployed, |this| {
|
||||
this.tooltip(Tooltip::element({
|
||||
move |_, _| {
|
||||
context.clone().into_any_element()
|
||||
}
|
||||
}))
|
||||
})
|
||||
.when(
|
||||
is_local
|
||||
&& !context_menu_deployed
|
||||
&& this.show_hover_menus,
|
||||
|this| {
|
||||
this.tooltip(Tooltip::element({
|
||||
move |_, _| {
|
||||
context.clone().into_any_element()
|
||||
}
|
||||
}))
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
},
|
||||
);
|
||||
@@ -2222,6 +2323,9 @@ async fn save_keybinding_update(
|
||||
from: Some(target),
|
||||
}
|
||||
};
|
||||
|
||||
let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
|
||||
|
||||
let updated_keymap_contents =
|
||||
settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
|
||||
.context("Failed to update keybinding")?;
|
||||
@@ -2231,6 +2335,13 @@ async fn save_keybinding_update(
|
||||
)
|
||||
.await
|
||||
.context("Failed to write keymap file")?;
|
||||
|
||||
telemetry::event!(
|
||||
"Keybinding Updated",
|
||||
new_keybinding = new_keybinding,
|
||||
removed_keybinding = removed_keybinding,
|
||||
source = source
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2266,6 +2377,7 @@ async fn remove_keybinding(
|
||||
.unwrap_or(KeybindSource::User),
|
||||
};
|
||||
|
||||
let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
|
||||
let updated_keymap_contents =
|
||||
settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
|
||||
.context("Failed to update keybinding")?;
|
||||
@@ -2275,6 +2387,13 @@ async fn remove_keybinding(
|
||||
)
|
||||
.await
|
||||
.context("Failed to write keymap file")?;
|
||||
|
||||
telemetry::event!(
|
||||
"Keybinding Removed",
|
||||
new_keybinding = new_keybinding,
|
||||
removed_keybinding = removed_keybinding,
|
||||
source = source
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2298,7 +2417,6 @@ enum KeyPress<'a> {
|
||||
struct KeystrokeInput {
|
||||
keystrokes: Vec<Keystroke>,
|
||||
placeholder_keystrokes: Option<Vec<Keystroke>>,
|
||||
highlight_on_focus: bool,
|
||||
outer_focus_handle: FocusHandle,
|
||||
inner_focus_handle: FocusHandle,
|
||||
intercept_subscription: Option<Subscription>,
|
||||
@@ -2326,7 +2444,6 @@ impl KeystrokeInput {
|
||||
Self {
|
||||
keystrokes: Vec::new(),
|
||||
placeholder_keystrokes,
|
||||
highlight_on_focus: true,
|
||||
inner_focus_handle,
|
||||
outer_focus_handle,
|
||||
intercept_subscription: None,
|
||||
@@ -2474,6 +2591,8 @@ impl KeystrokeInput {
|
||||
if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX {
|
||||
self.keystrokes.push(Self::dummy(keystroke.modifiers));
|
||||
}
|
||||
} else if close_keystroke_result != CloseKeystrokeResult::Partial {
|
||||
self.clear_keystrokes(&ClearKeystrokes, window, cx);
|
||||
}
|
||||
}
|
||||
self.keystrokes_changed(cx);
|
||||
@@ -2543,10 +2662,6 @@ impl KeystrokeInput {
|
||||
self.inner_focus_handle.clone()
|
||||
}
|
||||
|
||||
fn set_search_mode(&mut self, search: bool) {
|
||||
self.search = search;
|
||||
}
|
||||
|
||||
fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.outer_focus_handle.is_focused(window) {
|
||||
return;
|
||||
@@ -2706,7 +2821,7 @@ impl Render for KeystrokeInput {
|
||||
.track_focus(&self.inner_focus_handle)
|
||||
.on_modifiers_changed(cx.listener(Self::on_modifiers_changed))
|
||||
.size_full()
|
||||
.when(self.highlight_on_focus, |this| {
|
||||
.when(!self.search, |this| {
|
||||
this.focus(|mut style| {
|
||||
style.border_color = Some(colors.border_focused);
|
||||
style
|
||||
|
||||
@@ -40,6 +40,7 @@ rpc.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
settings.workspace = true
|
||||
settings_ui.workspace = true
|
||||
smallvec.workspace = true
|
||||
story = { workspace = true, optional = true }
|
||||
telemetry.workspace = true
|
||||
|
||||
@@ -30,6 +30,7 @@ use onboarding_banner::OnboardingBanner;
|
||||
use project::Project;
|
||||
use rpc::proto;
|
||||
use settings::Settings as _;
|
||||
use settings_ui::keybindings;
|
||||
use std::sync::Arc;
|
||||
use theme::ActiveTheme;
|
||||
use title_bar_settings::TitleBarSettings;
|
||||
@@ -683,7 +684,7 @@ impl TitleBar {
|
||||
)
|
||||
.separator()
|
||||
.action("Settings", zed_actions::OpenSettings.boxed_clone())
|
||||
.action("Key Bindings", Box::new(zed_actions::OpenKeymap))
|
||||
.action("Key Bindings", Box::new(keybindings::OpenKeymapEditor))
|
||||
.action(
|
||||
"Themes…",
|
||||
zed_actions::theme_selector::Toggle::default().boxed_clone(),
|
||||
@@ -727,7 +728,7 @@ impl TitleBar {
|
||||
.menu(|window, cx| {
|
||||
ContextMenu::build(window, cx, |menu, _, _| {
|
||||
menu.action("Settings", zed_actions::OpenSettings.boxed_clone())
|
||||
.action("Key Bindings", Box::new(zed_actions::OpenKeymap))
|
||||
.action("Key Bindings", Box::new(keybindings::OpenKeymapEditor))
|
||||
.action(
|
||||
"Themes…",
|
||||
zed_actions::theme_selector::Toggle::default().boxed_clone(),
|
||||
|
||||
@@ -972,12 +972,10 @@ impl ContextMenu {
|
||||
.children(action.as_ref().and_then(|action| {
|
||||
self.action_context
|
||||
.as_ref()
|
||||
.map(|focus| {
|
||||
.and_then(|focus| {
|
||||
KeyBinding::for_action_in(&**action, focus, window, cx)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
KeyBinding::for_action(&**action, window, cx)
|
||||
})
|
||||
.or_else(|| KeyBinding::for_action(&**action, window, cx))
|
||||
.map(|binding| {
|
||||
div().ml_4().child(binding.disabled(*disabled)).when(
|
||||
*disabled && documentation_aside.is_some(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "The fast, collaborative code editor."
|
||||
edition.workspace = true
|
||||
name = "zed"
|
||||
version = "0.196.0"
|
||||
version = "0.196.3"
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Zed Team <hi@zed.dev>"]
|
||||
|
||||
@@ -1 +1 @@
|
||||
dev
|
||||
preview
|
||||
@@ -1,5 +1,6 @@
|
||||
use collab_ui::collab_panel;
|
||||
use gpui::{Menu, MenuItem, OsAction};
|
||||
use settings_ui::keybindings;
|
||||
use terminal_view::terminal_panel;
|
||||
|
||||
pub fn app_menus() -> Vec<Menu> {
|
||||
@@ -16,7 +17,7 @@ pub fn app_menus() -> Vec<Menu> {
|
||||
name: "Settings".into(),
|
||||
items: vec![
|
||||
MenuItem::action("Open Settings", super::OpenSettings),
|
||||
MenuItem::action("Open Key Bindings", zed_actions::OpenKeymap),
|
||||
MenuItem::action("Open Key Bindings", keybindings::OpenKeymapEditor),
|
||||
MenuItem::action("Open Default Settings", super::OpenDefaultSettings),
|
||||
MenuItem::action(
|
||||
"Open Default Key Bindings",
|
||||
|
||||
Reference in New Issue
Block a user