Compare commits
17 Commits
collab-ext
...
cnext-cpre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee1916965 | ||
|
|
da0960bab6 | ||
|
|
81519ae923 | ||
|
|
5f054e8d9c | ||
|
|
37e4f7e9b5 | ||
|
|
5f451c89e0 | ||
|
|
0362e301f7 | ||
|
|
37bd27b2a8 | ||
|
|
775548e93c | ||
|
|
90d7ccfd5d | ||
|
|
68295ba371 | ||
|
|
5152fd898e | ||
|
|
4e482288cb | ||
|
|
30deb22ab7 | ||
|
|
27c8e84535 | ||
|
|
8aa19a11ac | ||
|
|
4e705e5b5d |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -37,6 +37,7 @@ dependencies = [
|
||||
"terminal",
|
||||
"ui",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"util",
|
||||
"uuid",
|
||||
"watch",
|
||||
@@ -4809,6 +4810,7 @@ dependencies = [
|
||||
"log",
|
||||
"lsp",
|
||||
"markdown",
|
||||
"multi_buffer",
|
||||
"pretty_assertions",
|
||||
"project",
|
||||
"rand 0.9.2",
|
||||
|
||||
@@ -46,6 +46,7 @@ url.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
watch.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger.workspace = true
|
||||
|
||||
@@ -4,12 +4,14 @@ use file_icons::FileIcons;
|
||||
use prompt_store::{PromptId, UserPromptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
fmt,
|
||||
ops::RangeInclusive,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use ui::{App, IconName, SharedString};
|
||||
use url::Url;
|
||||
use urlencoding::decode;
|
||||
use util::paths::PathStyle;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
@@ -74,11 +76,13 @@ impl MentionUri {
|
||||
let path = url.path();
|
||||
match url.scheme() {
|
||||
"file" => {
|
||||
let path = if path_style.is_windows() {
|
||||
let normalized = if path_style.is_windows() {
|
||||
path.trim_start_matches("/")
|
||||
} else {
|
||||
path
|
||||
};
|
||||
let decoded = decode(normalized).unwrap_or(Cow::Borrowed(normalized));
|
||||
let path = decoded.as_ref();
|
||||
|
||||
if let Some(fragment) = url.fragment() {
|
||||
let line_range = parse_line_range(fragment)?;
|
||||
@@ -406,6 +410,19 @@ mod tests {
|
||||
assert_eq!(parsed.to_uri().to_string(), selection_uri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_file_uri_with_non_ascii() {
|
||||
let file_uri = uri!("file:///path/to/%E6%97%A5%E6%9C%AC%E8%AA%9E.txt");
|
||||
let parsed = MentionUri::parse(file_uri, PathStyle::local()).unwrap();
|
||||
match &parsed {
|
||||
MentionUri::File { abs_path } => {
|
||||
assert_eq!(abs_path, Path::new(path!("/path/to/日本語.txt")));
|
||||
}
|
||||
_ => panic!("Expected File variant"),
|
||||
}
|
||||
assert_eq!(parsed.to_uri().to_string(), file_uri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_untitled_selection_uri() {
|
||||
let selection_uri = uri!("zed:///agent/untitled-buffer#L1:10");
|
||||
|
||||
@@ -12,14 +12,11 @@ use gpui::{
|
||||
};
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use ui::{
|
||||
DocumentationAside, DocumentationEdge, DocumentationSide, IntoElement, KeyBinding, ListItem,
|
||||
ListItemSpacing, prelude::*,
|
||||
};
|
||||
use ui::{DocumentationAside, DocumentationEdge, DocumentationSide, prelude::*};
|
||||
use util::ResultExt;
|
||||
use zed_actions::agent::OpenSettings;
|
||||
|
||||
use crate::ui::HoldForDefault;
|
||||
use crate::ui::{HoldForDefault, ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
|
||||
|
||||
pub type AcpModelSelector = Picker<AcpModelPickerDelegate>;
|
||||
|
||||
@@ -236,39 +233,19 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
is_focused: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered_entries.get(ix)? {
|
||||
AcpModelPickerEntry::Separator(title) => Some(
|
||||
div()
|
||||
.px_2()
|
||||
.pb_1()
|
||||
.when(ix > 1, |this| {
|
||||
this.mt_1()
|
||||
.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.child(
|
||||
Label::new(title)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
AcpModelPickerEntry::Separator(title) => {
|
||||
Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
|
||||
}
|
||||
AcpModelPickerEntry::Model(model_info) => {
|
||||
let is_selected = Some(model_info) == self.selected_model.as_ref();
|
||||
let default_model = self.agent_server.default_model(cx);
|
||||
let is_default = default_model.as_ref() == Some(&model_info.id);
|
||||
|
||||
let model_icon_color = if is_selected {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
Some(
|
||||
div()
|
||||
.id(("model-picker-menu-child", ix))
|
||||
@@ -284,30 +261,10 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
}))
|
||||
})
|
||||
.child(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.when_some(model_info.icon, |this, icon| {
|
||||
this.child(
|
||||
Icon::new(icon)
|
||||
.color(model_icon_color)
|
||||
.size(IconSize::Small)
|
||||
)
|
||||
})
|
||||
.child(Label::new(model_info.name.clone()).truncate()),
|
||||
)
|
||||
.end_slot(div().pr_3().when(is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::Check)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
})),
|
||||
ModelSelectorListItem::new(ix, model_info.name.clone())
|
||||
.is_focused(is_focused)
|
||||
.is_selected(is_selected)
|
||||
.when_some(model_info.icon, |this, icon| this.icon(icon)),
|
||||
)
|
||||
.into_any_element()
|
||||
)
|
||||
@@ -343,7 +300,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
fn render_footer(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
@@ -351,26 +308,7 @@ impl PickerDelegate for AcpModelPickerDelegate {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.p_1p5()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
Button::new("configure", "Configure")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(OpenSettings.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.into_any(),
|
||||
)
|
||||
Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,9 +341,7 @@ async fn fuzzy_search(
|
||||
let candidates = model_list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, model)| {
|
||||
StringMatchCandidate::new(ix, &format!("{}/{}", model.id, model.name))
|
||||
})
|
||||
.map(|(ix, model)| StringMatchCandidate::new(ix, model.name.as_ref()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut matches = match_strings(
|
||||
&candidates,
|
||||
|
||||
@@ -63,10 +63,7 @@ use crate::acp::message_editor::{MessageEditor, MessageEditorEvent};
|
||||
use crate::agent_diff::AgentDiff;
|
||||
use crate::profile_selector::{ProfileProvider, ProfileSelector};
|
||||
|
||||
use crate::ui::{
|
||||
AgentNotification, AgentNotificationEvent, BurnModeTooltip, UnavailableEditingTooltip,
|
||||
UsageCallout,
|
||||
};
|
||||
use crate::ui::{AgentNotification, AgentNotificationEvent, BurnModeTooltip, UsageCallout};
|
||||
use crate::{
|
||||
AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, ContinueThread, ContinueWithBurnMode,
|
||||
CycleModeSelector, ExpandMessageEditor, Follow, KeepAll, NewThread, OpenAgentDiff, OpenHistory,
|
||||
@@ -693,7 +690,7 @@ impl AcpThreadView {
|
||||
this.new_server_version_available = Some(new_version.into());
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2091,10 +2088,23 @@ impl AcpThreadView {
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.style(ButtonStyle::Transparent)
|
||||
.tooltip(move |_window, cx| {
|
||||
cx.new(|_| UnavailableEditingTooltip::new(agent_name.clone()))
|
||||
.into()
|
||||
})
|
||||
.tooltip(Tooltip::element({
|
||||
move |_, _| {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(Label::new("Unavailable Editing")).child(
|
||||
div().max_w_64().child(
|
||||
Label::new(format!(
|
||||
"Editing previous messages is not available for {} yet.",
|
||||
agent_name.clone()
|
||||
))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -4208,7 +4218,11 @@ impl AcpThreadView {
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &CycleModeSelector, window, cx| {
|
||||
if let Some(mode_selector) = this.mode_selector() {
|
||||
if let Some(profile_selector) = this.profile_selector.as_ref() {
|
||||
profile_selector.update(cx, |profile_selector, cx| {
|
||||
profile_selector.cycle_profile(cx);
|
||||
});
|
||||
} else if let Some(mode_selector) = this.mode_selector() {
|
||||
mode_selector.update(cx, |mode_selector, cx| {
|
||||
mode_selector.cycle_mode(window, cx);
|
||||
});
|
||||
@@ -4859,6 +4873,32 @@ impl AcpThreadView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(thread) = self.thread() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let entries = thread.read(cx).entries();
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the most recent user message and scroll it to the top of the viewport.
|
||||
// (Fallback: if no user message exists, scroll to the bottom.)
|
||||
if let Some(ix) = entries
|
||||
.iter()
|
||||
.rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)))
|
||||
{
|
||||
self.list_state.scroll_to(ListOffset {
|
||||
item_ix: ix,
|
||||
offset_in_item: px(0.0),
|
||||
});
|
||||
cx.notify();
|
||||
} else {
|
||||
self.scroll_to_bottom(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(thread) = self.thread() {
|
||||
let entry_count = thread.read(cx).entries().len();
|
||||
@@ -5077,6 +5117,16 @@ impl AcpThreadView {
|
||||
}
|
||||
}));
|
||||
|
||||
let scroll_to_recent_user_prompt =
|
||||
IconButton::new("scroll_to_recent_user_prompt", IconName::ForwardArrow)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Ignored)
|
||||
.tooltip(Tooltip::text("Scroll To Most Recent User Prompt"))
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.scroll_to_most_recent_user_prompt(cx);
|
||||
}));
|
||||
|
||||
let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
@@ -5153,6 +5203,7 @@ impl AcpThreadView {
|
||||
|
||||
container
|
||||
.child(open_as_markdown)
|
||||
.child(scroll_to_recent_user_prompt)
|
||||
.child(scroll_to_top)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -6785,6 +6836,70 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_scroll_to_most_recent_user_prompt(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let connection = StubAgentConnection::new();
|
||||
|
||||
// Each user prompt will result in a user message entry plus an agent message entry.
|
||||
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("Response 1".into()),
|
||||
)]);
|
||||
|
||||
let (thread_view, cx) =
|
||||
setup_thread_view(StubAgentServer::new(connection.clone()), cx).await;
|
||||
|
||||
let thread = thread_view
|
||||
.read_with(cx, |view, _| view.thread().cloned())
|
||||
.unwrap();
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Prompt 1", cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("Response 2".into()),
|
||||
)]);
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Prompt 2", cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Move somewhere else first so we're not trivially already on the last user prompt.
|
||||
thread_view.update(cx, |view, cx| {
|
||||
view.scroll_to_top(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
thread_view.update(cx, |view, cx| {
|
||||
view.scroll_to_most_recent_user_prompt(cx);
|
||||
let scroll_top = view.list_state.logical_scroll_top();
|
||||
// Entries layout is: [User1, Assistant1, User2, Assistant2]
|
||||
assert_eq!(scroll_top.item_ix, 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_scroll_to_most_recent_user_prompt_falls_back_to_bottom_without_user_messages(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
let (thread_view, cx) = setup_thread_view(StubAgentServer::default_response(), cx).await;
|
||||
|
||||
// With no entries, scrolling should be a no-op and must not panic.
|
||||
thread_view.update(cx, |view, cx| {
|
||||
view.scroll_to_most_recent_user_prompt(cx);
|
||||
let scroll_top = view.list_state.logical_scroll_top();
|
||||
assert_eq!(scroll_top.item_ix, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_message_editing_cancel(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
@@ -8,6 +8,7 @@ use editor::Editor;
|
||||
use fs::Fs;
|
||||
use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, prelude::*};
|
||||
use language_model::{LanguageModel, LanguageModelRegistry};
|
||||
use settings::SettingsStore;
|
||||
use settings::{
|
||||
LanguageModelProviderSetting, LanguageModelSelection, Settings as _, update_settings_file,
|
||||
};
|
||||
@@ -94,6 +95,7 @@ pub struct ViewProfileMode {
|
||||
configure_default_model: NavigableEntry,
|
||||
configure_tools: NavigableEntry,
|
||||
configure_mcps: NavigableEntry,
|
||||
delete_profile: NavigableEntry,
|
||||
cancel_item: NavigableEntry,
|
||||
}
|
||||
|
||||
@@ -109,6 +111,7 @@ pub struct ManageProfilesModal {
|
||||
active_model: Option<Arc<dyn LanguageModel>>,
|
||||
focus_handle: FocusHandle,
|
||||
mode: Mode,
|
||||
_settings_subscription: Subscription,
|
||||
}
|
||||
|
||||
impl ManageProfilesModal {
|
||||
@@ -148,12 +151,23 @@ impl ManageProfilesModal {
|
||||
) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
|
||||
// Keep this modal in sync with settings changes (including profile deletion).
|
||||
let settings_subscription =
|
||||
cx.observe_global_in::<SettingsStore>(window, |this, window, cx| {
|
||||
if matches!(this.mode, Mode::ChooseProfile(_)) {
|
||||
this.mode = Mode::choose_profile(window, cx);
|
||||
this.focus_handle(cx).focus(window);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
fs,
|
||||
active_model,
|
||||
context_server_registry,
|
||||
focus_handle,
|
||||
mode: Mode::choose_profile(window, cx),
|
||||
_settings_subscription: settings_subscription,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +206,7 @@ impl ManageProfilesModal {
|
||||
configure_default_model: NavigableEntry::focusable(cx),
|
||||
configure_tools: NavigableEntry::focusable(cx),
|
||||
configure_mcps: NavigableEntry::focusable(cx),
|
||||
delete_profile: NavigableEntry::focusable(cx),
|
||||
cancel_item: NavigableEntry::focusable(cx),
|
||||
});
|
||||
self.focus_handle(cx).focus(window);
|
||||
@@ -369,6 +384,42 @@ impl ManageProfilesModal {
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_profile(
|
||||
&mut self,
|
||||
profile_id: AgentProfileId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if builtin_profiles::is_builtin(&profile_id) {
|
||||
self.view_profile(profile_id, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let fs = self.fs.clone();
|
||||
|
||||
update_settings_file(fs, cx, move |settings, _cx| {
|
||||
let Some(agent_settings) = settings.agent.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(profiles) = agent_settings.profiles.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
profiles.shift_remove(profile_id.0.as_ref());
|
||||
|
||||
if agent_settings
|
||||
.default_profile
|
||||
.as_deref()
|
||||
.is_some_and(|default_profile| default_profile == profile_id.0.as_ref())
|
||||
{
|
||||
agent_settings.default_profile = Some(AgentProfileId::default().0);
|
||||
}
|
||||
});
|
||||
|
||||
self.choose_profile(window, cx);
|
||||
}
|
||||
|
||||
fn cancel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match &self.mode {
|
||||
Mode::ChooseProfile { .. } => {
|
||||
@@ -756,6 +807,40 @@ impl ManageProfilesModal {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id("delete-profile")
|
||||
.track_focus(&mode.delete_profile.focus_handle)
|
||||
.on_action({
|
||||
let profile_id = mode.profile_id.clone();
|
||||
cx.listener(move |this, _: &menu::Confirm, window, cx| {
|
||||
this.delete_profile(profile_id.clone(), window, cx);
|
||||
})
|
||||
})
|
||||
.child(
|
||||
ListItem::new("delete-profile")
|
||||
.toggle_state(
|
||||
mode.delete_profile
|
||||
.focus_handle
|
||||
.contains_focused(window, cx),
|
||||
)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.start_slot(
|
||||
Icon::new(IconName::Trash)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
)
|
||||
.child(Label::new("Delete Profile").color(Color::Error))
|
||||
.disabled(builtin_profiles::is_builtin(&mode.profile_id))
|
||||
.on_click({
|
||||
let profile_id = mode.profile_id.clone();
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.delete_profile(profile_id.clone(), window, cx);
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(ListSeparator)
|
||||
.child(
|
||||
div()
|
||||
@@ -805,6 +890,7 @@ impl ManageProfilesModal {
|
||||
.entry(mode.configure_default_model)
|
||||
.entry(mode.configure_tools)
|
||||
.entry(mode.configure_mcps)
|
||||
.entry(mode.delete_profile)
|
||||
.entry(mode.cancel_item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ use language_model::{
|
||||
};
|
||||
use ordered_float::OrderedFloat;
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
|
||||
use ui::prelude::*;
|
||||
use zed_actions::agent::OpenSettings;
|
||||
|
||||
use crate::ui::{ModelSelectorFooter, ModelSelectorHeader, ModelSelectorListItem};
|
||||
|
||||
type OnModelChanged = Arc<dyn Fn(Arc<dyn LanguageModel>, &mut App) + 'static>;
|
||||
type GetActiveModel = Arc<dyn Fn(&App) -> Option<ConfiguredModel> + 'static>;
|
||||
|
||||
@@ -459,28 +461,14 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
fn render_match(
|
||||
&self,
|
||||
ix: usize,
|
||||
selected: bool,
|
||||
is_focused: bool,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<Self::ListItem> {
|
||||
match self.filtered_entries.get(ix)? {
|
||||
LanguageModelPickerEntry::Separator(title) => Some(
|
||||
div()
|
||||
.px_2()
|
||||
.pb_1()
|
||||
.when(ix > 1, |this| {
|
||||
this.mt_1()
|
||||
.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.child(
|
||||
Label::new(title)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.into_any_element(),
|
||||
),
|
||||
LanguageModelPickerEntry::Separator(title) => {
|
||||
Some(ModelSelectorHeader::new(title, ix > 1).into_any_element())
|
||||
}
|
||||
LanguageModelPickerEntry::Model(model_info) => {
|
||||
let active_model = (self.get_active_model)(cx);
|
||||
let active_provider_id = active_model.as_ref().map(|m| m.provider.id());
|
||||
@@ -489,35 +477,11 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
let is_selected = Some(model_info.model.provider_id()) == active_provider_id
|
||||
&& Some(model_info.model.id()) == active_model_id;
|
||||
|
||||
let model_icon_color = if is_selected {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
Some(
|
||||
ListItem::new(ix)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(model_info.icon)
|
||||
.color(model_icon_color)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
.child(Label::new(model_info.model.name().0).truncate()),
|
||||
)
|
||||
.end_slot(div().pr_3().when(is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::Check)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
}))
|
||||
ModelSelectorListItem::new(ix, model_info.model.name().0)
|
||||
.is_focused(is_focused)
|
||||
.is_selected(is_selected)
|
||||
.icon(model_info.icon)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
@@ -527,34 +491,15 @@ impl PickerDelegate for LanguageModelPickerDelegate {
|
||||
fn render_footer(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
_cx: &mut Context<Picker<Self>>,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
if !self.popover_styles {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.p_1p5()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
Button::new("configure", "Configure")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(&OpenSettings, &focus_handle, cx)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(OpenSettings.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.into_any(),
|
||||
)
|
||||
let focus_handle = self.focus_handle.clone();
|
||||
|
||||
Some(ModelSelectorFooter::new(OpenSettings.boxed_clone(), focus_handle).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{ManageProfiles, ToggleProfileSelector};
|
||||
use crate::{CycleModeSelector, ManageProfiles, ToggleProfileSelector};
|
||||
use agent_settings::{
|
||||
AgentProfile, AgentProfileId, AgentSettings, AvailableProfiles, builtin_profiles,
|
||||
};
|
||||
@@ -70,6 +70,29 @@ impl ProfileSelector {
|
||||
self.picker_handle.clone()
|
||||
}
|
||||
|
||||
pub fn cycle_profile(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.provider.profiles_supported(cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let profiles = AgentProfile::available_profiles(cx);
|
||||
if profiles.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_profile_id = self.provider.profile_id(cx);
|
||||
let current_index = profiles
|
||||
.keys()
|
||||
.position(|id| id == ¤t_profile_id)
|
||||
.unwrap_or(0);
|
||||
|
||||
let next_index = (current_index + 1) % profiles.len();
|
||||
|
||||
if let Some((next_profile_id, _)) = profiles.get_index(next_index) {
|
||||
self.provider.set_profile(next_profile_id.clone(), cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_picker(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
@@ -163,14 +186,29 @@ impl Render for ProfileSelector {
|
||||
PickerPopoverMenu::new(
|
||||
picker,
|
||||
trigger_button,
|
||||
move |_window, cx| {
|
||||
Tooltip::for_action_in(
|
||||
"Toggle Profile Menu",
|
||||
&ToggleProfileSelector,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)
|
||||
},
|
||||
Tooltip::element({
|
||||
move |_window, cx| {
|
||||
let container = || h_flex().gap_1().justify_between();
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
container()
|
||||
.pb_1()
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(Label::new("Cycle Through Profiles"))
|
||||
.child(KeyBinding::for_action_in(
|
||||
&CycleModeSelector,
|
||||
&focus_handle,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(container().child(Label::new("Toggle Profile Menu")).child(
|
||||
KeyBinding::for_action_in(&ToggleProfileSelector, &focus_handle, cx),
|
||||
))
|
||||
.into_any()
|
||||
}
|
||||
}),
|
||||
gpui::Corner::BottomRight,
|
||||
cx,
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ mod burn_mode_tooltip;
|
||||
mod claude_code_onboarding_modal;
|
||||
mod end_trial_upsell;
|
||||
mod hold_for_default;
|
||||
mod model_selector_components;
|
||||
mod onboarding_modal;
|
||||
mod unavailable_editing_tooltip;
|
||||
mod usage_callout;
|
||||
|
||||
pub use acp_onboarding_modal::*;
|
||||
@@ -14,6 +14,6 @@ pub use burn_mode_tooltip::*;
|
||||
pub use claude_code_onboarding_modal::*;
|
||||
pub use end_trial_upsell::*;
|
||||
pub use hold_for_default::*;
|
||||
pub use model_selector_components::*;
|
||||
pub use onboarding_modal::*;
|
||||
pub use unavailable_editing_tooltip::*;
|
||||
pub use usage_callout::*;
|
||||
|
||||
147
crates/agent_ui/src/ui/model_selector_components.rs
Normal file
147
crates/agent_ui/src/ui/model_selector_components.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use gpui::{Action, FocusHandle, prelude::*};
|
||||
use ui::{KeyBinding, ListItem, ListItemSpacing, prelude::*};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorHeader {
|
||||
title: SharedString,
|
||||
has_border: bool,
|
||||
}
|
||||
|
||||
impl ModelSelectorHeader {
|
||||
pub fn new(title: impl Into<SharedString>, has_border: bool) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
has_border,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorHeader {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.px_2()
|
||||
.pb_1()
|
||||
.when(self.has_border, |this| {
|
||||
this.mt_1()
|
||||
.pt_2()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
})
|
||||
.child(
|
||||
Label::new(self.title)
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorListItem {
|
||||
index: usize,
|
||||
title: SharedString,
|
||||
icon: Option<IconName>,
|
||||
is_selected: bool,
|
||||
is_focused: bool,
|
||||
}
|
||||
|
||||
impl ModelSelectorListItem {
|
||||
pub fn new(index: usize, title: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
index,
|
||||
title: title.into(),
|
||||
icon: None,
|
||||
is_selected: false,
|
||||
is_focused: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_selected(mut self, is_selected: bool) -> Self {
|
||||
self.is_selected = is_selected;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_focused(mut self, is_focused: bool) -> Self {
|
||||
self.is_focused = is_focused;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorListItem {
|
||||
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let model_icon_color = if self.is_selected {
|
||||
Color::Accent
|
||||
} else {
|
||||
Color::Muted
|
||||
};
|
||||
|
||||
ListItem::new(self.index)
|
||||
.inset(true)
|
||||
.spacing(ListItemSpacing::Sparse)
|
||||
.toggle_state(self.is_focused)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.when_some(self.icon, |this, icon| {
|
||||
this.child(
|
||||
Icon::new(icon)
|
||||
.color(model_icon_color)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
})
|
||||
.child(Label::new(self.title).truncate()),
|
||||
)
|
||||
.end_slot(div().pr_2().when(self.is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::Check)
|
||||
.color(Color::Accent)
|
||||
.size(IconSize::Small),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct ModelSelectorFooter {
|
||||
action: Box<dyn Action>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl ModelSelectorFooter {
|
||||
pub fn new(action: Box<dyn Action>, focus_handle: FocusHandle) -> Self {
|
||||
Self {
|
||||
action,
|
||||
focus_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ModelSelectorFooter {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let action = self.action;
|
||||
let focus_handle = self.focus_handle;
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.p_1p5()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().colors().border_variant)
|
||||
.child(
|
||||
Button::new("configure", "Configure")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.key_binding(
|
||||
KeyBinding::for_action_in(action.as_ref(), &focus_handle, cx)
|
||||
.map(|kb| kb.size(rems_from_px(12.))),
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(action.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use gpui::{Context, IntoElement, Render, Window};
|
||||
use ui::{prelude::*, tooltip_container};
|
||||
|
||||
pub struct UnavailableEditingTooltip {
|
||||
agent_name: SharedString,
|
||||
}
|
||||
|
||||
impl UnavailableEditingTooltip {
|
||||
pub fn new(agent_name: SharedString) -> Self {
|
||||
Self { agent_name }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for UnavailableEditingTooltip {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
tooltip_container(cx, |this, _| {
|
||||
this.child(Label::new("Unavailable Editing")).child(
|
||||
div().max_w_64().child(
|
||||
Label::new(format!(
|
||||
"Editing previous messages is not available for {} yet.",
|
||||
self.agent_name
|
||||
))
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,9 @@ struct CopilotSweAgentBot;
|
||||
impl CopilotSweAgentBot {
|
||||
const LOGIN: &'static str = "copilot-swe-agent[bot]";
|
||||
const USER_ID: i32 = 198982749;
|
||||
/// The alias of the GitHub copilot user. Although https://api.github.com/users/copilot
|
||||
/// yields a 404, GitHub still refers to the copilot bot user as @Copilot in some cases.
|
||||
const NAME_ALIAS: &'static str = "copilot";
|
||||
|
||||
/// Returns the `created_at` timestamp for the Dependabot bot user.
|
||||
fn created_at() -> &'static NaiveDateTime {
|
||||
@@ -125,7 +128,9 @@ impl CopilotSweAgentBot {
|
||||
/// Returns whether the given contributor selector corresponds to the Copilot bot user.
|
||||
fn is_copilot_bot(contributor: &ContributorSelector) -> bool {
|
||||
match contributor {
|
||||
ContributorSelector::GitHubLogin { github_login } => github_login == Self::LOGIN,
|
||||
ContributorSelector::GitHubLogin { github_login } => {
|
||||
github_login == Self::LOGIN || github_login == Self::NAME_ALIAS
|
||||
}
|
||||
ContributorSelector::GitHubUserId { github_user_id } => {
|
||||
github_user_id == &Self::USER_ID
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ language.workspace = true
|
||||
log.workspace = true
|
||||
lsp.workspace = true
|
||||
markdown.workspace = true
|
||||
multi_buffer.workspace = true
|
||||
project.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
use anyhow::Result;
|
||||
use collections::HashMap;
|
||||
use editor::{
|
||||
Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
|
||||
Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey,
|
||||
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
|
||||
multibuffer_context_lines,
|
||||
};
|
||||
@@ -701,8 +701,12 @@ impl Item for BufferDiagnosticsEditor {
|
||||
});
|
||||
}
|
||||
|
||||
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
|
||||
if EditorSettings::get_global(cx).toolbar.breadcrumbs {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
} else {
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
|
||||
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
|
||||
|
||||
@@ -12,7 +12,7 @@ use buffer_diagnostics::BufferDiagnosticsEditor;
|
||||
use collections::{BTreeSet, HashMap, HashSet};
|
||||
use diagnostic_renderer::DiagnosticBlock;
|
||||
use editor::{
|
||||
Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
|
||||
Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey,
|
||||
display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
|
||||
multibuffer_context_lines,
|
||||
};
|
||||
@@ -336,6 +336,33 @@ impl ProjectDiagnosticsEditor {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_multibuffer_initial_ranges(&mut self, cx: &mut App) {
|
||||
let snapshot = self.multibuffer.read(cx).snapshot(cx);
|
||||
|
||||
let len = self.diagnostics.values().map(|v| v.len()).sum();
|
||||
let mut ranges: Vec<Range<multi_buffer::Anchor>> = Vec::with_capacity(len);
|
||||
|
||||
for (buffer_id, diagnostics) in &self.diagnostics {
|
||||
let Some(excerpt_id) = snapshot.excerpt_id_for_buffer_id(*buffer_id) else {
|
||||
#[cfg(debug_assertions)]
|
||||
panic!("no excerpt_id for buffer_id: {buffer_id}");
|
||||
#[cfg(not(debug_assertions))]
|
||||
continue;
|
||||
};
|
||||
|
||||
for diagnostic in diagnostics {
|
||||
let opt = snapshot.anchor_range_in_excerpt(excerpt_id, diagnostic.range.clone());
|
||||
if let Some(range) = opt {
|
||||
ranges.push(range);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
editor.set_initial_multibuffer_matches(ranges);
|
||||
});
|
||||
}
|
||||
|
||||
fn update_stale_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.update_excerpts_task.is_some() {
|
||||
return;
|
||||
@@ -375,6 +402,10 @@ impl ProjectDiagnosticsEditor {
|
||||
this.update_excerpts(buffer, retain_excerpts, window, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.update_multibuffer_initial_ranges(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -464,6 +495,7 @@ impl ProjectDiagnosticsEditor {
|
||||
});
|
||||
|
||||
self.update_stale_excerpts(window, cx);
|
||||
self.update_multibuffer_initial_ranges(cx);
|
||||
}
|
||||
|
||||
fn diagnostics_are_unchanged(
|
||||
@@ -894,8 +926,12 @@ impl Item for ProjectDiagnosticsEditor {
|
||||
Some(Box::new(self.editor.clone()))
|
||||
}
|
||||
|
||||
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
|
||||
if EditorSettings::get_global(cx).toolbar.breadcrumbs {
|
||||
ToolbarItemLocation::PrimaryLeft
|
||||
} else {
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
|
||||
fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
|
||||
|
||||
@@ -717,6 +717,10 @@ actions!(
|
||||
SelectNextSyntaxNode,
|
||||
/// Selects the previous syntax node sibling.
|
||||
SelectPreviousSyntaxNode,
|
||||
/// Select the next match in the current multibuffer.
|
||||
SelectNextMultibufferMatch,
|
||||
/// Select the previous match in the current multibuffer.
|
||||
SelectPreviousMultibufferMatch,
|
||||
/// Extends selection left.
|
||||
SelectLeft,
|
||||
/// Selects the current line.
|
||||
|
||||
@@ -1218,6 +1218,10 @@ pub struct Editor {
|
||||
accent_data: Option<AccentData>,
|
||||
fetched_tree_sitter_chunks: HashMap<ExcerptId, HashSet<Range<BufferRow>>>,
|
||||
use_base_text_line_numbers: bool,
|
||||
/// Matches used to create the multibuffer (e.g. LSP references, project search matches).
|
||||
/// Ranges are always sorted by start anchor. Invalid anchors are removed on the next call to
|
||||
/// [`SelectNextMultibufferMatch`] (or prev.)
|
||||
initial_multibuffer_matches: Vec<Range<multi_buffer::Anchor>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -2420,6 +2424,7 @@ impl Editor {
|
||||
accent_data: None,
|
||||
fetched_tree_sitter_chunks: HashMap::default(),
|
||||
use_base_text_line_numbers: false,
|
||||
initial_multibuffer_matches: Vec::default(),
|
||||
};
|
||||
|
||||
if is_minimap {
|
||||
@@ -16315,6 +16320,103 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_next_multibuffer_match(
|
||||
&mut self,
|
||||
_: &SelectNextMultibufferMatch,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.select_multibuffer_match(Bias::Right, window, cx);
|
||||
}
|
||||
|
||||
pub fn select_prev_multibuffer_match(
|
||||
&mut self,
|
||||
_: &SelectPreviousMultibufferMatch,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.select_multibuffer_match(Bias::Left, window, cx);
|
||||
}
|
||||
|
||||
pub fn set_initial_multibuffer_matches(&mut self, ranges: Vec<Range<Anchor>>) {
|
||||
self.initial_multibuffer_matches = ranges;
|
||||
}
|
||||
|
||||
/// Multibuffers can be created with initial "matches" (e.g. LSP references, project search
|
||||
/// matches, etc.). We populate a list when the multibuffer is created. During editing, matches
|
||||
/// can be invalidated if the anchors are deleted
|
||||
fn select_multibuffer_match(
|
||||
&mut self,
|
||||
direction: Bias,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let len_before = self.initial_multibuffer_matches.len();
|
||||
let snapshot = self.buffer.read(cx).snapshot(cx);
|
||||
// todo! doing a linear scan every time might be slow?
|
||||
self.initial_multibuffer_matches.retain(|range| {
|
||||
// todo! is this right? what if a match is half-invalidated? can we truncate the match?
|
||||
range.start.is_valid(&snapshot) && range.end.is_valid(&snapshot)
|
||||
});
|
||||
let len_after = self.initial_multibuffer_matches.len();
|
||||
dbg!(len_before, len_after);
|
||||
|
||||
match &self.initial_multibuffer_matches[..] {
|
||||
[] => return,
|
||||
// if there is only one match, we select it regardless of direction
|
||||
[only] => {
|
||||
let only = only.clone();
|
||||
self.change_selections(SelectionEffects::default(), window, cx, |selections| {
|
||||
selections.clear_disjoint();
|
||||
selections.insert_range(only.start..only.start);
|
||||
});
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
debug_assert!(self.initial_multibuffer_matches.len() >= 2);
|
||||
|
||||
dbg!(
|
||||
self.initial_multibuffer_matches
|
||||
.iter()
|
||||
.map(|range| format!(
|
||||
"{:?}..{:?}",
|
||||
range.start.to_point(&snapshot),
|
||||
range.end.to_point(&snapshot)
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let display_snapshot = self.display_snapshot(cx);
|
||||
let selection = self.selections.last::<MultiBufferOffset>(&display_snapshot);
|
||||
|
||||
let search = self
|
||||
.initial_multibuffer_matches
|
||||
.binary_search_by(|range| range.start.to_offset(&snapshot).cmp(&selection.start));
|
||||
|
||||
dbg!(self.initial_multibuffer_matches.len(), search, direction);
|
||||
|
||||
let target_index = match search {
|
||||
Ok(i) => match (i, direction) {
|
||||
(0, Bias::Left) => self.initial_multibuffer_matches.len() - 1,
|
||||
(i, Bias::Left) => i - 1,
|
||||
(i, Bias::Right) => (i + 1) % self.initial_multibuffer_matches.len(),
|
||||
},
|
||||
Err(i) => match (i, direction) {
|
||||
(0, Bias::Left) => self.initial_multibuffer_matches.len() - 1,
|
||||
(i, Bias::Left) => i - 1,
|
||||
(i, Bias::Right) => i % self.initial_multibuffer_matches.len(),
|
||||
},
|
||||
};
|
||||
|
||||
let target = self.initial_multibuffer_matches[target_index].clone();
|
||||
self.change_selections(SelectionEffects::default(), window, cx, |selections| {
|
||||
selections.clear_disjoint();
|
||||
selections.insert_range(target.start..target.start);
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
|
||||
if !EditorSettings::get_global(cx).gutter.runnables {
|
||||
self.clear_tasks();
|
||||
@@ -17709,9 +17811,6 @@ impl Editor {
|
||||
}
|
||||
};
|
||||
|
||||
// TODO(cameron): is this needed?
|
||||
// the thinking is to avoid "jumping to the current location" (avoid
|
||||
// polluting "jumplist" in vim terms)
|
||||
if current_location_index == destination_location_index {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -17966,6 +18065,7 @@ impl Editor {
|
||||
cx,
|
||||
);
|
||||
editor.lookup_key = Some(Box::new(key));
|
||||
editor.set_initial_multibuffer_matches(ranges.clone());
|
||||
editor
|
||||
})
|
||||
});
|
||||
|
||||
@@ -29335,3 +29335,59 @@ async fn test_find_references_single_case(cx: &mut TestAppContext) {
|
||||
|
||||
cx.assert_editor_state(after);
|
||||
}
|
||||
|
||||
// #[gpui::test]
|
||||
// async fn next_multibuffer_match_lsp_references(cx: &mut TestAppContext) {
|
||||
// init_test(cx, |_| {});
|
||||
// let mut cx = EditorLspTestContext::new_rust(
|
||||
// lsp::ServerCapabilities {
|
||||
// references_provider: Some(lsp::OneOf::Left(true)),
|
||||
// ..lsp::ServerCapabilities::default()
|
||||
// },
|
||||
// cx,
|
||||
// )
|
||||
// .await;
|
||||
// cx.lsp
|
||||
// .set_request_handler::<lsp::request::References, _, _>(async move |params, _| {
|
||||
// let mk_loc = |row, start_col, end_col| lsp::Location {
|
||||
// uri: params.text_document_position.text_document.uri.clone(),
|
||||
// range: lsp::Range::new(
|
||||
// lsp::Position::new(row, start_col),
|
||||
// lsp::Position::new(row, end_col),
|
||||
// ),
|
||||
// };
|
||||
//
|
||||
// Ok(Some(vec![
|
||||
// mk_loc(1, 8, 13),
|
||||
// mk_loc(3, 12, 17),
|
||||
// mk_loc(5, 12, 17),
|
||||
// mk_loc(7, 12, 17),
|
||||
// ]))
|
||||
// });
|
||||
//
|
||||
// let initial_state = indoc!(
|
||||
// r#"
|
||||
// fn main() {
|
||||
// let ˇhello = 123;
|
||||
//
|
||||
// let x = hello;
|
||||
//
|
||||
// let y = hello;
|
||||
//
|
||||
// let z = hello;
|
||||
// }
|
||||
// "#
|
||||
// );
|
||||
//
|
||||
// cx.set_state(initial_state);
|
||||
//
|
||||
// let navigated = cx
|
||||
// .update_editor(|editor, window, cx| editor.find_all_references(&action, window, cx))
|
||||
// .expect("should have spawned a task")
|
||||
// .await
|
||||
// .unwrap();
|
||||
// assert_eq!(navigated, Navigated::Yes);
|
||||
//
|
||||
//
|
||||
//
|
||||
// }
|
||||
|
||||
@@ -385,6 +385,8 @@ impl EditorElement {
|
||||
register_action(editor, window, Editor::select_smaller_syntax_node);
|
||||
register_action(editor, window, Editor::select_next_syntax_node);
|
||||
register_action(editor, window, Editor::select_prev_syntax_node);
|
||||
register_action(editor, window, Editor::select_next_multibuffer_match);
|
||||
register_action(editor, window, Editor::select_prev_multibuffer_match);
|
||||
register_action(editor, window, Editor::unwrap_syntax_node);
|
||||
register_action(editor, window, Editor::select_enclosing_symbol);
|
||||
register_action(editor, window, Editor::move_to_enclosing_bracket);
|
||||
|
||||
@@ -656,6 +656,7 @@ pub fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
|
||||
.text_base()
|
||||
.mt(rems(1.))
|
||||
.mb_0(),
|
||||
table_columns_min_size: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -709,6 +710,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.text_base()
|
||||
.mb_0(),
|
||||
table_columns_min_size: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2425,7 +2425,10 @@ impl CodeLabel {
|
||||
"invalid filter range"
|
||||
);
|
||||
runs.iter().for_each(|(range, _)| {
|
||||
assert!(text.get(range.clone()).is_some(), "invalid run range");
|
||||
assert!(
|
||||
text.get(range.clone()).is_some(),
|
||||
"invalid run range with inputs. Requested range {range:?} in text '{text}'",
|
||||
);
|
||||
});
|
||||
Self {
|
||||
runs,
|
||||
|
||||
@@ -375,16 +375,20 @@ impl LspAdapter for RustLspAdapter {
|
||||
let start_pos = range.start as usize;
|
||||
let end_pos = range.end as usize;
|
||||
|
||||
label.push_str(&snippet.text[text_pos..end_pos]);
|
||||
text_pos = end_pos;
|
||||
label.push_str(&snippet.text[text_pos..start_pos]);
|
||||
|
||||
if start_pos == end_pos {
|
||||
let caret_start = label.len();
|
||||
label.push('…');
|
||||
runs.push((caret_start..label.len(), HighlightId::TABSTOP_INSERT_ID));
|
||||
} else {
|
||||
runs.push((start_pos..end_pos, HighlightId::TABSTOP_REPLACE_ID));
|
||||
let label_start = label.len();
|
||||
label.push_str(&snippet.text[start_pos..end_pos]);
|
||||
let label_end = label.len();
|
||||
runs.push((label_start..label_end, HighlightId::TABSTOP_REPLACE_ID));
|
||||
}
|
||||
|
||||
text_pos = end_pos;
|
||||
}
|
||||
|
||||
label.push_str(&snippet.text[text_pos..]);
|
||||
@@ -1592,6 +1596,44 @@ mod tests {
|
||||
],
|
||||
))
|
||||
);
|
||||
|
||||
// Test for correct range calculation with mixed empty and non-empty tabstops.(See https://github.com/zed-industries/zed/issues/44825)
|
||||
let res = adapter
|
||||
.label_for_completion(
|
||||
&lsp::CompletionItem {
|
||||
kind: Some(lsp::CompletionItemKind::STRUCT),
|
||||
label: "Particles".to_string(),
|
||||
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
|
||||
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
|
||||
range: lsp::Range::default(),
|
||||
new_text: "Particles { pos_x: $1, pos_y: $2, vel_x: $3, vel_y: $4, acc_x: ${5:()}, acc_y: ${6:()}, mass: $7 }$0".to_string(),
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
&language,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res,
|
||||
CodeLabel::new(
|
||||
"Particles { pos_x: …, pos_y: …, vel_x: …, vel_y: …, acc_x: (), acc_y: (), mass: … }".to_string(),
|
||||
0..9,
|
||||
vec![
|
||||
(19..22, HighlightId::TABSTOP_INSERT_ID),
|
||||
(31..34, HighlightId::TABSTOP_INSERT_ID),
|
||||
(43..46, HighlightId::TABSTOP_INSERT_ID),
|
||||
(55..58, HighlightId::TABSTOP_INSERT_ID),
|
||||
(67..69, HighlightId::TABSTOP_REPLACE_ID),
|
||||
(78..80, HighlightId::TABSTOP_REPLACE_ID),
|
||||
(88..91, HighlightId::TABSTOP_INSERT_ID),
|
||||
(0..9, highlight_type),
|
||||
(60..65, highlight_field),
|
||||
(71..76, highlight_field),
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -70,6 +70,7 @@ pub struct MarkdownStyle {
|
||||
pub heading_level_styles: Option<HeadingLevelStyles>,
|
||||
pub height_is_multiple_of_line_height: bool,
|
||||
pub prevent_mouse_interaction: bool,
|
||||
pub table_columns_min_size: bool,
|
||||
}
|
||||
|
||||
impl Default for MarkdownStyle {
|
||||
@@ -91,6 +92,7 @@ impl Default for MarkdownStyle {
|
||||
heading_level_styles: None,
|
||||
height_is_multiple_of_line_height: false,
|
||||
prevent_mouse_interaction: false,
|
||||
table_columns_min_size: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1061,15 +1063,23 @@ impl Element for MarkdownElement {
|
||||
}
|
||||
MarkdownTag::MetadataBlock(_) => {}
|
||||
MarkdownTag::Table(alignments) => {
|
||||
builder.table_alignments = alignments.clone();
|
||||
builder.table.start(alignments.clone());
|
||||
|
||||
let column_count = alignments.len();
|
||||
builder.push_div(
|
||||
div()
|
||||
.id(("table", range.start))
|
||||
.min_w_0()
|
||||
.grid()
|
||||
.grid_cols(column_count as u16)
|
||||
.when(self.style.table_columns_min_size, |this| {
|
||||
this.grid_cols_min_content(column_count as u16)
|
||||
})
|
||||
.when(!self.style.table_columns_min_size, |this| {
|
||||
this.grid_cols(column_count as u16)
|
||||
})
|
||||
.size_full()
|
||||
.mb_2()
|
||||
.border_1()
|
||||
.border(px(1.5))
|
||||
.border_color(cx.theme().colors().border)
|
||||
.rounded_sm()
|
||||
.overflow_hidden(),
|
||||
@@ -1078,38 +1088,33 @@ impl Element for MarkdownElement {
|
||||
);
|
||||
}
|
||||
MarkdownTag::TableHead => {
|
||||
let column_count = builder.table_alignments.len();
|
||||
|
||||
builder.push_div(
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(column_count as u16)
|
||||
.bg(cx.theme().colors().title_bar_background),
|
||||
range,
|
||||
markdown_end,
|
||||
);
|
||||
builder.table.start_head();
|
||||
builder.push_text_style(TextStyleRefinement {
|
||||
font_weight: Some(FontWeight::SEMIBOLD),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
MarkdownTag::TableRow => {
|
||||
let column_count = builder.table_alignments.len();
|
||||
|
||||
builder.push_div(
|
||||
div().grid().grid_cols(column_count as u16),
|
||||
range,
|
||||
markdown_end,
|
||||
);
|
||||
builder.table.start_row();
|
||||
}
|
||||
MarkdownTag::TableCell => {
|
||||
let is_header = builder.table.in_head;
|
||||
let row_index = builder.table.row_index;
|
||||
let col_index = builder.table.col_index;
|
||||
|
||||
builder.push_div(
|
||||
div()
|
||||
.min_w_0()
|
||||
.border(px(0.5))
|
||||
.when(col_index > 0, |this| this.border_l_1())
|
||||
.when(row_index > 0, |this| this.border_t_1())
|
||||
.border_color(cx.theme().colors().border)
|
||||
.px_1()
|
||||
.py_0p5(),
|
||||
.py_0p5()
|
||||
.when(is_header, |this| {
|
||||
this.bg(cx.theme().colors().title_bar_background)
|
||||
})
|
||||
.when(!is_header && row_index % 2 == 1, |this| {
|
||||
this.bg(cx.theme().colors().panel_background)
|
||||
}),
|
||||
range,
|
||||
markdown_end,
|
||||
);
|
||||
@@ -1223,17 +1228,18 @@ impl Element for MarkdownElement {
|
||||
}
|
||||
MarkdownTagEnd::Table => {
|
||||
builder.pop_div();
|
||||
builder.table_alignments.clear();
|
||||
builder.table.end();
|
||||
}
|
||||
MarkdownTagEnd::TableHead => {
|
||||
builder.pop_div();
|
||||
builder.pop_text_style();
|
||||
builder.table.end_head();
|
||||
}
|
||||
MarkdownTagEnd::TableRow => {
|
||||
builder.pop_div();
|
||||
builder.table.end_row();
|
||||
}
|
||||
MarkdownTagEnd::TableCell => {
|
||||
builder.pop_div();
|
||||
builder.table.end_cell();
|
||||
}
|
||||
_ => log::debug!("unsupported markdown tag end: {:?}", tag),
|
||||
},
|
||||
@@ -1488,6 +1494,50 @@ impl ParentElement for AnyDiv {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TableState {
|
||||
alignments: Vec<Alignment>,
|
||||
in_head: bool,
|
||||
row_index: usize,
|
||||
col_index: usize,
|
||||
}
|
||||
|
||||
impl TableState {
|
||||
fn start(&mut self, alignments: Vec<Alignment>) {
|
||||
self.alignments = alignments;
|
||||
self.in_head = false;
|
||||
self.row_index = 0;
|
||||
self.col_index = 0;
|
||||
}
|
||||
|
||||
fn end(&mut self) {
|
||||
self.alignments.clear();
|
||||
self.in_head = false;
|
||||
self.row_index = 0;
|
||||
self.col_index = 0;
|
||||
}
|
||||
|
||||
fn start_head(&mut self) {
|
||||
self.in_head = true;
|
||||
}
|
||||
|
||||
fn end_head(&mut self) {
|
||||
self.in_head = false;
|
||||
}
|
||||
|
||||
fn start_row(&mut self) {
|
||||
self.col_index = 0;
|
||||
}
|
||||
|
||||
fn end_row(&mut self) {
|
||||
self.row_index += 1;
|
||||
}
|
||||
|
||||
fn end_cell(&mut self) {
|
||||
self.col_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkdownElementBuilder {
|
||||
div_stack: Vec<AnyDiv>,
|
||||
rendered_lines: Vec<RenderedLine>,
|
||||
@@ -1499,7 +1549,7 @@ struct MarkdownElementBuilder {
|
||||
text_style_stack: Vec<TextStyleRefinement>,
|
||||
code_block_stack: Vec<Option<Arc<Language>>>,
|
||||
list_stack: Vec<ListStackEntry>,
|
||||
table_alignments: Vec<Alignment>,
|
||||
table: TableState,
|
||||
syntax_theme: Arc<SyntaxTheme>,
|
||||
}
|
||||
|
||||
@@ -1535,7 +1585,7 @@ impl MarkdownElementBuilder {
|
||||
text_style_stack: Vec::new(),
|
||||
code_block_stack: Vec::new(),
|
||||
list_stack: Vec::new(),
|
||||
table_alignments: Vec::new(),
|
||||
table: TableState::default(),
|
||||
syntax_theme,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
AbsoluteLength, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Element,
|
||||
ElementId, Entity, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke,
|
||||
Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle,
|
||||
WeakEntity, Window, div, img, rems,
|
||||
WeakEntity, Window, div, img, px, rems,
|
||||
};
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
@@ -521,7 +521,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
|
||||
.children(render_markdown_text(&cell.children, cx))
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_1()
|
||||
.when(col_idx > 0, |this| this.border_l_1())
|
||||
.when(row_idx > 0, |this| this.border_t_1())
|
||||
.border_color(cx.border_color)
|
||||
.when(cell.is_header, |this| {
|
||||
this.bg(cx.title_bar_background_color)
|
||||
@@ -551,7 +552,8 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
|
||||
}
|
||||
|
||||
let empty_cell = div()
|
||||
.border_1()
|
||||
.when(col_idx > 0, |this| this.border_l_1())
|
||||
.when(row_idx > 0, |this| this.border_t_1())
|
||||
.border_color(cx.border_color)
|
||||
.when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color));
|
||||
|
||||
@@ -568,8 +570,10 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(max_column_count as u16)
|
||||
.border_1()
|
||||
.border(px(1.5))
|
||||
.border_color(cx.border_color)
|
||||
.rounded_sm()
|
||||
.overflow_hidden()
|
||||
.children(cells),
|
||||
)
|
||||
.into_any()
|
||||
|
||||
@@ -6428,6 +6428,14 @@ impl MultiBufferSnapshot {
|
||||
Some(self.excerpt(excerpt_id)?.buffer_id)
|
||||
}
|
||||
|
||||
pub fn excerpt_id_for_buffer_id(&self, buffer_id: BufferId) -> Option<ExcerptId> {
|
||||
// todo! is linear OK here?
|
||||
self.excerpts
|
||||
.iter()
|
||||
.find(|ex| ex.buffer_id == buffer_id)
|
||||
.map(|ex| ex.id)
|
||||
}
|
||||
|
||||
pub fn buffer_for_excerpt(&self, excerpt_id: ExcerptId) -> Option<&BufferSnapshot> {
|
||||
Some(&self.excerpt(excerpt_id)?.buffer)
|
||||
}
|
||||
|
||||
@@ -6050,9 +6050,9 @@ impl Render for ProjectPanel {
|
||||
h_flex()
|
||||
.w_1_2()
|
||||
.gap_2()
|
||||
.child(div().flex_1().child(Divider::horizontal()))
|
||||
.child(Divider::horizontal())
|
||||
.child(Label::new("or").size(LabelSize::XSmall).color(Color::Muted))
|
||||
.child(div().flex_1().child(Divider::horizontal())),
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(
|
||||
Button::new("clone_repo", "Clone Repository")
|
||||
|
||||
@@ -1498,6 +1498,9 @@ impl ProjectSearchView {
|
||||
|
||||
fn entity_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let match_ranges = self.entity.read(cx).match_ranges.clone();
|
||||
self.results_editor.update(cx, |editor, cx| {
|
||||
editor.set_initial_multibuffer_matches(match_ranges.clone());
|
||||
});
|
||||
|
||||
if match_ranges.is_empty() {
|
||||
self.active_match_index = None;
|
||||
|
||||
@@ -146,13 +146,11 @@ impl RenderOnce for Divider {
|
||||
let base = match self.direction {
|
||||
DividerDirection::Horizontal => div()
|
||||
.min_w_0()
|
||||
.flex_none()
|
||||
.h_px()
|
||||
.w_full()
|
||||
.when(self.inset, |this| this.mx_1p5()),
|
||||
DividerDirection::Vertical => div()
|
||||
.min_w_0()
|
||||
.flex_none()
|
||||
.w_px()
|
||||
.h_full()
|
||||
.when(self.inset, |this| this.my_1p5()),
|
||||
|
||||
@@ -1529,11 +1529,14 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
|
||||
VimCommand::str(("cl", "ist"), "diagnostics::Deploy"),
|
||||
VimCommand::new(("cc", ""), editor::actions::Hover),
|
||||
VimCommand::new(("ll", ""), editor::actions::Hover),
|
||||
VimCommand::new(("cn", "ext"), editor::actions::GoToDiagnostic::default())
|
||||
.range(wrap_count),
|
||||
VimCommand::new(
|
||||
("cn", "ext"),
|
||||
editor::actions::SelectNextMultibufferMatch::default(),
|
||||
)
|
||||
.range(wrap_count),
|
||||
VimCommand::new(
|
||||
("cp", "revious"),
|
||||
editor::actions::GoToPreviousDiagnostic::default(),
|
||||
editor::actions::SelectPreviousMultibufferMatch::default(),
|
||||
)
|
||||
.range(wrap_count),
|
||||
VimCommand::new(
|
||||
|
||||
Reference in New Issue
Block a user