Compare commits
35 Commits
v0.197.1-p
...
agent-msg-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ac874ab31 | ||
|
|
2658b2801e | ||
|
|
2a9355a3d2 | ||
|
|
fa788a39a4 | ||
|
|
7cdd808db2 | ||
|
|
29332c1962 | ||
|
|
fab450e39d | ||
|
|
4fb540d6d2 | ||
|
|
1e2b0fcab6 | ||
|
|
0af690080b | ||
|
|
dd52fb58fe | ||
|
|
913b9296d7 | ||
|
|
5c9363b1c4 | ||
|
|
cd9bcc7f09 | ||
|
|
65759d4316 | ||
|
|
ddd50aabba | ||
|
|
34bf6ebba6 | ||
|
|
a6956eebcb | ||
|
|
8b0ec287a5 | ||
|
|
c08851a85e | ||
|
|
b93e1c736b | ||
|
|
67027bb241 | ||
|
|
31afda3c0c | ||
|
|
3d4266bb8f | ||
|
|
4a87397d37 | ||
|
|
3da23cc65b | ||
|
|
b63d820be2 | ||
|
|
7e9d6cc25c | ||
|
|
8bf7dcb613 | ||
|
|
edceb7284f | ||
|
|
50985b7d23 | ||
|
|
be0d9eecb7 | ||
|
|
9863c8a44e | ||
|
|
a48247a313 | ||
|
|
5f0edd38f8 |
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -249,6 +249,7 @@ dependencies = [
|
||||
"prompt_store",
|
||||
"proto",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"release_channel",
|
||||
"rope",
|
||||
"rules_library",
|
||||
@@ -7402,9 +7403,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grid"
|
||||
version = "0.14.0"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82"
|
||||
checksum = "71b01d27060ad58be4663b9e4ac9e2d4806918e8876af8912afbddd1a91d5eaa"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
@@ -15961,13 +15962,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "taffy"
|
||||
version = "0.5.1"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61630cba2afd2c851821add2e1bb1b7851a2436e839ab73b56558b009035e"
|
||||
checksum = "7aaef0ac998e6527d6d0d5582f7e43953bb17221ac75bb8eb2fcc2db3396db1c"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"grid",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"slotmap",
|
||||
]
|
||||
@@ -20170,7 +20170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zed"
|
||||
version = "0.197.0"
|
||||
version = "0.198.0"
|
||||
dependencies = [
|
||||
"activity_indicator",
|
||||
"agent",
|
||||
@@ -20211,6 +20211,7 @@ dependencies = [
|
||||
"extension",
|
||||
"extension_host",
|
||||
"extensions_ui",
|
||||
"feature_flags",
|
||||
"feedback",
|
||||
"file_finder",
|
||||
"fs",
|
||||
|
||||
@@ -220,6 +220,8 @@
|
||||
{
|
||||
"context": "vim_mode == normal",
|
||||
"bindings": {
|
||||
"i": "vim::InsertBefore",
|
||||
"a": "vim::InsertAfter",
|
||||
"ctrl-[": "editor::Cancel",
|
||||
":": "command_palette::Toggle",
|
||||
"c": "vim::PushChange",
|
||||
@@ -353,9 +355,7 @@
|
||||
"shift-d": "vim::DeleteToEndOfLine",
|
||||
"shift-j": "vim::JoinLines",
|
||||
"shift-y": "vim::YankLine",
|
||||
"i": "vim::InsertBefore",
|
||||
"shift-i": "vim::InsertFirstNonWhitespace",
|
||||
"a": "vim::InsertAfter",
|
||||
"shift-a": "vim::InsertEndOfLine",
|
||||
"o": "vim::InsertLineBelow",
|
||||
"shift-o": "vim::InsertLineAbove",
|
||||
@@ -377,6 +377,8 @@
|
||||
{
|
||||
"context": "vim_mode == helix_normal && !menu",
|
||||
"bindings": {
|
||||
"i": "vim::HelixInsert",
|
||||
"a": "vim::HelixAppend",
|
||||
"ctrl-[": "editor::Cancel",
|
||||
";": "vim::HelixCollapseSelection",
|
||||
":": "command_palette::Toggle",
|
||||
|
||||
@@ -691,7 +691,10 @@
|
||||
// 5. Never show the scrollbar:
|
||||
// "never"
|
||||
"show": null
|
||||
}
|
||||
},
|
||||
// Default depth to expand outline items in the current file.
|
||||
// Set to 0 to collapse all items that have children, 1 or higher to collapse items at that depth or deeper.
|
||||
"expand_outlines_with_depth": 100
|
||||
},
|
||||
"collaboration_panel": {
|
||||
// Whether to show the collaboration panel button in the status bar.
|
||||
|
||||
@@ -2037,6 +2037,12 @@ impl Thread {
|
||||
if let Some(retry_strategy) =
|
||||
Thread::get_retry_strategy(completion_error)
|
||||
{
|
||||
log::info!(
|
||||
"Retrying with {:?} for language model completion error {:?}",
|
||||
retry_strategy,
|
||||
completion_error
|
||||
);
|
||||
|
||||
retry_scheduled = thread
|
||||
.handle_retryable_error_with_delay(
|
||||
&completion_error,
|
||||
@@ -2246,15 +2252,14 @@ impl Thread {
|
||||
..
|
||||
}
|
||||
| AuthenticationError { .. }
|
||||
| PermissionError { .. } => None,
|
||||
// These errors might be transient, so retry them
|
||||
SerializeRequest { .. }
|
||||
| BuildRequestBody { .. }
|
||||
| PromptTooLarge { .. }
|
||||
| PermissionError { .. }
|
||||
| NoApiKey { .. }
|
||||
| ApiEndpointNotFound { .. }
|
||||
| NoApiKey { .. } => Some(RetryStrategy::Fixed {
|
||||
| PromptTooLarge { .. } => None,
|
||||
// These errors might be transient, so retry them
|
||||
SerializeRequest { .. } | BuildRequestBody { .. } => Some(RetryStrategy::Fixed {
|
||||
delay: BASE_RETRY_DELAY,
|
||||
max_attempts: 2,
|
||||
max_attempts: 1,
|
||||
}),
|
||||
// Retry all other 4xx and 5xx errors once.
|
||||
HttpResponseError { status_code, .. }
|
||||
|
||||
@@ -41,6 +41,9 @@ use std::{
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
|
||||
pub static ZED_STATELESS: std::sync::LazyLock<bool> =
|
||||
std::sync::LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty()));
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DataType {
|
||||
#[serde(rename = "json")]
|
||||
@@ -874,7 +877,11 @@ impl ThreadsDatabase {
|
||||
|
||||
let needs_migration_from_heed = mdb_path.exists();
|
||||
|
||||
let connection = Connection::open_file(&sqlite_path.to_string_lossy());
|
||||
let connection = if *ZED_STATELESS {
|
||||
Connection::open_memory(Some("THREAD_FALLBACK_DB"))
|
||||
} else {
|
||||
Connection::open_file(&sqlite_path.to_string_lossy())
|
||||
};
|
||||
|
||||
connection.exec(indoc! {"
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
|
||||
@@ -68,6 +68,7 @@ picker.workspace = true
|
||||
project.workspace = true
|
||||
prompt_store.workspace = true
|
||||
proto.workspace = true
|
||||
regex.workspace = true
|
||||
release_channel.workspace = true
|
||||
rope.workspace = true
|
||||
rules_library.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -185,7 +185,15 @@ impl AgentConfiguration {
|
||||
None
|
||||
};
|
||||
|
||||
let is_signed_in = self
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| {
|
||||
workspace.client().status().borrow().is_connected()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.when(is_expanded, |this| this.mb_2())
|
||||
.child(
|
||||
div()
|
||||
@@ -216,6 +224,7 @@ impl AgentConfiguration {
|
||||
.hover(|hover| hover.bg(cx.theme().colors().element_hover))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(provider.icon())
|
||||
@@ -224,14 +233,15 @@ impl AgentConfiguration {
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new(provider_name.clone())
|
||||
.size(LabelSize::Large),
|
||||
)
|
||||
.map(|this| {
|
||||
if is_zed_provider {
|
||||
this.gap_2().child(
|
||||
if is_zed_provider && is_signed_in {
|
||||
this.child(
|
||||
self.render_zed_plan_info(current_plan, cx),
|
||||
)
|
||||
} else {
|
||||
@@ -307,6 +317,7 @@ impl AgentConfiguration {
|
||||
let providers = LanguageModelRegistry::read_global(cx).providers();
|
||||
|
||||
v_flex()
|
||||
.w_full()
|
||||
.child(
|
||||
h_flex()
|
||||
.p(DynamicSpacing::Base16.rems(cx))
|
||||
@@ -317,50 +328,67 @@ impl AgentConfiguration {
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.w_full()
|
||||
.gap_0p5()
|
||||
.child(Headline::new("LLM Providers"))
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.justify_between()
|
||||
.child(Headline::new("LLM Providers"))
|
||||
.child(
|
||||
PopoverMenu::new("add-provider-popover")
|
||||
.trigger(
|
||||
Button::new("add-provider", "Add Provider")
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon(IconName::Plus)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.label_size(LabelSize::Small),
|
||||
)
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
.menu({
|
||||
let workspace = self.workspace.clone();
|
||||
move |window, cx| {
|
||||
Some(ContextMenu::build(
|
||||
window,
|
||||
cx,
|
||||
|menu, _window, _cx| {
|
||||
menu.header("Compatible APIs").entry(
|
||||
"OpenAI",
|
||||
None,
|
||||
{
|
||||
let workspace =
|
||||
workspace.clone();
|
||||
move |window, cx| {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
AddLlmProviderModal::toggle(
|
||||
LlmCompatibleProvider::OpenAi,
|
||||
workspace,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
))
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Label::new("Add at least one provider to use AI-powered features.")
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
PopoverMenu::new("add-provider-popover")
|
||||
.trigger(
|
||||
Button::new("add-provider", "Add Provider")
|
||||
.icon_position(IconPosition::Start)
|
||||
.icon(IconName::Plus)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.label_size(LabelSize::Small),
|
||||
)
|
||||
.anchor(gpui::Corner::TopRight)
|
||||
.menu({
|
||||
let workspace = self.workspace.clone();
|
||||
move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
|
||||
menu.header("Compatible APIs").entry("OpenAI", None, {
|
||||
let workspace = workspace.clone();
|
||||
move |window, cx| {
|
||||
workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
AddLlmProviderModal::toggle(
|
||||
LlmCompatibleProvider::OpenAi,
|
||||
workspace,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.pl(DynamicSpacing::Base08.rems(cx))
|
||||
.pr(DynamicSpacing::Base20.rems(cx))
|
||||
.children(
|
||||
|
||||
@@ -9,7 +9,6 @@ use agent_servers::AgentServer;
|
||||
use db::kvp::{Dismissable, KEY_VALUE_STORE};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::NewExternalAgentThread;
|
||||
use crate::agent_diff::AgentDiffThread;
|
||||
use crate::message_editor::{MAX_EDITOR_LINES, MIN_EDITOR_LINES};
|
||||
use crate::ui::NewThreadButton;
|
||||
@@ -31,6 +30,7 @@ use crate::{
|
||||
thread_history::{HistoryEntryElement, ThreadHistory},
|
||||
ui::{AgentOnboardingModal, EndTrialUpsell},
|
||||
};
|
||||
use crate::{EditAssistantMessage, EditUserMessage, NewExternalAgentThread};
|
||||
use agent::{
|
||||
Thread, ThreadError, ThreadEvent, ThreadId, ThreadSummary, TokenUsageRatio,
|
||||
context_store::ContextStore,
|
||||
@@ -564,6 +564,17 @@ impl AgentPanel {
|
||||
let inline_assist_context_store =
|
||||
cx.new(|_cx| ContextStore::new(project.downgrade(), Some(thread_store.downgrade())));
|
||||
|
||||
let thread_id = thread.read(cx).id().clone();
|
||||
|
||||
let history_store = cx.new(|cx| {
|
||||
HistoryStore::new(
|
||||
thread_store.clone(),
|
||||
context_store.clone(),
|
||||
[HistoryEntryId::Thread(thread_id)],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let message_editor = cx.new(|cx| {
|
||||
MessageEditor::new(
|
||||
fs.clone(),
|
||||
@@ -573,22 +584,13 @@ impl AgentPanel {
|
||||
prompt_store.clone(),
|
||||
thread_store.downgrade(),
|
||||
context_store.downgrade(),
|
||||
Some(history_store.downgrade()),
|
||||
thread.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let thread_id = thread.read(cx).id().clone();
|
||||
let history_store = cx.new(|cx| {
|
||||
HistoryStore::new(
|
||||
thread_store.clone(),
|
||||
context_store.clone(),
|
||||
[HistoryEntryId::Thread(thread_id)],
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
|
||||
|
||||
let active_thread = cx.new(|cx| {
|
||||
@@ -851,6 +853,7 @@ impl AgentPanel {
|
||||
self.prompt_store.clone(),
|
||||
self.thread_store.downgrade(),
|
||||
self.context_store.downgrade(),
|
||||
Some(self.history_store.downgrade()),
|
||||
thread.clone(),
|
||||
window,
|
||||
cx,
|
||||
@@ -1124,6 +1127,7 @@ impl AgentPanel {
|
||||
self.prompt_store.clone(),
|
||||
self.thread_store.downgrade(),
|
||||
self.context_store.downgrade(),
|
||||
Some(self.history_store.downgrade()),
|
||||
thread.clone(),
|
||||
window,
|
||||
cx,
|
||||
@@ -1901,85 +1905,96 @@ impl AgentPanel {
|
||||
)
|
||||
.anchor(Corner::TopRight)
|
||||
.with_handle(self.new_thread_menu_handle.clone())
|
||||
.menu(move |window, cx| {
|
||||
let active_thread = active_thread.clone();
|
||||
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
|
||||
menu = menu
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.header("Zed Agent")
|
||||
})
|
||||
.item(
|
||||
ContextMenuEntry::new("New Thread")
|
||||
.icon(IconName::NewThread)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(NewThread::default().boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Text Thread")
|
||||
.icon(IconName::NewTextThread)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(NewTextThread.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.when_some(active_thread, |this, active_thread| {
|
||||
let thread = active_thread.read(cx);
|
||||
.menu({
|
||||
let focus_handle = focus_handle.clone();
|
||||
move |window, cx| {
|
||||
let active_thread = active_thread.clone();
|
||||
Some(ContextMenu::build(window, cx, |mut menu, _window, cx| {
|
||||
menu = menu
|
||||
.context(focus_handle.clone())
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.header("Zed Agent")
|
||||
})
|
||||
.item(
|
||||
ContextMenuEntry::new("New Thread")
|
||||
.icon(IconName::NewThread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewThread::default().boxed_clone())
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewThread::default().boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Text Thread")
|
||||
.icon(IconName::NewTextThread)
|
||||
.icon_color(Color::Muted)
|
||||
.action(NewTextThread.boxed_clone())
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(NewTextThread.boxed_clone(), cx);
|
||||
}),
|
||||
)
|
||||
.when_some(active_thread, |this, active_thread| {
|
||||
let thread = active_thread.read(cx);
|
||||
|
||||
if !thread.is_empty() {
|
||||
let thread_id = thread.id().clone();
|
||||
this.item(
|
||||
ContextMenuEntry::new("New From Summary")
|
||||
.icon(IconName::NewFromSummary)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewThread {
|
||||
from_thread_id: Some(thread_id.clone()),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.separator()
|
||||
.header("External Agents")
|
||||
.item(
|
||||
ContextMenuEntry::new("New Gemini Thread")
|
||||
.icon(IconName::AiGemini)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::Gemini),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Claude Code Thread")
|
||||
.icon(IconName::AiClaude)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::ClaudeCode),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
});
|
||||
menu
|
||||
}))
|
||||
if !thread.is_empty() {
|
||||
let thread_id = thread.id().clone();
|
||||
this.item(
|
||||
ContextMenuEntry::new("New From Summary")
|
||||
.icon(IconName::NewFromSummary)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(NewThread {
|
||||
from_thread_id: Some(thread_id.clone()),
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
})
|
||||
.when(cx.has_flag::<feature_flags::AcpFeatureFlag>(), |this| {
|
||||
this.separator()
|
||||
.header("External Agents")
|
||||
.item(
|
||||
ContextMenuEntry::new("New Gemini Thread")
|
||||
.icon(IconName::AiGemini)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(crate::ExternalAgent::Gemini),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
.item(
|
||||
ContextMenuEntry::new("New Claude Code Thread")
|
||||
.icon(IconName::AiClaude)
|
||||
.icon_color(Color::Muted)
|
||||
.handler(move |window, cx| {
|
||||
window.dispatch_action(
|
||||
NewExternalAgentThread {
|
||||
agent: Some(
|
||||
crate::ExternalAgent::ClaudeCode,
|
||||
),
|
||||
}
|
||||
.boxed_clone(),
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
)
|
||||
});
|
||||
menu
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
let agent_panel_menu = PopoverMenu::new("agent-options-menu")
|
||||
@@ -2272,20 +2287,21 @@ impl AgentPanel {
|
||||
}
|
||||
|
||||
match &self.active_view {
|
||||
ActiveView::Thread { thread, .. } => thread
|
||||
.read(cx)
|
||||
.thread()
|
||||
.read(cx)
|
||||
.configured_model()
|
||||
.map_or(true, |model| {
|
||||
model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID
|
||||
}),
|
||||
ActiveView::TextThread { .. } => LanguageModelRegistry::global(cx)
|
||||
.read(cx)
|
||||
.default_model()
|
||||
.map_or(true, |model| {
|
||||
model.provider.id() == language_model::ZED_CLOUD_PROVIDER_ID
|
||||
}),
|
||||
ActiveView::Thread { .. } | ActiveView::TextThread { .. } => {
|
||||
let history_is_empty = self
|
||||
.history_store
|
||||
.update(cx, |store, cx| store.recent_entries(1, cx).is_empty());
|
||||
|
||||
let has_configured_non_zed_providers = LanguageModelRegistry::read_global(cx)
|
||||
.providers()
|
||||
.iter()
|
||||
.any(|provider| {
|
||||
provider.is_authenticated(cx)
|
||||
&& provider.id() != language_model::ZED_CLOUD_PROVIDER_ID
|
||||
});
|
||||
|
||||
history_is_empty || !has_configured_non_zed_providers
|
||||
}
|
||||
ActiveView::ExternalAgentThread { .. }
|
||||
| ActiveView::History
|
||||
| ActiveView::Configuration => false,
|
||||
@@ -2306,9 +2322,8 @@ impl AgentPanel {
|
||||
|
||||
Some(
|
||||
div()
|
||||
.size_full()
|
||||
.when(thread_view, |this| {
|
||||
this.bg(cx.theme().colors().panel_background)
|
||||
this.size_full().bg(cx.theme().colors().panel_background)
|
||||
})
|
||||
.when(text_thread_view, |this| {
|
||||
this.bg(cx.theme().colors().editor_background)
|
||||
@@ -3207,6 +3222,20 @@ impl Render for AgentPanel {
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(Self::toggle_burn_mode))
|
||||
// .on_action(cx.listener(|this, _: &EditAssistantMessage, window, cx| {
|
||||
// if let ActiveView::Thread { thread, .. } = &this.active_view {
|
||||
// thread.update(cx, |this, cx| {
|
||||
// this.edit_last_message(Role::Assistant, window, cx);
|
||||
// });
|
||||
// }
|
||||
// }))
|
||||
// .on_action(cx.listener(|this, _: &EditUserMessage, window, cx| {
|
||||
// if let ActiveView::Thread { thread, .. } = &this.active_view {
|
||||
// thread.update(cx, |this, cx| {
|
||||
// this.edit_last_message(Role::User, window, cx);
|
||||
// });
|
||||
// }
|
||||
// }))
|
||||
.child(self.render_toolbar(window, cx))
|
||||
.children(self.render_onboarding(window, cx))
|
||||
.children(self.render_trial_end_upsell(window, cx))
|
||||
|
||||
@@ -123,6 +123,8 @@ actions!(
|
||||
ContinueWithBurnMode,
|
||||
/// Toggles burn mode for faster responses.
|
||||
ToggleBurnMode,
|
||||
EditAssistantMessage,
|
||||
EditUserMessage,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::ui::{
|
||||
MaxModeTooltip,
|
||||
preview::{AgentPreview, UsageCallout},
|
||||
};
|
||||
use agent::history_store::HistoryStore;
|
||||
use agent::{
|
||||
context::{AgentContextKey, ContextLoadResult, load_context},
|
||||
context_store::ContextStoreEvent,
|
||||
@@ -29,8 +30,9 @@ use fs::Fs;
|
||||
use futures::future::Shared;
|
||||
use futures::{FutureExt as _, future};
|
||||
use gpui::{
|
||||
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, KeyContext, Subscription, Task,
|
||||
TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between,
|
||||
Animation, AnimationExt, App, Entity, EventEmitter, Focusable, IntoElement, KeyContext,
|
||||
Subscription, Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point,
|
||||
pulsating_between,
|
||||
};
|
||||
use language::{Buffer, Language, Point};
|
||||
use language_model::{
|
||||
@@ -80,6 +82,7 @@ pub struct MessageEditor {
|
||||
user_store: Entity<UserStore>,
|
||||
context_store: Entity<ContextStore>,
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
history_store: Option<WeakEntity<HistoryStore>>,
|
||||
context_strip: Entity<ContextStrip>,
|
||||
context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
|
||||
model_selector: Entity<AgentModelSelector>,
|
||||
@@ -161,6 +164,7 @@ impl MessageEditor {
|
||||
prompt_store: Option<Entity<PromptStore>>,
|
||||
thread_store: WeakEntity<ThreadStore>,
|
||||
text_thread_store: WeakEntity<TextThreadStore>,
|
||||
history_store: Option<WeakEntity<HistoryStore>>,
|
||||
thread: Entity<Thread>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
@@ -233,6 +237,7 @@ impl MessageEditor {
|
||||
workspace,
|
||||
context_store,
|
||||
prompt_store,
|
||||
history_store,
|
||||
context_strip,
|
||||
context_picker_menu_handle,
|
||||
load_context_task: None,
|
||||
@@ -625,7 +630,7 @@ impl MessageEditor {
|
||||
.unwrap_or(false);
|
||||
|
||||
IconButton::new("follow-agent", IconName::Crosshair)
|
||||
.disabled(is_model_selected)
|
||||
.disabled(!is_model_selected)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.toggle_state(following)
|
||||
@@ -1661,32 +1666,36 @@ impl Render for MessageEditor {
|
||||
|
||||
let line_height = TextSize::Small.rems(cx).to_pixels(window.rem_size()) * 1.5;
|
||||
|
||||
let in_pro_trial = matches!(
|
||||
self.user_store.read(cx).current_plan(),
|
||||
Some(proto::Plan::ZedProTrial)
|
||||
);
|
||||
let has_configured_providers = LanguageModelRegistry::read_global(cx)
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
|
||||
})
|
||||
.count()
|
||||
> 0;
|
||||
|
||||
let pro_user = matches!(
|
||||
self.user_store.read(cx).current_plan(),
|
||||
Some(proto::Plan::ZedPro)
|
||||
);
|
||||
let is_signed_out = self
|
||||
.workspace
|
||||
.read_with(cx, |workspace, _| {
|
||||
workspace.client().status().borrow().is_signed_out()
|
||||
})
|
||||
.unwrap_or(true);
|
||||
|
||||
let configured_providers: Vec<(IconName, SharedString)> =
|
||||
LanguageModelRegistry::read_global(cx)
|
||||
.providers()
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider.is_authenticated(cx) && provider.id() != ZED_CLOUD_PROVIDER_ID
|
||||
})
|
||||
.map(|provider| (provider.icon(), provider.name().0.clone()))
|
||||
.collect();
|
||||
let has_existing_providers = configured_providers.len() > 0;
|
||||
let has_history = self
|
||||
.history_store
|
||||
.as_ref()
|
||||
.and_then(|hs| hs.update(cx, |hs, cx| hs.entries(cx).len() > 0).ok())
|
||||
.unwrap_or(false)
|
||||
|| self
|
||||
.thread
|
||||
.read_with(cx, |thread, _| thread.messages().len() > 0);
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.bg(cx.theme().colors().panel_background)
|
||||
.when(
|
||||
has_existing_providers && !in_pro_trial && !pro_user,
|
||||
!has_history && is_signed_out && has_configured_providers,
|
||||
|this| this.child(cx.new(ApiKeysWithProviders::new)),
|
||||
)
|
||||
.when(changed_buffers.len() > 0, |parent| {
|
||||
@@ -1778,6 +1787,7 @@ impl AgentPreview for MessageEditor {
|
||||
None,
|
||||
thread_store.downgrade(),
|
||||
text_thread_store.downgrade(),
|
||||
None,
|
||||
thread,
|
||||
window,
|
||||
cx,
|
||||
|
||||
@@ -5,7 +5,6 @@ mod end_trial_upsell;
|
||||
mod new_thread_button;
|
||||
mod onboarding_modal;
|
||||
pub mod preview;
|
||||
mod upsell;
|
||||
|
||||
pub use agent_notification::*;
|
||||
pub use burn_mode_tooltip::*;
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use ai_onboarding::{AgentPanelOnboardingCard, BulletItem};
|
||||
use client::zed_urls;
|
||||
use gpui::{AnyElement, App, IntoElement, RenderOnce, Window};
|
||||
use ui::{Divider, List, prelude::*};
|
||||
use ui::{Divider, List, Tooltip, prelude::*};
|
||||
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct EndTrialUpsell {
|
||||
@@ -33,14 +33,19 @@ impl RenderOnce for EndTrialUpsell {
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new("500 prompts per month with Claude models"))
|
||||
.child(BulletItem::new("Unlimited edit predictions")),
|
||||
.child(BulletItem::new("500 prompts with Claude models"))
|
||||
.child(BulletItem::new(
|
||||
"Unlimited edit predictions with Zeta, our open-source model",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("cta-button", "Upgrade to Zed Pro")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))),
|
||||
.on_click(move |_, _window, cx| {
|
||||
telemetry::event!("Upgrade To Pro Clicked", state = "end-of-trial");
|
||||
cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx))
|
||||
}),
|
||||
);
|
||||
|
||||
let free_section = v_flex()
|
||||
@@ -55,37 +60,43 @@ impl RenderOnce for EndTrialUpsell {
|
||||
.color(Color::Muted)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(
|
||||
Label::new("(Current Plan)")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Custom(cx.theme().colors().text_muted.opacity(0.6)))
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(Divider::horizontal()),
|
||||
)
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new(
|
||||
"50 prompts per month with the Claude models",
|
||||
))
|
||||
.child(BulletItem::new(
|
||||
"2000 accepted edit predictions using our open-source Zeta model",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("dismiss-button", "Stay on Free")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Outlined)
|
||||
.on_click({
|
||||
let callback = self.dismiss_upsell.clone();
|
||||
move |_, window, cx| callback(window, cx)
|
||||
}),
|
||||
.child(BulletItem::new("50 prompts with the Claude models"))
|
||||
.child(BulletItem::new("2,000 accepted edit predictions")),
|
||||
);
|
||||
|
||||
AgentPanelOnboardingCard::new()
|
||||
.child(Headline::new("Your Zed Pro trial has expired."))
|
||||
.child(Headline::new("Your Zed Pro Trial has expired"))
|
||||
.child(
|
||||
Label::new("You've been automatically reset to the Free plan.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted)
|
||||
.mb_1(),
|
||||
.mb_2(),
|
||||
)
|
||||
.child(pro_section)
|
||||
.child(free_section)
|
||||
.child(
|
||||
h_flex().absolute().top_4().right_4().child(
|
||||
IconButton::new("dismiss_onboarding", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Dismiss"))
|
||||
.on_click({
|
||||
let callback = self.dismiss_upsell.clone();
|
||||
move |_, window, cx| {
|
||||
telemetry::event!("Banner Dismissed", source = "AI Onboarding");
|
||||
callback(window, cx)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
use component::{Component, ComponentScope, single_example};
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
Button, ButtonCommon, ButtonStyle, Checkbox, Clickable, Color, Label, LabelCommon,
|
||||
RegisterComponent, ToggleState, h_flex, v_flex,
|
||||
};
|
||||
|
||||
/// A component that displays an upsell message with a call-to-action button
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// let upsell = Upsell::new(
|
||||
/// "Upgrade to Zed Pro",
|
||||
/// "Get access to advanced AI features and more",
|
||||
/// "Upgrade Now",
|
||||
/// Box::new(|_, _window, cx| {
|
||||
/// cx.open_url("https://zed.dev/pricing");
|
||||
/// }),
|
||||
/// Box::new(|_, _window, cx| {
|
||||
/// // Handle dismiss
|
||||
/// }),
|
||||
/// Box::new(|checked, window, cx| {
|
||||
/// // Handle don't show again
|
||||
/// }),
|
||||
/// );
|
||||
/// ```
|
||||
#[derive(IntoElement, RegisterComponent)]
|
||||
pub struct Upsell {
|
||||
title: SharedString,
|
||||
message: SharedString,
|
||||
cta_text: SharedString,
|
||||
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
|
||||
on_dismiss: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
|
||||
on_dont_show_again: Box<dyn Fn(bool, &mut Window, &mut App) + 'static>,
|
||||
}
|
||||
|
||||
impl Upsell {
|
||||
/// Create a new upsell component
|
||||
pub fn new(
|
||||
title: impl Into<SharedString>,
|
||||
message: impl Into<SharedString>,
|
||||
cta_text: impl Into<SharedString>,
|
||||
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
|
||||
on_dismiss: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
|
||||
on_dont_show_again: Box<dyn Fn(bool, &mut Window, &mut App) + 'static>,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
message: message.into(),
|
||||
cta_text: cta_text.into(),
|
||||
on_click,
|
||||
on_dismiss,
|
||||
on_dont_show_again,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Upsell {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.p_4()
|
||||
.gap_3()
|
||||
.bg(cx.theme().colors().surface_background)
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(cx.theme().colors().border)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new(self.title)
|
||||
.size(ui::LabelSize::Large)
|
||||
.weight(gpui::FontWeight::BOLD),
|
||||
)
|
||||
.child(Label::new(self.message).color(Color::Muted)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(
|
||||
Checkbox::new("dont-show-again", ToggleState::Unselected).on_click(
|
||||
move |_, window, cx| {
|
||||
(self.on_dont_show_again)(true, window, cx);
|
||||
},
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Label::new("Don't show again")
|
||||
.color(Color::Muted)
|
||||
.size(ui::LabelSize::Small),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("dismiss-button", "No Thanks")
|
||||
.style(ButtonStyle::Subtle)
|
||||
.on_click(self.on_dismiss),
|
||||
)
|
||||
.child(
|
||||
Button::new("cta-button", self.cta_text)
|
||||
.style(ButtonStyle::Filled)
|
||||
.on_click(self.on_click),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Upsell {
|
||||
fn scope() -> ComponentScope {
|
||||
ComponentScope::Agent
|
||||
}
|
||||
|
||||
fn name() -> &'static str {
|
||||
"Upsell"
|
||||
}
|
||||
|
||||
fn description() -> Option<&'static str> {
|
||||
Some("A promotional component that displays a message with a call-to-action.")
|
||||
}
|
||||
|
||||
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
|
||||
let examples = vec![
|
||||
single_example(
|
||||
"Default",
|
||||
Upsell::new(
|
||||
"Upgrade to Zed Pro",
|
||||
"Get unlimited access to AI features and more with Zed Pro. Unlock advanced AI capabilities and other premium features.",
|
||||
"Upgrade Now",
|
||||
Box::new(|_, _, _| {}),
|
||||
Box::new(|_, _, _| {}),
|
||||
Box::new(|_, _, _| {}),
|
||||
).render(window, cx).into_any_element(),
|
||||
),
|
||||
single_example(
|
||||
"Short Message",
|
||||
Upsell::new(
|
||||
"Try Zed Pro for free",
|
||||
"Start your 7-day trial today.",
|
||||
"Start Trial",
|
||||
Box::new(|_, _, _| {}),
|
||||
Box::new(|_, _, _| {}),
|
||||
Box::new(|_, _, _| {}),
|
||||
).render(window, cx).into_any_element(),
|
||||
),
|
||||
];
|
||||
|
||||
Some(v_flex().gap_4().children(examples).into_any_element())
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,11 @@ impl Render for AgentPanelOnboarding {
|
||||
Some(proto::Plan::ZedProTrial)
|
||||
);
|
||||
|
||||
let is_pro_user = matches!(
|
||||
self.user_store.read(cx).current_plan(),
|
||||
Some(proto::Plan::ZedPro)
|
||||
);
|
||||
|
||||
AgentPanelOnboardingCard::new()
|
||||
.child(
|
||||
ZedAiOnboarding::new(
|
||||
@@ -75,7 +80,7 @@ impl Render for AgentPanelOnboarding {
|
||||
}),
|
||||
)
|
||||
.map(|this| {
|
||||
if enrolled_in_trial || self.configured_providers.len() >= 1 {
|
||||
if enrolled_in_trial || is_pro_user || self.configured_providers.len() >= 1 {
|
||||
this
|
||||
} else {
|
||||
this.child(ApiKeysWithoutProviders::new())
|
||||
|
||||
@@ -16,6 +16,7 @@ use client::{Client, UserStore, zed_urls};
|
||||
use gpui::{AnyElement, Entity, IntoElement, ParentElement, SharedString};
|
||||
use ui::{Divider, List, ListItem, RegisterComponent, TintColor, Tooltip, prelude::*};
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct BulletItem {
|
||||
label: SharedString,
|
||||
}
|
||||
@@ -28,18 +29,27 @@ impl BulletItem {
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for BulletItem {
|
||||
type Element = AnyElement;
|
||||
impl RenderOnce for BulletItem {
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let line_height = 0.85 * window.line_height();
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
ListItem::new("list-item")
|
||||
.selectable(false)
|
||||
.start_slot(
|
||||
Icon::new(IconName::Dash)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Hidden),
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.min_w_0()
|
||||
.gap_1()
|
||||
.items_start()
|
||||
.child(
|
||||
h_flex().h(line_height).justify_center().child(
|
||||
Icon::new(IconName::Dash)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Hidden),
|
||||
),
|
||||
)
|
||||
.child(div().w_full().min_w_0().child(Label::new(self.label))),
|
||||
)
|
||||
.child(div().w_full().child(Label::new(self.label)))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -237,7 +247,7 @@ impl ZedAiOnboarding {
|
||||
.icon_color(Color::Muted)
|
||||
.icon_size(IconSize::XSmall)
|
||||
.on_click(move |_, _window, cx| {
|
||||
telemetry::event!("Review Terms of Service Click");
|
||||
telemetry::event!("Review Terms of Service Clicked");
|
||||
cx.open_url(&zed_urls::terms_of_service(cx))
|
||||
}),
|
||||
)
|
||||
@@ -248,7 +258,7 @@ impl ZedAiOnboarding {
|
||||
.on_click({
|
||||
let callback = self.accept_terms_of_service.clone();
|
||||
move |_, window, cx| {
|
||||
telemetry::event!("Accepted Terms of Service");
|
||||
telemetry::event!("Terms of Service Accepted");
|
||||
(callback)(window, cx)}
|
||||
}),
|
||||
)
|
||||
@@ -373,7 +383,9 @@ impl ZedAiOnboarding {
|
||||
.child(
|
||||
List::new()
|
||||
.child(BulletItem::new("500 prompts with Claude models"))
|
||||
.child(BulletItem::new("Unlimited edit predictions")),
|
||||
.child(BulletItem::new(
|
||||
"Unlimited edit predictions with Zeta, our open-source model",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("pro", "Continue with Zed Pro")
|
||||
|
||||
@@ -767,6 +767,11 @@ impl ContextStore {
|
||||
fn reload(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let fs = self.fs.clone();
|
||||
cx.spawn(async move |this, cx| {
|
||||
pub static ZED_STATELESS: LazyLock<bool> =
|
||||
LazyLock::new(|| std::env::var("ZED_STATELESS").map_or(false, |v| !v.is_empty()));
|
||||
if *ZED_STATELESS {
|
||||
return Ok(());
|
||||
}
|
||||
fs.create_dir(contexts_dir()).await?;
|
||||
|
||||
let mut paths = fs.read_dir(contexts_dir()).await?;
|
||||
|
||||
@@ -765,12 +765,14 @@ impl UserStore {
|
||||
|
||||
pub fn current_plan(&self) -> Option<proto::Plan> {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(plan) = std::env::var("ZED_SIMULATE_ZED_PRO_PLAN").as_ref() {
|
||||
if let Ok(plan) = std::env::var("ZED_SIMULATE_PLAN").as_ref() {
|
||||
return match plan.as_str() {
|
||||
"free" => Some(proto::Plan::Free),
|
||||
"trial" => Some(proto::Plan::ZedProTrial),
|
||||
"pro" => Some(proto::Plan::ZedPro),
|
||||
_ => None,
|
||||
_ => {
|
||||
panic!("ZED_SIMULATE_PLAN must be one of 'free', 'trial', or 'pro'");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,8 @@ use collections::{HashMap, HashSet};
|
||||
use reqwest::StatusCode;
|
||||
use sea_orm::ActiveValue;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use stripe::{
|
||||
BillingPortalSession, CancellationDetailsReason, CreateBillingPortalSession,
|
||||
CreateBillingPortalSessionFlowData, CreateBillingPortalSessionFlowDataAfterCompletion,
|
||||
CreateBillingPortalSessionFlowDataAfterCompletionRedirect,
|
||||
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm,
|
||||
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems,
|
||||
CreateBillingPortalSessionFlowDataType, CustomerId, EventObject, EventType, ListEvents,
|
||||
PaymentMethod, Subscription, SubscriptionId, SubscriptionStatus,
|
||||
};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use stripe::{CancellationDetailsReason, EventObject, EventType, ListEvents, SubscriptionStatus};
|
||||
use util::{ResultExt, maybe};
|
||||
use zed_llm_client::LanguageModelProvider;
|
||||
|
||||
@@ -31,7 +23,7 @@ use crate::{AppState, Error, Result};
|
||||
use crate::{db::UserId, llm::db::LlmDatabase};
|
||||
use crate::{
|
||||
db::{
|
||||
BillingSubscriptionId, CreateBillingCustomerParams, CreateBillingSubscriptionParams,
|
||||
CreateBillingCustomerParams, CreateBillingSubscriptionParams,
|
||||
CreateProcessedStripeEventParams, UpdateBillingCustomerParams,
|
||||
UpdateBillingSubscriptionParams, billing_customer,
|
||||
},
|
||||
@@ -39,260 +31,10 @@ use crate::{
|
||||
};
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/billing/subscriptions/manage",
|
||||
post(manage_billing_subscription),
|
||||
)
|
||||
.route(
|
||||
"/billing/subscriptions/sync",
|
||||
post(sync_billing_subscription),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ManageSubscriptionIntent {
|
||||
/// The user intends to manage their subscription.
|
||||
///
|
||||
/// This will open the Stripe billing portal without putting the user in a specific flow.
|
||||
ManageSubscription,
|
||||
/// The user intends to update their payment method.
|
||||
UpdatePaymentMethod,
|
||||
/// The user intends to upgrade to Zed Pro.
|
||||
UpgradeToPro,
|
||||
/// The user intends to cancel their subscription.
|
||||
Cancel,
|
||||
/// The user intends to stop the cancellation of their subscription.
|
||||
StopCancellation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManageBillingSubscriptionBody {
|
||||
github_user_id: i32,
|
||||
intent: ManageSubscriptionIntent,
|
||||
/// The ID of the subscription to manage.
|
||||
subscription_id: BillingSubscriptionId,
|
||||
redirect_to: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ManageBillingSubscriptionResponse {
|
||||
billing_portal_session_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Initiates a Stripe customer portal session for managing a billing subscription.
|
||||
async fn manage_billing_subscription(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
extract::Json(body): extract::Json<ManageBillingSubscriptionBody>,
|
||||
) -> Result<Json<ManageBillingSubscriptionResponse>> {
|
||||
let user = app
|
||||
.db
|
||||
.get_user_by_github_user_id(body.github_user_id)
|
||||
.await?
|
||||
.context("user not found")?;
|
||||
|
||||
let Some(stripe_client) = app.real_stripe_client.clone() else {
|
||||
log::error!("failed to retrieve Stripe client");
|
||||
Err(Error::http(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"not supported".into(),
|
||||
))?
|
||||
};
|
||||
|
||||
let Some(stripe_billing) = app.stripe_billing.clone() else {
|
||||
log::error!("failed to retrieve Stripe billing object");
|
||||
Err(Error::http(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"not supported".into(),
|
||||
))?
|
||||
};
|
||||
|
||||
let customer = app
|
||||
.db
|
||||
.get_billing_customer_by_user_id(user.id)
|
||||
.await?
|
||||
.context("billing customer not found")?;
|
||||
let customer_id = CustomerId::from_str(&customer.stripe_customer_id)
|
||||
.context("failed to parse customer ID")?;
|
||||
|
||||
let subscription = app
|
||||
.db
|
||||
.get_billing_subscription_by_id(body.subscription_id)
|
||||
.await?
|
||||
.context("subscription not found")?;
|
||||
let subscription_id = SubscriptionId::from_str(&subscription.stripe_subscription_id)
|
||||
.context("failed to parse subscription ID")?;
|
||||
|
||||
if body.intent == ManageSubscriptionIntent::StopCancellation {
|
||||
let updated_stripe_subscription = Subscription::update(
|
||||
&stripe_client,
|
||||
&subscription_id,
|
||||
stripe::UpdateSubscription {
|
||||
cancel_at_period_end: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
app.db
|
||||
.update_billing_subscription(
|
||||
subscription.id,
|
||||
&UpdateBillingSubscriptionParams {
|
||||
stripe_cancel_at: ActiveValue::set(
|
||||
updated_stripe_subscription
|
||||
.cancel_at
|
||||
.and_then(|cancel_at| DateTime::from_timestamp(cancel_at, 0))
|
||||
.map(|time| time.naive_utc()),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(Json(ManageBillingSubscriptionResponse {
|
||||
billing_portal_session_url: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let flow = match body.intent {
|
||||
ManageSubscriptionIntent::ManageSubscription => None,
|
||||
ManageSubscriptionIntent::UpgradeToPro => {
|
||||
let zed_pro_price_id: stripe::PriceId =
|
||||
stripe_billing.zed_pro_price_id().await?.try_into()?;
|
||||
let zed_free_price_id: stripe::PriceId =
|
||||
stripe_billing.zed_free_price_id().await?.try_into()?;
|
||||
|
||||
let stripe_subscription =
|
||||
Subscription::retrieve(&stripe_client, &subscription_id, &[]).await?;
|
||||
|
||||
let is_on_zed_pro_trial = stripe_subscription.status == SubscriptionStatus::Trialing
|
||||
&& stripe_subscription.items.data.iter().any(|item| {
|
||||
item.price
|
||||
.as_ref()
|
||||
.map_or(false, |price| price.id == zed_pro_price_id)
|
||||
});
|
||||
if is_on_zed_pro_trial {
|
||||
let payment_methods = PaymentMethod::list(
|
||||
&stripe_client,
|
||||
&stripe::ListPaymentMethods {
|
||||
customer: Some(stripe_subscription.customer.id()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let has_payment_method = !payment_methods.data.is_empty();
|
||||
if !has_payment_method {
|
||||
return Err(Error::http(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing payment method".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// If the user is already on a Zed Pro trial and wants to upgrade to Pro, we just need to end their trial early.
|
||||
Subscription::update(
|
||||
&stripe_client,
|
||||
&stripe_subscription.id,
|
||||
stripe::UpdateSubscription {
|
||||
trial_end: Some(stripe::Scheduled::now()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(Json(ManageBillingSubscriptionResponse {
|
||||
billing_portal_session_url: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let subscription_item_to_update = stripe_subscription
|
||||
.items
|
||||
.data
|
||||
.iter()
|
||||
.find_map(|item| {
|
||||
let price = item.price.as_ref()?;
|
||||
|
||||
if price.id == zed_free_price_id {
|
||||
Some(item.id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.context("No subscription item to update")?;
|
||||
|
||||
Some(CreateBillingPortalSessionFlowData {
|
||||
type_: CreateBillingPortalSessionFlowDataType::SubscriptionUpdateConfirm,
|
||||
subscription_update_confirm: Some(
|
||||
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirm {
|
||||
subscription: subscription.stripe_subscription_id,
|
||||
items: vec![
|
||||
CreateBillingPortalSessionFlowDataSubscriptionUpdateConfirmItems {
|
||||
id: subscription_item_to_update.to_string(),
|
||||
price: Some(zed_pro_price_id.to_string()),
|
||||
quantity: Some(1),
|
||||
},
|
||||
],
|
||||
discounts: None,
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
ManageSubscriptionIntent::UpdatePaymentMethod => Some(CreateBillingPortalSessionFlowData {
|
||||
type_: CreateBillingPortalSessionFlowDataType::PaymentMethodUpdate,
|
||||
after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
|
||||
type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
|
||||
redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
|
||||
return_url: format!(
|
||||
"{}{path}",
|
||||
app.config.zed_dot_dev_url(),
|
||||
path = body.redirect_to.unwrap_or_else(|| "/account".to_string())
|
||||
),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
ManageSubscriptionIntent::Cancel => {
|
||||
if subscription.kind == Some(SubscriptionKind::ZedFree) {
|
||||
return Err(Error::http(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"free subscription cannot be canceled".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Some(CreateBillingPortalSessionFlowData {
|
||||
type_: CreateBillingPortalSessionFlowDataType::SubscriptionCancel,
|
||||
after_completion: Some(CreateBillingPortalSessionFlowDataAfterCompletion {
|
||||
type_: stripe::CreateBillingPortalSessionFlowDataAfterCompletionType::Redirect,
|
||||
redirect: Some(CreateBillingPortalSessionFlowDataAfterCompletionRedirect {
|
||||
return_url: format!("{}/account", app.config.zed_dot_dev_url()),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
subscription_cancel: Some(
|
||||
stripe::CreateBillingPortalSessionFlowDataSubscriptionCancel {
|
||||
subscription: subscription.stripe_subscription_id,
|
||||
retention: None,
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
ManageSubscriptionIntent::StopCancellation => unreachable!(),
|
||||
};
|
||||
|
||||
let mut params = CreateBillingPortalSession::new(customer_id);
|
||||
params.flow_data = flow;
|
||||
let return_url = format!("{}/account", app.config.zed_dot_dev_url());
|
||||
params.return_url = Some(&return_url);
|
||||
|
||||
let session = BillingPortalSession::create(&stripe_client, params).await?;
|
||||
|
||||
Ok(Json(ManageBillingSubscriptionResponse {
|
||||
billing_portal_session_url: Some(session.url),
|
||||
}))
|
||||
Router::new().route(
|
||||
"/billing/subscriptions/sync",
|
||||
post(sync_billing_subscription),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -365,6 +365,8 @@ actions!(
|
||||
ConvertToLowerCase,
|
||||
/// Toggles the case of selected text.
|
||||
ConvertToOppositeCase,
|
||||
/// Converts selected text to sentence case.
|
||||
ConvertToSentenceCase,
|
||||
/// Converts selected text to snake_case.
|
||||
ConvertToSnakeCase,
|
||||
/// Converts selected text to Title Case.
|
||||
|
||||
@@ -844,7 +844,7 @@ impl CompletionsMenu {
|
||||
.with_sizing_behavior(ListSizingBehavior::Infer)
|
||||
.w(rems(34.));
|
||||
|
||||
Popover::new().child(list).into_any_element()
|
||||
Popover::new().child(div().child(list)).into_any_element()
|
||||
}
|
||||
|
||||
fn render_aside(
|
||||
|
||||
@@ -10878,17 +10878,6 @@ impl Editor {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.manipulate_text(window, cx, |text| {
|
||||
let has_upper_case_characters = text.chars().any(|c| c.is_uppercase());
|
||||
if has_upper_case_characters {
|
||||
text.to_lowercase()
|
||||
} else {
|
||||
text.to_uppercase()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn manipulate_immutable_lines<Fn>(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
@@ -11144,6 +11133,26 @@ impl Editor {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_to_sentence_case(
|
||||
&mut self,
|
||||
_: &ConvertToSentenceCase,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.manipulate_text(window, cx, |text| text.to_case(Case::Sentence))
|
||||
}
|
||||
|
||||
pub fn toggle_case(&mut self, _: &ToggleCase, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.manipulate_text(window, cx, |text| {
|
||||
let has_upper_case_characters = text.chars().any(|c| c.is_uppercase());
|
||||
if has_upper_case_characters {
|
||||
text.to_lowercase()
|
||||
} else {
|
||||
text.to_uppercase()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_to_rot13(
|
||||
&mut self,
|
||||
_: &ConvertToRot13,
|
||||
@@ -16968,7 +16977,7 @@ impl Editor {
|
||||
now: Instant,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
) -> Option<TransactionId> {
|
||||
self.end_selection(window, cx);
|
||||
if let Some(tx_id) = self
|
||||
.buffer
|
||||
@@ -16978,7 +16987,10 @@ impl Editor {
|
||||
.insert_transaction(tx_id, self.selections.disjoint_anchors());
|
||||
cx.emit(EditorEvent::TransactionBegun {
|
||||
transaction_id: tx_id,
|
||||
})
|
||||
});
|
||||
Some(tx_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17006,6 +17018,17 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modify_transaction_selection_history(
|
||||
&mut self,
|
||||
transaction_id: TransactionId,
|
||||
modify: impl FnOnce(&mut (Arc<[Selection<Anchor>]>, Option<Arc<[Selection<Anchor>]>>)),
|
||||
) -> bool {
|
||||
self.selection_history
|
||||
.transaction_mut(transaction_id)
|
||||
.map(modify)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn set_mark(&mut self, _: &actions::SetMark, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selection_mark_mode {
|
||||
self.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
@@ -22258,7 +22281,7 @@ fn consume_contiguous_rows(
|
||||
selections: &mut Peekable<std::slice::Iter<Selection<Point>>>,
|
||||
) -> (MultiBufferRow, MultiBufferRow) {
|
||||
contiguous_row_selections.push(selection.clone());
|
||||
let start_row = MultiBufferRow(selection.start.row);
|
||||
let start_row = starting_row(selection, display_map);
|
||||
let mut end_row = ending_row(selection, display_map);
|
||||
|
||||
while let Some(next_selection) = selections.peek() {
|
||||
@@ -22272,6 +22295,14 @@ fn consume_contiguous_rows(
|
||||
(start_row, end_row)
|
||||
}
|
||||
|
||||
fn starting_row(selection: &Selection<Point>, display_map: &DisplaySnapshot) -> MultiBufferRow {
|
||||
if selection.start.column > 0 {
|
||||
MultiBufferRow(display_map.prev_line_boundary(selection.start).0.row)
|
||||
} else {
|
||||
MultiBufferRow(selection.start.row)
|
||||
}
|
||||
}
|
||||
|
||||
fn ending_row(next_selection: &Selection<Point>, display_map: &DisplaySnapshot) -> MultiBufferRow {
|
||||
if next_selection.end.column > 0 || next_selection.is_empty() {
|
||||
MultiBufferRow(display_map.next_line_boundary(next_selection.end).0.row + 1)
|
||||
|
||||
@@ -4724,6 +4724,23 @@ async fn test_toggle_case(cx: &mut TestAppContext) {
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_convert_to_sentence_case(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
cx.set_state(indoc! {"
|
||||
«implement-windows-supportˇ»
|
||||
"});
|
||||
cx.update_editor(|e, window, cx| {
|
||||
e.convert_to_sentence_case(&ConvertToSentenceCase, window, cx)
|
||||
});
|
||||
cx.assert_editor_state(indoc! {"
|
||||
«Implement windows supportˇ»
|
||||
"});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_manipulate_text(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -5069,6 +5086,33 @@ fn test_move_line_up_down(cx: &mut TestAppContext) {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_move_line_up_selection_at_end_of_fold(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
let editor = cx.add_window(|window, cx| {
|
||||
let buffer = MultiBuffer::build_simple("\n\n\n\n\n\naaaa\nbbbb\ncccc", cx);
|
||||
build_editor(buffer, window, cx)
|
||||
});
|
||||
_ = editor.update(cx, |editor, window, cx| {
|
||||
editor.fold_creases(
|
||||
vec![Crease::simple(
|
||||
Point::new(6, 4)..Point::new(7, 4),
|
||||
FoldPlaceholder::test(),
|
||||
)],
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_ranges([Point::new(7, 4)..Point::new(7, 4)])
|
||||
});
|
||||
assert_eq!(editor.display_text(cx), "\n\n\n\n\n\naaaa⋯\ncccc");
|
||||
editor.move_line_up(&MoveLineUp, window, cx);
|
||||
let buffer_text = editor.buffer.read(cx).snapshot(cx).text();
|
||||
assert_eq!(buffer_text, "\n\n\n\n\naaaa\nbbbb\n\ncccc");
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
@@ -16837,7 +16881,7 @@ async fn test_multibuffer_reverts(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_mutlibuffer_in_navigation_history(cx: &mut TestAppContext) {
|
||||
async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) {
|
||||
init_test(cx, |_| {});
|
||||
|
||||
let cols = 4;
|
||||
|
||||
@@ -230,7 +230,6 @@ impl EditorElement {
|
||||
register_action(editor, window, Editor::sort_lines_case_insensitive);
|
||||
register_action(editor, window, Editor::reverse_lines);
|
||||
register_action(editor, window, Editor::shuffle_lines);
|
||||
register_action(editor, window, Editor::toggle_case);
|
||||
register_action(editor, window, Editor::convert_indentation_to_spaces);
|
||||
register_action(editor, window, Editor::convert_indentation_to_tabs);
|
||||
register_action(editor, window, Editor::convert_to_upper_case);
|
||||
@@ -241,6 +240,8 @@ impl EditorElement {
|
||||
register_action(editor, window, Editor::convert_to_upper_camel_case);
|
||||
register_action(editor, window, Editor::convert_to_lower_camel_case);
|
||||
register_action(editor, window, Editor::convert_to_opposite_case);
|
||||
register_action(editor, window, Editor::convert_to_sentence_case);
|
||||
register_action(editor, window, Editor::toggle_case);
|
||||
register_action(editor, window, Editor::convert_to_rot13);
|
||||
register_action(editor, window, Editor::convert_to_rot47);
|
||||
register_action(editor, window, Editor::delete_to_previous_word_start);
|
||||
@@ -4010,6 +4011,7 @@ impl EditorElement {
|
||||
let available_width = hitbox.bounds.size.width - right_margin;
|
||||
|
||||
let mut header = v_flex()
|
||||
.w_full()
|
||||
.relative()
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -85,6 +85,11 @@ impl FeatureFlag for ThreadAutoCaptureFeatureFlag {
|
||||
false
|
||||
}
|
||||
}
|
||||
pub struct PanicFeatureFlag;
|
||||
|
||||
impl FeatureFlag for PanicFeatureFlag {
|
||||
const NAME: &'static str = "panic";
|
||||
}
|
||||
|
||||
pub struct JjUiFeatureFlag {}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use language::{self, Buffer, Point};
|
||||
use project::Project;
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
cmp,
|
||||
ops::Range,
|
||||
pin::pin,
|
||||
sync::Arc,
|
||||
@@ -45,38 +46,60 @@ impl TextDiffView {
|
||||
) -> Option<Task<Result<Entity<Self>>>> {
|
||||
let source_editor = diff_data.editor.clone();
|
||||
|
||||
let source_editor_buffer_and_range = source_editor.update(cx, |editor, cx| {
|
||||
let selection_data = source_editor.update(cx, |editor, cx| {
|
||||
let multibuffer = editor.buffer().read(cx);
|
||||
let source_buffer = multibuffer.as_singleton()?.clone();
|
||||
let selections = editor.selections.all::<Point>(cx);
|
||||
let buffer_snapshot = source_buffer.read(cx);
|
||||
let first_selection = selections.first()?;
|
||||
let selection_range = if first_selection.is_empty() {
|
||||
Point::new(0, 0)..buffer_snapshot.max_point()
|
||||
} else {
|
||||
first_selection.start..first_selection.end
|
||||
};
|
||||
let max_point = buffer_snapshot.max_point();
|
||||
|
||||
Some((source_buffer, selection_range))
|
||||
if first_selection.is_empty() {
|
||||
let full_range = Point::new(0, 0)..max_point;
|
||||
return Some((source_buffer, full_range));
|
||||
}
|
||||
|
||||
let start = first_selection.start;
|
||||
let end = first_selection.end;
|
||||
let expanded_start = Point::new(start.row, 0);
|
||||
|
||||
let expanded_end = if end.column > 0 {
|
||||
let next_row = end.row + 1;
|
||||
cmp::min(max_point, Point::new(next_row, 0))
|
||||
} else {
|
||||
end
|
||||
};
|
||||
Some((source_buffer, expanded_start..expanded_end))
|
||||
});
|
||||
|
||||
let Some((source_buffer, selected_range)) = source_editor_buffer_and_range else {
|
||||
let Some((source_buffer, expanded_selection_range)) = selection_data else {
|
||||
log::warn!("There should always be at least one selection in Zed. This is a bug.");
|
||||
return None;
|
||||
};
|
||||
|
||||
let clipboard_text = diff_data.clipboard_text.clone();
|
||||
|
||||
let workspace = workspace.weak_handle();
|
||||
|
||||
let diff_buffer = cx.new(|cx| {
|
||||
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
|
||||
let diff = BufferDiff::new(&source_buffer_snapshot.text, cx);
|
||||
diff
|
||||
source_editor.update(cx, |source_editor, cx| {
|
||||
source_editor.change_selections(Default::default(), window, cx, |s| {
|
||||
s.select_ranges(vec![
|
||||
expanded_selection_range.start..expanded_selection_range.end,
|
||||
]);
|
||||
})
|
||||
});
|
||||
|
||||
let clipboard_buffer =
|
||||
build_clipboard_buffer(clipboard_text, &source_buffer, selected_range.clone(), cx);
|
||||
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
|
||||
let mut clipboard_text = diff_data.clipboard_text.clone();
|
||||
|
||||
if !clipboard_text.ends_with("\n") {
|
||||
clipboard_text.push_str("\n");
|
||||
}
|
||||
|
||||
let workspace = workspace.weak_handle();
|
||||
let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
|
||||
let clipboard_buffer = build_clipboard_buffer(
|
||||
clipboard_text,
|
||||
&source_buffer,
|
||||
expanded_selection_range.clone(),
|
||||
cx,
|
||||
);
|
||||
|
||||
let task = window.spawn(cx, async move |cx| {
|
||||
let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
|
||||
@@ -89,7 +112,7 @@ impl TextDiffView {
|
||||
clipboard_buffer,
|
||||
source_editor,
|
||||
source_buffer,
|
||||
selected_range,
|
||||
expanded_selection_range,
|
||||
diff_buffer,
|
||||
project,
|
||||
window,
|
||||
@@ -208,9 +231,9 @@ impl TextDiffView {
|
||||
}
|
||||
|
||||
fn build_clipboard_buffer(
|
||||
clipboard_text: String,
|
||||
text: String,
|
||||
source_buffer: &Entity<Buffer>,
|
||||
selected_range: Range<Point>,
|
||||
replacement_range: Range<Point>,
|
||||
cx: &mut App,
|
||||
) -> Entity<Buffer> {
|
||||
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
|
||||
@@ -219,9 +242,9 @@ fn build_clipboard_buffer(
|
||||
let language = source_buffer.read(cx).language().cloned();
|
||||
buffer.set_language(language, cx);
|
||||
|
||||
let range_start = source_buffer_snapshot.point_to_offset(selected_range.start);
|
||||
let range_end = source_buffer_snapshot.point_to_offset(selected_range.end);
|
||||
buffer.edit([(range_start..range_end, clipboard_text)], None, cx);
|
||||
let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
|
||||
let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
|
||||
buffer.edit([(range_start..range_end, text)], None, cx);
|
||||
|
||||
buffer
|
||||
})
|
||||
@@ -293,7 +316,7 @@ impl Item for TextDiffView {
|
||||
}
|
||||
|
||||
fn telemetry_event_text(&self) -> Option<&'static str> {
|
||||
Some("Diff View Opened")
|
||||
Some("Selection Diff View Opened")
|
||||
}
|
||||
|
||||
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -395,21 +418,13 @@ pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
|
||||
let buffer_snapshot = buffer.snapshot(cx);
|
||||
let first_selection = editor.selections.disjoint.first()?;
|
||||
|
||||
let (start_row, start_column, end_row, end_column) =
|
||||
if first_selection.start == first_selection.end {
|
||||
let max_point = buffer_snapshot.max_point();
|
||||
(0, 0, max_point.row, max_point.column)
|
||||
} else {
|
||||
let selection_start = first_selection.start.to_point(&buffer_snapshot);
|
||||
let selection_end = first_selection.end.to_point(&buffer_snapshot);
|
||||
let selection_start = first_selection.start.to_point(&buffer_snapshot);
|
||||
let selection_end = first_selection.end.to_point(&buffer_snapshot);
|
||||
|
||||
(
|
||||
selection_start.row,
|
||||
selection_start.column,
|
||||
selection_end.row,
|
||||
selection_end.column,
|
||||
)
|
||||
};
|
||||
let start_row = selection_start.row;
|
||||
let start_column = selection_start.column;
|
||||
let end_row = selection_end.row;
|
||||
let end_column = selection_end.column;
|
||||
|
||||
let range_text = if start_row == end_row {
|
||||
format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
|
||||
@@ -435,14 +450,13 @@ impl Render for TextDiffView {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use editor::{actions, test::editor_test_context::assert_state_with_diff};
|
||||
use editor::test::editor_test_context::assert_state_with_diff;
|
||||
use gpui::{TestAppContext, VisualContext};
|
||||
use project::{FakeFs, Project};
|
||||
use serde_json::json;
|
||||
use settings::{Settings, SettingsStore};
|
||||
use unindent::unindent;
|
||||
use util::path;
|
||||
use util::{path, test::marked_text_ranges};
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
@@ -457,52 +471,236 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_against_specific_selection(cx: &mut TestAppContext) {
|
||||
base_test(true, cx).await;
|
||||
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
|
||||
"def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
|
||||
&unindent(
|
||||
"
|
||||
- def process_incoming_inventory(items, warehouse_id):
|
||||
+ ˇdef process_outgoing_inventory(items, warehouse_id):
|
||||
pass
|
||||
",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-L3:1",
|
||||
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer(
|
||||
async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
base_test(false, cx).await;
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
|
||||
"«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
|
||||
&unindent(
|
||||
"
|
||||
- def process_incoming_inventory(items, warehouse_id):
|
||||
+ ˇdef process_outgoing_inventory(items, warehouse_id):
|
||||
pass
|
||||
",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-L3:1",
|
||||
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn base_test(select_all_text: bool, cx: &mut TestAppContext) {
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
"a",
|
||||
"«bbˇ»",
|
||||
&unindent(
|
||||
"
|
||||
- a
|
||||
+ ˇbb",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-3",
|
||||
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
" a",
|
||||
"«bbˇ»",
|
||||
&unindent(
|
||||
"
|
||||
- a
|
||||
+ ˇbb",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-3",
|
||||
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
"a",
|
||||
" «bbˇ»",
|
||||
&unindent(
|
||||
"
|
||||
- a
|
||||
+ ˇ bb",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-7",
|
||||
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
"a",
|
||||
"« bbˇ»",
|
||||
&unindent(
|
||||
"
|
||||
- a
|
||||
+ ˇ bb",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-7",
|
||||
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
" a",
|
||||
" «bbˇ»",
|
||||
&unindent(
|
||||
"
|
||||
- a
|
||||
+ ˇ bb",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-7",
|
||||
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
" a",
|
||||
"« bbˇ»",
|
||||
&unindent(
|
||||
"
|
||||
- a
|
||||
+ ˇ bb",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-7",
|
||||
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
base_test(
|
||||
path!("/test"),
|
||||
path!("/test/text.txt"),
|
||||
"a",
|
||||
"«bˇ»b",
|
||||
&unindent(
|
||||
"
|
||||
- a
|
||||
+ ˇbb",
|
||||
),
|
||||
"Clipboard ↔ text.txt @ L1:1-3",
|
||||
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn base_test(
|
||||
project_root: &str,
|
||||
file_path: &str,
|
||||
clipboard_text: &str,
|
||||
editor_text: &str,
|
||||
expected_diff: &str,
|
||||
expected_tab_title: &str,
|
||||
expected_tab_tooltip: &str,
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
init_test(cx);
|
||||
|
||||
let file_name = std::path::Path::new(file_path)
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/test"),
|
||||
project_root,
|
||||
json!({
|
||||
"a": {
|
||||
"b": {
|
||||
"text.txt": "new line 1\nline 2\nnew line 3\nline 4"
|
||||
}
|
||||
}
|
||||
file_name: editor_text
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/test").as_ref()], cx).await;
|
||||
let project = Project::test(fs, [project_root.as_ref()], cx).await;
|
||||
|
||||
let (workspace, mut cx) =
|
||||
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
|
||||
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer(path!("/test/a/b/text.txt"), cx)
|
||||
})
|
||||
.update(cx, |project, cx| project.open_local_buffer(file_path, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let editor = cx.new_window_entity(|window, cx| {
|
||||
let mut editor = Editor::for_buffer(buffer, None, window, cx);
|
||||
editor.set_text("new line 1\nline 2\nnew line 3\nline 4\n", window, cx);
|
||||
|
||||
if select_all_text {
|
||||
editor.select_all(&actions::SelectAll, window, cx);
|
||||
}
|
||||
let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
|
||||
editor.set_text(unmarked_text, window, cx);
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
s.select_ranges(selection_ranges)
|
||||
});
|
||||
|
||||
editor
|
||||
});
|
||||
@@ -511,7 +709,7 @@ mod tests {
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
TextDiffView::open(
|
||||
&DiffClipboardWithSelectionData {
|
||||
clipboard_text: "old line 1\nline 2\nold line 3\nline 4\n".to_string(),
|
||||
clipboard_text: clipboard_text.to_string(),
|
||||
editor,
|
||||
},
|
||||
workspace,
|
||||
@@ -528,26 +726,14 @@ mod tests {
|
||||
assert_state_with_diff(
|
||||
&diff_view.read_with(cx, |diff_view, _| diff_view.diff_editor.clone()),
|
||||
&mut cx,
|
||||
&unindent(
|
||||
"
|
||||
- old line 1
|
||||
+ ˇnew line 1
|
||||
line 2
|
||||
- old line 3
|
||||
+ new line 3
|
||||
line 4
|
||||
",
|
||||
),
|
||||
expected_diff,
|
||||
);
|
||||
|
||||
diff_view.read_with(cx, |diff_view, cx| {
|
||||
assert_eq!(
|
||||
diff_view.tab_content_text(0, cx),
|
||||
"Clipboard ↔ text.txt @ L1:1-L5:1"
|
||||
);
|
||||
assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
|
||||
assert_eq!(
|
||||
diff_view.tab_tooltip_text(cx).unwrap(),
|
||||
format!("Clipboard ↔ {}", path!("test/a/b/text.txt @ L1:1-L5:1"))
|
||||
expected_tab_tooltip
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
strum.workspace = true
|
||||
sum_tree.workspace = true
|
||||
taffy = "=0.5.1"
|
||||
taffy = "=0.8.3"
|
||||
thiserror.workspace = true
|
||||
util.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -1334,7 +1334,6 @@ impl Element for Div {
|
||||
} else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() {
|
||||
let mut state = scroll_handle.0.borrow_mut();
|
||||
state.child_bounds = Vec::with_capacity(request_layout.child_layout_ids.len());
|
||||
state.bounds = bounds;
|
||||
for child_layout_id in &request_layout.child_layout_ids {
|
||||
let child_bounds = window.layout_bounds(*child_layout_id);
|
||||
child_min = child_min.min(&child_bounds.origin);
|
||||
@@ -1706,6 +1705,7 @@ impl Interactivity {
|
||||
|
||||
if let Some(mut scroll_handle_state) = tracked_scroll_handle {
|
||||
scroll_handle_state.max_offset = scroll_max;
|
||||
scroll_handle_state.bounds = bounds;
|
||||
}
|
||||
|
||||
*scroll_offset
|
||||
@@ -3007,11 +3007,6 @@ impl ScrollHandle {
|
||||
self.0.borrow().bounds
|
||||
}
|
||||
|
||||
/// Set the bounds into which this child is painted
|
||||
pub(super) fn set_bounds(&self, bounds: Bounds<Pixels>) {
|
||||
self.0.borrow_mut().bounds = bounds;
|
||||
}
|
||||
|
||||
/// Get the bounds for a specific child.
|
||||
pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
|
||||
self.0.borrow().child_bounds.get(ix).cloned()
|
||||
|
||||
@@ -295,9 +295,8 @@ impl Element for UniformList {
|
||||
bounds.bottom_right() - point(border.right + padding.right, border.bottom),
|
||||
);
|
||||
|
||||
let y_flipped = if let Some(scroll_handle) = self.scroll_handle.as_mut() {
|
||||
let mut scroll_state = scroll_handle.0.borrow_mut();
|
||||
scroll_state.base_handle.set_bounds(bounds);
|
||||
let y_flipped = if let Some(scroll_handle) = &self.scroll_handle {
|
||||
let scroll_state = scroll_handle.0.borrow();
|
||||
scroll_state.y_flipped
|
||||
} else {
|
||||
false
|
||||
|
||||
@@ -845,9 +845,15 @@ impl crate::Keystroke {
|
||||
{
|
||||
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()
|
||||
// map ctrl-a to `a`
|
||||
// ctrl-0..9 may emit control codes like ctrl-[, but
|
||||
// we don't want to map them to `[`
|
||||
} else if key_utf32 <= 0x1f
|
||||
&& !name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
{
|
||||
((key_utf32 as u8 + 0x40) as char)
|
||||
.to_ascii_lowercase()
|
||||
.to_string()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ impl ToTaffy<taffy::style::LengthPercentageAuto> for Length {
|
||||
fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::LengthPercentageAuto {
|
||||
match self {
|
||||
Length::Definite(length) => length.to_taffy(rem_size),
|
||||
Length::Auto => taffy::prelude::LengthPercentageAuto::Auto,
|
||||
Length::Auto => taffy::prelude::LengthPercentageAuto::auto(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,7 +292,7 @@ impl ToTaffy<taffy::style::Dimension> for Length {
|
||||
fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::Dimension {
|
||||
match self {
|
||||
Length::Definite(length) => length.to_taffy(rem_size),
|
||||
Length::Auto => taffy::prelude::Dimension::Auto,
|
||||
Length::Auto => taffy::prelude::Dimension::auto(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,14 +302,14 @@ impl ToTaffy<taffy::style::LengthPercentage> for DefiniteLength {
|
||||
match self {
|
||||
DefiniteLength::Absolute(length) => match length {
|
||||
AbsoluteLength::Pixels(pixels) => {
|
||||
taffy::style::LengthPercentage::Length(pixels.into())
|
||||
taffy::style::LengthPercentage::length(pixels.into())
|
||||
}
|
||||
AbsoluteLength::Rems(rems) => {
|
||||
taffy::style::LengthPercentage::Length((*rems * rem_size).into())
|
||||
taffy::style::LengthPercentage::length((*rems * rem_size).into())
|
||||
}
|
||||
},
|
||||
DefiniteLength::Fraction(fraction) => {
|
||||
taffy::style::LengthPercentage::Percent(*fraction)
|
||||
taffy::style::LengthPercentage::percent(*fraction)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,14 +320,14 @@ impl ToTaffy<taffy::style::LengthPercentageAuto> for DefiniteLength {
|
||||
match self {
|
||||
DefiniteLength::Absolute(length) => match length {
|
||||
AbsoluteLength::Pixels(pixels) => {
|
||||
taffy::style::LengthPercentageAuto::Length(pixels.into())
|
||||
taffy::style::LengthPercentageAuto::length(pixels.into())
|
||||
}
|
||||
AbsoluteLength::Rems(rems) => {
|
||||
taffy::style::LengthPercentageAuto::Length((*rems * rem_size).into())
|
||||
taffy::style::LengthPercentageAuto::length((*rems * rem_size).into())
|
||||
}
|
||||
},
|
||||
DefiniteLength::Fraction(fraction) => {
|
||||
taffy::style::LengthPercentageAuto::Percent(*fraction)
|
||||
taffy::style::LengthPercentageAuto::percent(*fraction)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,12 +337,12 @@ impl ToTaffy<taffy::style::Dimension> for DefiniteLength {
|
||||
fn to_taffy(&self, rem_size: Pixels) -> taffy::style::Dimension {
|
||||
match self {
|
||||
DefiniteLength::Absolute(length) => match length {
|
||||
AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::Length(pixels.into()),
|
||||
AbsoluteLength::Pixels(pixels) => taffy::style::Dimension::length(pixels.into()),
|
||||
AbsoluteLength::Rems(rems) => {
|
||||
taffy::style::Dimension::Length((*rems * rem_size).into())
|
||||
taffy::style::Dimension::length((*rems * rem_size).into())
|
||||
}
|
||||
},
|
||||
DefiniteLength::Fraction(fraction) => taffy::style::Dimension::Percent(*fraction),
|
||||
DefiniteLength::Fraction(fraction) => taffy::style::Dimension::percent(*fraction),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,9 +350,9 @@ impl ToTaffy<taffy::style::Dimension> for DefiniteLength {
|
||||
impl ToTaffy<taffy::style::LengthPercentage> for AbsoluteLength {
|
||||
fn to_taffy(&self, rem_size: Pixels) -> taffy::style::LengthPercentage {
|
||||
match self {
|
||||
AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::Length(pixels.into()),
|
||||
AbsoluteLength::Pixels(pixels) => taffy::style::LengthPercentage::length(pixels.into()),
|
||||
AbsoluteLength::Rems(rems) => {
|
||||
taffy::style::LengthPercentage::Length((*rems * rem_size).into())
|
||||
taffy::style::LengthPercentage::length((*rems * rem_size).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1159,19 +1159,20 @@ impl RenderOnce for ZedAiConfiguration {
|
||||
|
||||
let manage_subscription_buttons = if is_pro {
|
||||
Button::new("manage_settings", "Manage Subscription")
|
||||
.full_width()
|
||||
.style(ButtonStyle::Tinted(TintColor::Accent))
|
||||
.on_click(|_, _, cx| cx.open_url(&zed_urls::account_url(cx)))
|
||||
.into_any_element()
|
||||
} else if self.plan.is_none() || self.eligible_for_trial {
|
||||
Button::new("start_trial", "Start 14-day Free Pro Trial")
|
||||
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.full_width()
|
||||
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click(|_, _, cx| cx.open_url(&zed_urls::start_trial_url(cx)))
|
||||
.into_any_element()
|
||||
} else {
|
||||
Button::new("upgrade", "Upgrade to Pro")
|
||||
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.full_width()
|
||||
.style(ui::ButtonStyle::Tinted(ui::TintColor::Accent))
|
||||
.on_click(|_, _, cx| cx.open_url(&zed_urls::upgrade_to_zed_pro_url(cx)))
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
@@ -3,16 +3,41 @@ use collections::HashMap;
|
||||
mod remote_video_track_view;
|
||||
pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent};
|
||||
|
||||
#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))]
|
||||
#[cfg(not(any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
)))]
|
||||
mod livekit_client;
|
||||
#[cfg(not(any(test, feature = "test-support", target_os = "freebsd")))]
|
||||
#[cfg(not(any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
)))]
|
||||
pub use livekit_client::*;
|
||||
|
||||
#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
|
||||
#[cfg(any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
))]
|
||||
mod mock_client;
|
||||
#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
|
||||
#[cfg(any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
))]
|
||||
pub mod test;
|
||||
#[cfg(any(test, feature = "test-support", target_os = "freebsd"))]
|
||||
#[cfg(any(
|
||||
test,
|
||||
feature = "test-support",
|
||||
all(target_os = "windows", target_env = "gnu"),
|
||||
target_os = "freebsd"
|
||||
))]
|
||||
pub use mock_client::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -4,7 +4,7 @@ pub use lsp_types::request::*;
|
||||
pub use lsp_types::*;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use collections::HashMap;
|
||||
use collections::{BTreeMap, HashMap};
|
||||
use futures::{
|
||||
AsyncRead, AsyncWrite, Future, FutureExt,
|
||||
channel::oneshot::{self, Canceled},
|
||||
@@ -40,7 +40,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::{path::Path, process::Stdio};
|
||||
use util::{ConnectionResult, ResultExt, TryFutureExt};
|
||||
use util::{ConnectionResult, ResultExt, TryFutureExt, redact};
|
||||
|
||||
const JSON_RPC_VERSION: &str = "2.0";
|
||||
const CONTENT_LEN_HEADER: &str = "Content-Length: ";
|
||||
@@ -62,7 +62,7 @@ pub enum IoKind {
|
||||
|
||||
/// Represents a launchable language server. This can either be a standalone binary or the path
|
||||
/// to a runtime with arguments to instruct it to launch the actual language server file.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct LanguageServerBinary {
|
||||
pub path: PathBuf,
|
||||
pub arguments: Vec<OsString>,
|
||||
@@ -877,39 +877,41 @@ impl LanguageServer {
|
||||
|
||||
let server = self.server.clone();
|
||||
let name = self.name.clone();
|
||||
let server_id = self.server_id;
|
||||
let mut timer = self.executor.timer(SERVER_SHUTDOWN_TIMEOUT).fuse();
|
||||
Some(
|
||||
async move {
|
||||
log::debug!("language server shutdown started");
|
||||
Some(async move {
|
||||
log::debug!("language server shutdown started");
|
||||
|
||||
select! {
|
||||
request_result = shutdown_request.fuse() => {
|
||||
match request_result {
|
||||
ConnectionResult::Timeout => {
|
||||
log::warn!("timeout waiting for language server {name} to shutdown");
|
||||
},
|
||||
ConnectionResult::ConnectionReset => {},
|
||||
ConnectionResult::Result(r) => r?,
|
||||
}
|
||||
select! {
|
||||
request_result = shutdown_request.fuse() => {
|
||||
match request_result {
|
||||
ConnectionResult::Timeout => {
|
||||
log::warn!("timeout waiting for language server {name} (id {server_id}) to shutdown");
|
||||
},
|
||||
ConnectionResult::ConnectionReset => {
|
||||
log::warn!("language server {name} (id {server_id}) closed the shutdown request connection");
|
||||
},
|
||||
ConnectionResult::Result(Err(e)) => {
|
||||
log::error!("Shutdown request failure, server {name} (id {server_id}): {e:#}");
|
||||
},
|
||||
ConnectionResult::Result(Ok(())) => {}
|
||||
}
|
||||
|
||||
_ = timer => {
|
||||
log::info!("timeout waiting for language server {name} to shutdown");
|
||||
},
|
||||
}
|
||||
|
||||
response_handlers.lock().take();
|
||||
Self::notify_internal::<notification::Exit>(&outbound_tx, &()).ok();
|
||||
outbound_tx.close();
|
||||
output_done.recv().await;
|
||||
server.lock().take().map(|mut child| child.kill());
|
||||
log::debug!("language server shutdown finished");
|
||||
|
||||
drop(tasks);
|
||||
anyhow::Ok(())
|
||||
_ = timer => {
|
||||
log::info!("timeout waiting for language server {name} (id {server_id}) to shutdown");
|
||||
},
|
||||
}
|
||||
.log_err(),
|
||||
)
|
||||
|
||||
response_handlers.lock().take();
|
||||
Self::notify_internal::<notification::Exit>(&outbound_tx, &()).ok();
|
||||
outbound_tx.close();
|
||||
output_done.recv().await;
|
||||
server.lock().take().map(|mut child| child.kill());
|
||||
drop(tasks);
|
||||
log::debug!("language server shutdown finished");
|
||||
Some(())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1448,6 +1450,33 @@ impl fmt::Debug for LanguageServer {
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for LanguageServerBinary {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut debug = f.debug_struct("LanguageServerBinary");
|
||||
debug.field("path", &self.path);
|
||||
debug.field("arguments", &self.arguments);
|
||||
|
||||
if let Some(env) = &self.env {
|
||||
let redacted_env: BTreeMap<String, String> = env
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let redacted_value = if redact::should_redact(key) {
|
||||
"REDACTED".to_string()
|
||||
} else {
|
||||
value.clone()
|
||||
};
|
||||
(key.clone(), redacted_value)
|
||||
})
|
||||
.collect();
|
||||
debug.field("env", &Some(redacted_env));
|
||||
} else {
|
||||
debug.field("env", &self.env);
|
||||
}
|
||||
|
||||
debug.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Subscription {
|
||||
fn drop(&mut self) {
|
||||
match self {
|
||||
|
||||
@@ -16,6 +16,7 @@ actions!(
|
||||
Cancel,
|
||||
/// Confirms the selected menu item.
|
||||
Confirm,
|
||||
SaveEdit,
|
||||
/// Performs secondary confirmation action.
|
||||
SecondaryConfirm,
|
||||
/// Selects the previous item in the menu.
|
||||
|
||||
@@ -48,18 +48,29 @@ pub enum Model {
|
||||
#[serde(rename = "codestral-latest", alias = "codestral-latest")]
|
||||
#[default]
|
||||
CodestralLatest,
|
||||
|
||||
#[serde(rename = "mistral-large-latest", alias = "mistral-large-latest")]
|
||||
MistralLargeLatest,
|
||||
#[serde(rename = "mistral-medium-latest", alias = "mistral-medium-latest")]
|
||||
MistralMediumLatest,
|
||||
#[serde(rename = "mistral-small-latest", alias = "mistral-small-latest")]
|
||||
MistralSmallLatest,
|
||||
|
||||
#[serde(rename = "magistral-medium-latest", alias = "magistral-medium-latest")]
|
||||
MagistralMediumLatest,
|
||||
#[serde(rename = "magistral-small-latest", alias = "magistral-small-latest")]
|
||||
MagistralSmallLatest,
|
||||
|
||||
#[serde(rename = "open-mistral-nemo", alias = "open-mistral-nemo")]
|
||||
OpenMistralNemo,
|
||||
#[serde(rename = "open-codestral-mamba", alias = "open-codestral-mamba")]
|
||||
OpenCodestralMamba,
|
||||
|
||||
#[serde(rename = "devstral-medium-latest", alias = "devstral-medium-latest")]
|
||||
DevstralMediumLatest,
|
||||
#[serde(rename = "devstral-small-latest", alias = "devstral-small-latest")]
|
||||
DevstralSmallLatest,
|
||||
|
||||
#[serde(rename = "pixtral-12b-latest", alias = "pixtral-12b-latest")]
|
||||
Pixtral12BLatest,
|
||||
#[serde(rename = "pixtral-large-latest", alias = "pixtral-large-latest")]
|
||||
@@ -89,8 +100,11 @@ impl Model {
|
||||
"mistral-large-latest" => Ok(Self::MistralLargeLatest),
|
||||
"mistral-medium-latest" => Ok(Self::MistralMediumLatest),
|
||||
"mistral-small-latest" => Ok(Self::MistralSmallLatest),
|
||||
"magistral-medium-latest" => Ok(Self::MagistralMediumLatest),
|
||||
"magistral-small-latest" => Ok(Self::MagistralSmallLatest),
|
||||
"open-mistral-nemo" => Ok(Self::OpenMistralNemo),
|
||||
"open-codestral-mamba" => Ok(Self::OpenCodestralMamba),
|
||||
"devstral-medium-latest" => Ok(Self::DevstralMediumLatest),
|
||||
"devstral-small-latest" => Ok(Self::DevstralSmallLatest),
|
||||
"pixtral-12b-latest" => Ok(Self::Pixtral12BLatest),
|
||||
"pixtral-large-latest" => Ok(Self::PixtralLargeLatest),
|
||||
@@ -104,8 +118,11 @@ impl Model {
|
||||
Self::MistralLargeLatest => "mistral-large-latest",
|
||||
Self::MistralMediumLatest => "mistral-medium-latest",
|
||||
Self::MistralSmallLatest => "mistral-small-latest",
|
||||
Self::MagistralMediumLatest => "magistral-medium-latest",
|
||||
Self::MagistralSmallLatest => "magistral-small-latest",
|
||||
Self::OpenMistralNemo => "open-mistral-nemo",
|
||||
Self::OpenCodestralMamba => "open-codestral-mamba",
|
||||
Self::DevstralMediumLatest => "devstral-medium-latest",
|
||||
Self::DevstralSmallLatest => "devstral-small-latest",
|
||||
Self::Pixtral12BLatest => "pixtral-12b-latest",
|
||||
Self::PixtralLargeLatest => "pixtral-large-latest",
|
||||
@@ -119,8 +136,11 @@ impl Model {
|
||||
Self::MistralLargeLatest => "mistral-large-latest",
|
||||
Self::MistralMediumLatest => "mistral-medium-latest",
|
||||
Self::MistralSmallLatest => "mistral-small-latest",
|
||||
Self::MagistralMediumLatest => "magistral-medium-latest",
|
||||
Self::MagistralSmallLatest => "magistral-small-latest",
|
||||
Self::OpenMistralNemo => "open-mistral-nemo",
|
||||
Self::OpenCodestralMamba => "open-codestral-mamba",
|
||||
Self::DevstralMediumLatest => "devstral-medium-latest",
|
||||
Self::DevstralSmallLatest => "devstral-small-latest",
|
||||
Self::Pixtral12BLatest => "pixtral-12b-latest",
|
||||
Self::PixtralLargeLatest => "pixtral-large-latest",
|
||||
@@ -136,8 +156,11 @@ impl Model {
|
||||
Self::MistralLargeLatest => 131000,
|
||||
Self::MistralMediumLatest => 128000,
|
||||
Self::MistralSmallLatest => 32000,
|
||||
Self::MagistralMediumLatest => 40000,
|
||||
Self::MagistralSmallLatest => 40000,
|
||||
Self::OpenMistralNemo => 131000,
|
||||
Self::OpenCodestralMamba => 256000,
|
||||
Self::DevstralMediumLatest => 128000,
|
||||
Self::DevstralSmallLatest => 262144,
|
||||
Self::Pixtral12BLatest => 128000,
|
||||
Self::PixtralLargeLatest => 128000,
|
||||
@@ -160,8 +183,11 @@ impl Model {
|
||||
| Self::MistralLargeLatest
|
||||
| Self::MistralMediumLatest
|
||||
| Self::MistralSmallLatest
|
||||
| Self::MagistralMediumLatest
|
||||
| Self::MagistralSmallLatest
|
||||
| Self::OpenMistralNemo
|
||||
| Self::OpenCodestralMamba
|
||||
| Self::DevstralMediumLatest
|
||||
| Self::DevstralSmallLatest
|
||||
| Self::Pixtral12BLatest
|
||||
| Self::PixtralLargeLatest => true,
|
||||
@@ -177,8 +203,11 @@ impl Model {
|
||||
| Self::MistralSmallLatest => true,
|
||||
Self::CodestralLatest
|
||||
| Self::MistralLargeLatest
|
||||
| Self::MagistralMediumLatest
|
||||
| Self::MagistralSmallLatest
|
||||
| Self::OpenMistralNemo
|
||||
| Self::OpenCodestralMamba
|
||||
| Self::DevstralMediumLatest
|
||||
| Self::DevstralSmallLatest => false,
|
||||
Self::Custom {
|
||||
supports_images, ..
|
||||
|
||||
@@ -55,6 +55,7 @@ fn get_max_tokens(name: &str) -> u64 {
|
||||
"codellama" | "starcoder2" => 16384,
|
||||
"mistral" | "codestral" | "mixstral" | "llava" | "qwen2" | "qwen2.5-coder"
|
||||
| "dolphin-mixtral" => 32768,
|
||||
"magistral" => 40000,
|
||||
"llama3.1" | "llama3.2" | "llama3.3" | "phi3" | "phi3.5" | "phi4" | "command-r"
|
||||
| "qwen3" | "gemma3" | "deepseek-coder-v2" | "deepseek-v3" | "deepseek-r1" | "yi-coder"
|
||||
| "devstral" => 128000,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ pub struct OutlinePanelSettings {
|
||||
pub auto_reveal_entries: bool,
|
||||
pub auto_fold_dirs: bool,
|
||||
pub scrollbar: ScrollbarSettings,
|
||||
pub expand_outlines_with_depth: usize,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
@@ -105,6 +106,13 @@ pub struct OutlinePanelSettingsContent {
|
||||
pub indent_guides: Option<IndentGuidesSettingsContent>,
|
||||
/// Scrollbar-related settings
|
||||
pub scrollbar: Option<ScrollbarSettingsContent>,
|
||||
/// Default depth to expand outline items in the current file.
|
||||
/// The default depth to which outline entries are expanded on reveal.
|
||||
/// - Set to 0 to collapse all items that have children
|
||||
/// - Set to 1 or higher to collapse items at that depth or deeper
|
||||
///
|
||||
/// Default: 100
|
||||
pub expand_outlines_with_depth: Option<usize>,
|
||||
}
|
||||
|
||||
impl Settings for OutlinePanelSettings {
|
||||
|
||||
@@ -152,6 +152,10 @@ async function handleMessage(message, prettier) {
|
||||
throw new Error(`Message method is undefined: ${JSON.stringify(message)}`);
|
||||
} else if (method == "initialized") {
|
||||
return;
|
||||
} else if (method === "shutdown") {
|
||||
sendResponse({ result: {} });
|
||||
} else if (method == "exit") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (id === undefined) {
|
||||
|
||||
@@ -322,6 +322,7 @@ pub fn init(cx: &mut App) {
|
||||
});
|
||||
|
||||
workspace.register_action(|workspace, action: &Rename, window, cx| {
|
||||
workspace.open_panel::<ProjectPanel>(window, cx);
|
||||
if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
if let Some(first_marked) = panel.marked_entries.first() {
|
||||
@@ -335,6 +336,7 @@ pub fn init(cx: &mut App) {
|
||||
});
|
||||
|
||||
workspace.register_action(|workspace, action: &Duplicate, window, cx| {
|
||||
workspace.open_panel::<ProjectPanel>(window, cx);
|
||||
if let Some(panel) = workspace.panel::<ProjectPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.duplicate(action, window, cx);
|
||||
@@ -2721,26 +2723,7 @@ impl ProjectPanel {
|
||||
}
|
||||
|
||||
fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
|
||||
let mut entry_index = 0;
|
||||
let mut visible_entries_index = 0;
|
||||
for (worktree_index, (worktree_id, worktree_entries, _)) in
|
||||
self.visible_entries.iter().enumerate()
|
||||
{
|
||||
if *worktree_id == selection.worktree_id {
|
||||
for entry in worktree_entries {
|
||||
if entry.id == selection.entry_id {
|
||||
return Some((worktree_index, entry_index, visible_entries_index));
|
||||
} else {
|
||||
visible_entries_index += 1;
|
||||
entry_index += 1;
|
||||
}
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
visible_entries_index += worktree_entries.len();
|
||||
}
|
||||
}
|
||||
None
|
||||
self.index_for_entry(selection.entry_id, selection.worktree_id)
|
||||
}
|
||||
|
||||
fn disjoint_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
|
||||
@@ -3351,12 +3334,12 @@ impl ProjectPanel {
|
||||
entry_id: ProjectEntryId,
|
||||
worktree_id: WorktreeId,
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
let mut worktree_ix = 0;
|
||||
let mut total_ix = 0;
|
||||
for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
|
||||
for (worktree_ix, (current_worktree_id, visible_worktree_entries, _)) in
|
||||
self.visible_entries.iter().enumerate()
|
||||
{
|
||||
if worktree_id != *current_worktree_id {
|
||||
total_ix += visible_worktree_entries.len();
|
||||
worktree_ix += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -228,16 +228,17 @@ impl Render for BufferSearchBar {
|
||||
if in_replace {
|
||||
key_context.add("in_replace");
|
||||
}
|
||||
let editor_border = if self.query_error.is_some() {
|
||||
let query_border = if self.query_error.is_some() {
|
||||
Color::Error.color(cx)
|
||||
} else {
|
||||
cx.theme().colors().border
|
||||
};
|
||||
let replacement_border = cx.theme().colors().border;
|
||||
|
||||
let container_width = window.viewport_size().width;
|
||||
let input_width = SearchInputWidth::calc_width(container_width);
|
||||
|
||||
let input_base_styles = || {
|
||||
let input_base_styles = |border_color| {
|
||||
h_flex()
|
||||
.min_w_32()
|
||||
.w(input_width)
|
||||
@@ -246,7 +247,7 @@ impl Render for BufferSearchBar {
|
||||
.pr_1()
|
||||
.py_1()
|
||||
.border_1()
|
||||
.border_color(editor_border)
|
||||
.border_color(border_color)
|
||||
.rounded_lg()
|
||||
};
|
||||
|
||||
@@ -256,7 +257,7 @@ impl Render for BufferSearchBar {
|
||||
el.child(Label::new("Find in results").color(Color::Hint))
|
||||
})
|
||||
.child(
|
||||
input_base_styles()
|
||||
input_base_styles(query_border)
|
||||
.id("editor-scroll")
|
||||
.track_scroll(&self.editor_scroll_handle)
|
||||
.child(self.render_text_input(&self.query_editor, color_override, cx))
|
||||
@@ -430,11 +431,13 @@ impl Render for BufferSearchBar {
|
||||
let replace_line = should_show_replace_input.then(|| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(input_base_styles().child(self.render_text_input(
|
||||
&self.replacement_editor,
|
||||
None,
|
||||
cx,
|
||||
)))
|
||||
.child(
|
||||
input_base_styles(replacement_border).child(self.render_text_input(
|
||||
&self.replacement_editor,
|
||||
None,
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.min_w_64()
|
||||
@@ -700,7 +703,11 @@ impl BufferSearchBar {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let query_editor = cx.new(|cx| Editor::single_line(window, cx));
|
||||
let query_editor = cx.new(|cx| {
|
||||
let mut editor = Editor::single_line(window, cx);
|
||||
editor.set_use_autoclose(false);
|
||||
editor
|
||||
});
|
||||
cx.subscribe_in(&query_editor, window, Self::on_query_editor_event)
|
||||
.detach();
|
||||
let replacement_editor = cx.new(|cx| Editor::single_line(window, cx));
|
||||
@@ -771,6 +778,7 @@ impl BufferSearchBar {
|
||||
|
||||
pub fn dismiss(&mut self, _: &Dismiss, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dismissed = true;
|
||||
self.query_error = None;
|
||||
for searchable_item in self.searchable_items_with_matches.keys() {
|
||||
if let Some(searchable_item) =
|
||||
WeakSearchableItemHandle::upgrade(searchable_item.as_ref(), cx)
|
||||
|
||||
@@ -195,6 +195,7 @@ pub struct ProjectSearch {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum InputPanel {
|
||||
Query,
|
||||
Replacement,
|
||||
Exclude,
|
||||
Include,
|
||||
}
|
||||
@@ -1962,7 +1963,7 @@ impl Render for ProjectSearchBar {
|
||||
MultipleInputs,
|
||||
}
|
||||
|
||||
let input_base_styles = |base_style: BaseStyle| {
|
||||
let input_base_styles = |base_style: BaseStyle, panel: InputPanel| {
|
||||
h_flex()
|
||||
.min_w_32()
|
||||
.map(|div| match base_style {
|
||||
@@ -1974,11 +1975,11 @@ impl Render for ProjectSearchBar {
|
||||
.pr_1()
|
||||
.py_1()
|
||||
.border_1()
|
||||
.border_color(search.border_color_for(InputPanel::Query, cx))
|
||||
.border_color(search.border_color_for(panel, cx))
|
||||
.rounded_lg()
|
||||
};
|
||||
|
||||
let query_column = input_base_styles(BaseStyle::SingleInput)
|
||||
let query_column = input_base_styles(BaseStyle::SingleInput, InputPanel::Query)
|
||||
.on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
|
||||
.on_action(cx.listener(|this, action, window, cx| {
|
||||
this.previous_history_query(action, window, cx)
|
||||
@@ -2167,7 +2168,7 @@ impl Render for ProjectSearchBar {
|
||||
.child(h_flex().min_w_64().child(mode_column).child(matches_column));
|
||||
|
||||
let replace_line = search.replace_enabled.then(|| {
|
||||
let replace_column = input_base_styles(BaseStyle::SingleInput)
|
||||
let replace_column = input_base_styles(BaseStyle::SingleInput, InputPanel::Replacement)
|
||||
.child(self.render_text_input(&search.replacement_editor, cx));
|
||||
|
||||
let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
|
||||
@@ -2241,7 +2242,7 @@ impl Render for ProjectSearchBar {
|
||||
.gap_2()
|
||||
.w(input_width)
|
||||
.child(
|
||||
input_base_styles(BaseStyle::MultipleInputs)
|
||||
input_base_styles(BaseStyle::MultipleInputs, InputPanel::Include)
|
||||
.on_action(cx.listener(|this, action, window, cx| {
|
||||
this.previous_history_query(action, window, cx)
|
||||
}))
|
||||
@@ -2251,7 +2252,7 @@ impl Render for ProjectSearchBar {
|
||||
.child(self.render_text_input(&search.included_files_editor, cx)),
|
||||
)
|
||||
.child(
|
||||
input_base_styles(BaseStyle::MultipleInputs)
|
||||
input_base_styles(BaseStyle::MultipleInputs, InputPanel::Exclude)
|
||||
.on_action(cx.listener(|this, action, window, cx| {
|
||||
this.previous_history_query(action, window, cx)
|
||||
}))
|
||||
|
||||
@@ -41,16 +41,14 @@ pub trait Summary: Clone {
|
||||
fn add_summary(&mut self, summary: &Self, cx: &Self::Context);
|
||||
}
|
||||
|
||||
/// This type exists because we can't implement Summary for () without causing
|
||||
/// type resolution errors
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct Unit;
|
||||
|
||||
impl Summary for Unit {
|
||||
/// Catch-all implementation for when you need something that implements [`Summary`] without a specific type.
|
||||
/// We implement it on a &'static, as that avoids blanket impl collisions with `impl<T: Summary> Dimension for T`
|
||||
/// (as we also need unit type to be a fill-in dimension)
|
||||
impl Summary for &'static () {
|
||||
type Context = ();
|
||||
|
||||
fn zero(_: &()) -> Self {
|
||||
Unit
|
||||
&()
|
||||
}
|
||||
|
||||
fn add_summary(&mut self, _: &Self, _: &()) {}
|
||||
|
||||
@@ -430,6 +430,7 @@ impl TerminalView {
|
||||
|
||||
fn settings_changed(&mut self, cx: &mut Context<Self>) {
|
||||
let settings = TerminalSettings::get_global(cx);
|
||||
let breadcrumb_visibility_changed = self.show_breadcrumbs != settings.toolbar.breadcrumbs;
|
||||
self.show_breadcrumbs = settings.toolbar.breadcrumbs;
|
||||
|
||||
let new_cursor_shape = settings.cursor_shape.unwrap_or_default();
|
||||
@@ -441,6 +442,9 @@ impl TerminalView {
|
||||
});
|
||||
}
|
||||
|
||||
if breadcrumb_visibility_changed {
|
||||
cx.emit(ItemEvent::UpdateBreadcrumbs);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use editor::{
|
||||
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
|
||||
display_map::ToDisplayPoint,
|
||||
};
|
||||
use gpui::{Action, App, AppContext as _, Context, Global, Window, actions};
|
||||
use gpui::{Action, App, AppContext as _, Context, Global, Keystroke, Window, actions};
|
||||
use itertools::Itertools;
|
||||
use language::Point;
|
||||
use multi_buffer::MultiBufferRow;
|
||||
@@ -202,6 +202,7 @@ actions!(
|
||||
ArgumentRequired
|
||||
]
|
||||
);
|
||||
|
||||
/// Opens the specified file for editing.
|
||||
#[derive(Clone, PartialEq, Action)]
|
||||
#[action(namespace = vim, no_json, no_register)]
|
||||
@@ -209,6 +210,13 @@ struct VimEdit {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Action)]
|
||||
#[action(namespace = vim, no_json, no_register)]
|
||||
struct VimNorm {
|
||||
pub range: Option<CommandRange>,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WrappedAction(Box<dyn Action>);
|
||||
|
||||
@@ -447,6 +455,81 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
||||
});
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, action: &VimNorm, window, cx| {
|
||||
let keystrokes = action
|
||||
.command
|
||||
.chars()
|
||||
.map(|c| Keystroke::parse(&c.to_string()).unwrap())
|
||||
.collect();
|
||||
vim.switch_mode(Mode::Normal, true, window, cx);
|
||||
let initial_selections = vim.update_editor(window, cx, |_, editor, _, _| {
|
||||
editor.selections.disjoint_anchors()
|
||||
});
|
||||
if let Some(range) = &action.range {
|
||||
let result = vim.update_editor(window, cx, |vim, editor, window, cx| {
|
||||
let range = range.buffer_range(vim, editor, window, cx)?;
|
||||
editor.change_selections(
|
||||
SelectionEffects::no_scroll().nav_history(false),
|
||||
window,
|
||||
cx,
|
||||
|s| {
|
||||
s.select_ranges(
|
||||
(range.start.0..=range.end.0)
|
||||
.map(|line| Point::new(line, 0)..Point::new(line, 0)),
|
||||
);
|
||||
},
|
||||
);
|
||||
anyhow::Ok(())
|
||||
});
|
||||
if let Some(Err(err)) = result {
|
||||
log::error!("Error selecting range: {}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(workspace) = vim.workspace(window) else {
|
||||
return;
|
||||
};
|
||||
let task = workspace.update(cx, |workspace, cx| {
|
||||
workspace.send_keystrokes_impl(keystrokes, window, cx)
|
||||
});
|
||||
let had_range = action.range.is_some();
|
||||
|
||||
cx.spawn_in(window, async move |vim, cx| {
|
||||
task.await;
|
||||
vim.update_in(cx, |vim, window, cx| {
|
||||
vim.update_editor(window, cx, |_, editor, window, cx| {
|
||||
if had_range {
|
||||
editor.change_selections(SelectionEffects::default(), window, cx, |s| {
|
||||
s.select_anchor_ranges([s.newest_anchor().range()]);
|
||||
})
|
||||
}
|
||||
});
|
||||
if matches!(vim.mode, Mode::Insert | Mode::Replace) {
|
||||
vim.normal_before(&Default::default(), window, cx);
|
||||
} else {
|
||||
vim.switch_mode(Mode::Normal, true, window, cx);
|
||||
}
|
||||
vim.update_editor(window, cx, |_, editor, _, cx| {
|
||||
if let Some(first_sel) = initial_selections {
|
||||
if let Some(tx_id) = editor
|
||||
.buffer()
|
||||
.update(cx, |multi, cx| multi.last_transaction_id(cx))
|
||||
{
|
||||
let last_sel = editor.selections.disjoint_anchors();
|
||||
editor.modify_transaction_selection_history(tx_id, |old| {
|
||||
old.0 = first_sel;
|
||||
old.1 = Some(last_sel);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| {
|
||||
let Some(workspace) = vim.workspace(window) else {
|
||||
return;
|
||||
@@ -675,14 +758,15 @@ impl VimCommand {
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if !args.is_empty() {
|
||||
|
||||
let action = if args.is_empty() {
|
||||
action
|
||||
} else {
|
||||
// if command does not accept args and we have args then we should do no action
|
||||
if let Some(args_fn) = &self.args {
|
||||
args_fn.deref()(action, args)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else if let Some(range) = range {
|
||||
self.args.as_ref()?(action, args)?
|
||||
};
|
||||
|
||||
if let Some(range) = range {
|
||||
self.range.as_ref().and_then(|f| f(action, range))
|
||||
} else {
|
||||
Some(action)
|
||||
@@ -1061,6 +1145,27 @@ fn generate_commands(_: &App) -> Vec<VimCommand> {
|
||||
save_intent: Some(SaveIntent::Skip),
|
||||
close_pinned: true,
|
||||
}),
|
||||
VimCommand::new(
|
||||
("norm", "al"),
|
||||
VimNorm {
|
||||
command: "".into(),
|
||||
range: None,
|
||||
},
|
||||
)
|
||||
.args(|_, args| {
|
||||
Some(
|
||||
VimNorm {
|
||||
command: args,
|
||||
range: None,
|
||||
}
|
||||
.boxed_clone(),
|
||||
)
|
||||
})
|
||||
.range(|action, range| {
|
||||
let mut action: VimNorm = action.as_any().downcast_ref::<VimNorm>().unwrap().clone();
|
||||
action.range.replace(range.clone());
|
||||
Some(Box::new(action))
|
||||
}),
|
||||
VimCommand::new(("bn", "ext"), workspace::ActivateNextItem).count(),
|
||||
VimCommand::new(("bN", "ext"), workspace::ActivatePreviousItem).count(),
|
||||
VimCommand::new(("bp", "revious"), workspace::ActivatePreviousItem).count(),
|
||||
@@ -2298,4 +2403,78 @@ mod test {
|
||||
});
|
||||
assert!(mark.is_none())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_normal_command(cx: &mut TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state(indoc! {"
|
||||
The quick
|
||||
brown« fox
|
||||
jumpsˇ» over
|
||||
the lazy dog
|
||||
"})
|
||||
.await;
|
||||
|
||||
cx.simulate_shared_keystrokes(": n o r m space w C w o r d")
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
The quick
|
||||
brown word
|
||||
jumps worˇd
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes(": n o r m space _ w c i w t e s t")
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
The quick
|
||||
brown word
|
||||
jumps tesˇt
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes("_ l v l : n o r m space s l a")
|
||||
.await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
The quick
|
||||
brown word
|
||||
lˇaumps test
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.set_shared_state(indoc! {"
|
||||
ˇThe quick
|
||||
brown fox
|
||||
jumps over
|
||||
the lazy dog
|
||||
"})
|
||||
.await;
|
||||
|
||||
cx.simulate_shared_keystrokes("c i w M y escape").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
Mˇy quick
|
||||
brown fox
|
||||
jumps over
|
||||
the lazy dog
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes(": n o r m space u").await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
ˇThe quick
|
||||
brown fox
|
||||
jumps over
|
||||
the lazy dog
|
||||
"});
|
||||
// Once ctrl-v to input character literals is added there should be a test for redo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
use editor::{DisplayPoint, Editor, movement};
|
||||
use editor::{DisplayPoint, Editor, SelectionEffects, ToOffset, ToPoint, movement};
|
||||
use gpui::{Action, actions};
|
||||
use gpui::{Context, Window};
|
||||
use language::{CharClassifier, CharKind};
|
||||
use text::SelectionGoal;
|
||||
use text::{Bias, SelectionGoal};
|
||||
|
||||
use crate::{Vim, motion::Motion, state::Mode};
|
||||
use crate::{
|
||||
Vim,
|
||||
motion::{Motion, right},
|
||||
state::Mode,
|
||||
};
|
||||
|
||||
actions!(
|
||||
vim,
|
||||
[
|
||||
/// Switches to normal mode after the cursor (Helix-style).
|
||||
HelixNormalAfter
|
||||
HelixNormalAfter,
|
||||
/// Inserts at the beginning of the selection.
|
||||
HelixInsert,
|
||||
/// Appends at the end of the selection.
|
||||
HelixAppend,
|
||||
]
|
||||
);
|
||||
|
||||
pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
||||
Vim::action(editor, cx, Vim::helix_normal_after);
|
||||
Vim::action(editor, cx, Vim::helix_insert);
|
||||
Vim::action(editor, cx, Vim::helix_append);
|
||||
}
|
||||
|
||||
impl Vim {
|
||||
@@ -299,6 +309,112 @@ impl Vim {
|
||||
_ => self.helix_move_and_collapse(motion, times, window, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.start_recording(cx);
|
||||
self.update_editor(window, cx, |_, editor, window, cx| {
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(|_map, selection| {
|
||||
// In helix normal mode, move cursor to start of selection and collapse
|
||||
if !selection.is_empty() {
|
||||
selection.collapse_to(selection.start, SelectionGoal::None);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
self.switch_mode(Mode::Insert, false, window, cx);
|
||||
}
|
||||
|
||||
fn helix_append(&mut self, _: &HelixAppend, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.start_recording(cx);
|
||||
self.switch_mode(Mode::Insert, false, window, cx);
|
||||
self.update_editor(window, cx, |_, editor, window, cx| {
|
||||
editor.change_selections(Default::default(), window, cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
let point = if selection.is_empty() {
|
||||
right(map, selection.head(), 1)
|
||||
} else {
|
||||
selection.end
|
||||
};
|
||||
selection.collapse_to(point, SelectionGoal::None);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.update_editor(window, cx, |_, editor, window, cx| {
|
||||
editor.transact(window, cx, |editor, window, cx| {
|
||||
let (map, selections) = editor.selections.all_display(cx);
|
||||
|
||||
// Store selection info for positioning after edit
|
||||
let selection_info: Vec<_> = selections
|
||||
.iter()
|
||||
.map(|selection| {
|
||||
let range = selection.range();
|
||||
let start_offset = range.start.to_offset(&map, Bias::Left);
|
||||
let end_offset = range.end.to_offset(&map, Bias::Left);
|
||||
let was_empty = range.is_empty();
|
||||
let was_reversed = selection.reversed;
|
||||
(
|
||||
map.buffer_snapshot.anchor_at(start_offset, Bias::Left),
|
||||
end_offset - start_offset,
|
||||
was_empty,
|
||||
was_reversed,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut edits = Vec::new();
|
||||
for selection in &selections {
|
||||
let mut range = selection.range();
|
||||
|
||||
// For empty selections, extend to replace one character
|
||||
if range.is_empty() {
|
||||
range.end = movement::saturating_right(&map, range.start);
|
||||
}
|
||||
|
||||
let byte_range = range.start.to_offset(&map, Bias::Left)
|
||||
..range.end.to_offset(&map, Bias::Left);
|
||||
|
||||
if !byte_range.is_empty() {
|
||||
let replacement_text = text.repeat(byte_range.len());
|
||||
edits.push((byte_range, replacement_text));
|
||||
}
|
||||
}
|
||||
|
||||
editor.edit(edits, cx);
|
||||
|
||||
// Restore selections based on original info
|
||||
let snapshot = editor.buffer().read(cx).snapshot(cx);
|
||||
let ranges: Vec<_> = selection_info
|
||||
.into_iter()
|
||||
.map(|(start_anchor, original_len, was_empty, was_reversed)| {
|
||||
let start_point = start_anchor.to_point(&snapshot);
|
||||
if was_empty {
|
||||
// For cursor-only, collapse to start
|
||||
start_point..start_point
|
||||
} else {
|
||||
// For selections, span the replaced text
|
||||
let replacement_len = text.len() * original_len;
|
||||
let end_offset = start_anchor.to_offset(&snapshot) + replacement_len;
|
||||
let end_point = snapshot.offset_to_point(end_offset);
|
||||
if was_reversed {
|
||||
end_point..start_point
|
||||
} else {
|
||||
start_point..end_point
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
|
||||
s.select_ranges(ranges);
|
||||
});
|
||||
});
|
||||
});
|
||||
self.switch_mode(Mode::HelixNormal, true, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -497,4 +613,94 @@ mod test {
|
||||
|
||||
cx.assert_state("«ˇaa»\n", Mode::HelixNormal);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_insert_selected(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
cx.set_state(
|
||||
indoc! {"
|
||||
«The ˇ»quick brown
|
||||
fox jumps over
|
||||
the lazy dog."},
|
||||
Mode::HelixNormal,
|
||||
);
|
||||
|
||||
cx.simulate_keystrokes("i");
|
||||
|
||||
cx.assert_state(
|
||||
indoc! {"
|
||||
ˇThe quick brown
|
||||
fox jumps over
|
||||
the lazy dog."},
|
||||
Mode::Insert,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_append(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
// test from the end of the selection
|
||||
cx.set_state(
|
||||
indoc! {"
|
||||
«Theˇ» quick brown
|
||||
fox jumps over
|
||||
the lazy dog."},
|
||||
Mode::HelixNormal,
|
||||
);
|
||||
|
||||
cx.simulate_keystrokes("a");
|
||||
|
||||
cx.assert_state(
|
||||
indoc! {"
|
||||
Theˇ quick brown
|
||||
fox jumps over
|
||||
the lazy dog."},
|
||||
Mode::Insert,
|
||||
);
|
||||
|
||||
// test from the beginning of the selection
|
||||
cx.set_state(
|
||||
indoc! {"
|
||||
«ˇThe» quick brown
|
||||
fox jumps over
|
||||
the lazy dog."},
|
||||
Mode::HelixNormal,
|
||||
);
|
||||
|
||||
cx.simulate_keystrokes("a");
|
||||
|
||||
cx.assert_state(
|
||||
indoc! {"
|
||||
Theˇ quick brown
|
||||
fox jumps over
|
||||
the lazy dog."},
|
||||
Mode::Insert,
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_replace(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = VimTestContext::new(cx, true).await;
|
||||
|
||||
// No selection (single character)
|
||||
cx.set_state("ˇaa", Mode::HelixNormal);
|
||||
|
||||
cx.simulate_keystrokes("r x");
|
||||
|
||||
cx.assert_state("ˇxa", Mode::HelixNormal);
|
||||
|
||||
// Cursor at the beginning
|
||||
cx.set_state("«ˇaa»", Mode::HelixNormal);
|
||||
|
||||
cx.simulate_keystrokes("r x");
|
||||
|
||||
cx.assert_state("«ˇxx»", Mode::HelixNormal);
|
||||
|
||||
// Cursor at the end
|
||||
cx.set_state("«aaˇ»", Mode::HelixNormal);
|
||||
|
||||
cx.simulate_keystrokes("r x");
|
||||
|
||||
cx.assert_state("«xxˇ»", Mode::HelixNormal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
|
||||
}
|
||||
|
||||
impl Vim {
|
||||
fn normal_before(
|
||||
pub(crate) fn normal_before(
|
||||
&mut self,
|
||||
action: &NormalBefore,
|
||||
window: &mut Window,
|
||||
|
||||
@@ -1639,6 +1639,7 @@ impl Vim {
|
||||
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
|
||||
self.visual_replace(text, window, cx)
|
||||
}
|
||||
Mode::HelixNormal => self.helix_replace(&text, window, cx),
|
||||
_ => self.clear_operator(window, cx),
|
||||
},
|
||||
Some(Operator::Digraph { first_char }) => {
|
||||
|
||||
64
crates/vim/test_data/test_normal_command.json
Normal file
64
crates/vim/test_data/test_normal_command.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{"Put":{"state":"The quick\nbrown« fox\njumpsˇ» over\nthe lazy dog\n"}}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"w"}
|
||||
{"Key":"C"}
|
||||
{"Key":"w"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"d"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"The quick\nbrown word\njumps worˇd\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"_"}
|
||||
{"Key":"w"}
|
||||
{"Key":"c"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Key":"t"}
|
||||
{"Key":"e"}
|
||||
{"Key":"s"}
|
||||
{"Key":"t"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"The quick\nbrown word\njumps tesˇt\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Key":"_"}
|
||||
{"Key":"l"}
|
||||
{"Key":"v"}
|
||||
{"Key":"l"}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"s"}
|
||||
{"Key":"l"}
|
||||
{"Key":"a"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"The quick\nbrown word\nlˇaumps test\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Put":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n"}}
|
||||
{"Key":"c"}
|
||||
{"Key":"i"}
|
||||
{"Key":"w"}
|
||||
{"Key":"M"}
|
||||
{"Key":"y"}
|
||||
{"Key":"escape"}
|
||||
{"Get":{"state":"Mˇy quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
|
||||
{"Key":":"}
|
||||
{"Key":"n"}
|
||||
{"Key":"o"}
|
||||
{"Key":"r"}
|
||||
{"Key":"m"}
|
||||
{"Key":"space"}
|
||||
{"Key":"u"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"ˇThe quick\nbrown fox\njumps over\nthe lazy dog\n","mode":"Normal"}}
|
||||
@@ -934,6 +934,10 @@ impl Render for PanelButtons {
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.when(
|
||||
has_buttons && dock.position == DockPosition::Bottom,
|
||||
|this| this.child(Divider::vertical().color(DividerColor::Border)),
|
||||
)
|
||||
.children(buttons)
|
||||
.when(has_buttons && dock.position == DockPosition::Left, |this| {
|
||||
this.child(Divider::vertical().color(DividerColor::Border))
|
||||
|
||||
@@ -32,7 +32,7 @@ use futures::{
|
||||
mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
oneshot,
|
||||
},
|
||||
future::try_join_all,
|
||||
future::{Shared, try_join_all},
|
||||
};
|
||||
use gpui::{
|
||||
Action, AnyEntity, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context,
|
||||
@@ -87,7 +87,7 @@ use std::{
|
||||
borrow::Cow,
|
||||
cell::RefCell,
|
||||
cmp,
|
||||
collections::hash_map::DefaultHasher,
|
||||
collections::{VecDeque, hash_map::DefaultHasher},
|
||||
env,
|
||||
hash::{Hash, Hasher},
|
||||
path::{Path, PathBuf},
|
||||
@@ -1043,6 +1043,13 @@ type PromptForOpenPath = Box<
|
||||
) -> oneshot::Receiver<Option<Vec<PathBuf>>>,
|
||||
>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DispatchingKeystrokes {
|
||||
dispatched: HashSet<Vec<Keystroke>>,
|
||||
queue: VecDeque<Keystroke>,
|
||||
task: Option<Shared<Task<()>>>,
|
||||
}
|
||||
|
||||
/// Collects everything project-related for a certain window opened.
|
||||
/// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`.
|
||||
///
|
||||
@@ -1080,7 +1087,7 @@ pub struct Workspace {
|
||||
leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
|
||||
database_id: Option<WorkspaceId>,
|
||||
app_state: Arc<AppState>,
|
||||
dispatching_keystrokes: Rc<RefCell<(HashSet<String>, Vec<Keystroke>)>>,
|
||||
dispatching_keystrokes: Rc<RefCell<DispatchingKeystrokes>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_apply_leader_updates: Task<Result<()>>,
|
||||
_observe_current_user: Task<Result<()>>,
|
||||
@@ -2311,49 +2318,65 @@ impl Workspace {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut state = self.dispatching_keystrokes.borrow_mut();
|
||||
if !state.0.insert(action.0.clone()) {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
let mut keystrokes: Vec<Keystroke> = action
|
||||
let keystrokes: Vec<Keystroke> = action
|
||||
.0
|
||||
.split(' ')
|
||||
.flat_map(|k| Keystroke::parse(k).log_err())
|
||||
.collect();
|
||||
keystrokes.reverse();
|
||||
let _ = self.send_keystrokes_impl(keystrokes, window, cx);
|
||||
}
|
||||
|
||||
state.1.append(&mut keystrokes);
|
||||
drop(state);
|
||||
pub fn send_keystrokes_impl(
|
||||
&mut self,
|
||||
keystrokes: Vec<Keystroke>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Shared<Task<()>> {
|
||||
let mut state = self.dispatching_keystrokes.borrow_mut();
|
||||
if !state.dispatched.insert(keystrokes.clone()) {
|
||||
cx.propagate();
|
||||
return state.task.clone().unwrap();
|
||||
}
|
||||
|
||||
state.queue.extend(keystrokes);
|
||||
|
||||
let keystrokes = self.dispatching_keystrokes.clone();
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
// limit to 100 keystrokes to avoid infinite recursion.
|
||||
for _ in 0..100 {
|
||||
let Some(keystroke) = keystrokes.borrow_mut().1.pop() else {
|
||||
keystrokes.borrow_mut().0.clear();
|
||||
return Ok(());
|
||||
};
|
||||
cx.update(|window, cx| {
|
||||
let focused = window.focused(cx);
|
||||
window.dispatch_keystroke(keystroke.clone(), cx);
|
||||
if window.focused(cx) != focused {
|
||||
// dispatch_keystroke may cause the focus to change.
|
||||
// draw's side effect is to schedule the FocusChanged events in the current flush effect cycle
|
||||
// And we need that to happen before the next keystroke to keep vim mode happy...
|
||||
// (Note that the tests always do this implicitly, so you must manually test with something like:
|
||||
// "bindings": { "g z": ["workspace::SendKeystrokes", ": j <enter> u"]}
|
||||
// )
|
||||
window.draw(cx).clear();
|
||||
if state.task.is_none() {
|
||||
state.task = Some(
|
||||
window
|
||||
.spawn(cx, async move |cx| {
|
||||
// limit to 100 keystrokes to avoid infinite recursion.
|
||||
for _ in 0..100 {
|
||||
let mut state = keystrokes.borrow_mut();
|
||||
let Some(keystroke) = state.queue.pop_front() else {
|
||||
state.dispatched.clear();
|
||||
state.task.take();
|
||||
return;
|
||||
};
|
||||
drop(state);
|
||||
cx.update(|window, cx| {
|
||||
let focused = window.focused(cx);
|
||||
window.dispatch_keystroke(keystroke.clone(), cx);
|
||||
if window.focused(cx) != focused {
|
||||
// dispatch_keystroke may cause the focus to change.
|
||||
// draw's side effect is to schedule the FocusChanged events in the current flush effect cycle
|
||||
// And we need that to happen before the next keystroke to keep vim mode happy...
|
||||
// (Note that the tests always do this implicitly, so you must manually test with something like:
|
||||
// "bindings": { "g z": ["workspace::SendKeystrokes", ": j <enter> u"]}
|
||||
// )
|
||||
window.draw(cx).clear();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
*keystrokes.borrow_mut() = Default::default();
|
||||
anyhow::bail!("over 100 keystrokes passed to send_keystrokes");
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
*keystrokes.borrow_mut() = Default::default();
|
||||
log::error!("over 100 keystrokes passed to send_keystrokes");
|
||||
})
|
||||
.shared(),
|
||||
);
|
||||
}
|
||||
state.task.clone().unwrap()
|
||||
}
|
||||
|
||||
fn save_all_internal(
|
||||
|
||||
@@ -62,7 +62,7 @@ use std::{
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use sum_tree::{Bias, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet, Unit};
|
||||
use sum_tree::{Bias, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet};
|
||||
use text::{LineEnding, Rope};
|
||||
use util::{
|
||||
ResultExt,
|
||||
@@ -407,12 +407,12 @@ struct LocalRepositoryEntry {
|
||||
}
|
||||
|
||||
impl sum_tree::Item for LocalRepositoryEntry {
|
||||
type Summary = PathSummary<Unit>;
|
||||
type Summary = PathSummary<&'static ()>;
|
||||
|
||||
fn summary(&self, _: &<Self::Summary as Summary>::Context) -> Self::Summary {
|
||||
PathSummary {
|
||||
max_path: self.work_directory.path_key().0,
|
||||
item_summary: Unit,
|
||||
item_summary: &(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,12 +425,6 @@ impl KeyedItem for LocalRepositoryEntry {
|
||||
}
|
||||
}
|
||||
|
||||
//impl LocalRepositoryEntry {
|
||||
// pub fn repo(&self) -> &Arc<dyn GitRepository> {
|
||||
// &self.repo_ptr
|
||||
// }
|
||||
//}
|
||||
|
||||
impl Deref for LocalRepositoryEntry {
|
||||
type Target = WorkDirectory;
|
||||
|
||||
@@ -5417,7 +5411,7 @@ impl<'a> SeekTarget<'a, EntrySummary, TraversalProgress<'a>> for TraversalTarget
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> SeekTarget<'a, PathSummary<Unit>, TraversalProgress<'a>> for TraversalTarget<'_> {
|
||||
impl<'a> SeekTarget<'a, PathSummary<&'static ()>, TraversalProgress<'a>> for TraversalTarget<'_> {
|
||||
fn cmp(&self, cursor_location: &TraversalProgress<'a>, _: &()) -> Ordering {
|
||||
self.cmp_progress(cursor_location)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "The fast, collaborative code editor."
|
||||
edition.workspace = true
|
||||
name = "zed"
|
||||
version = "0.197.0"
|
||||
version = "0.198.0"
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Zed Team <hi@zed.dev>"]
|
||||
@@ -56,6 +56,7 @@ env_logger.workspace = true
|
||||
extension.workspace = true
|
||||
extension_host.workspace = true
|
||||
extensions_ui.workspace = true
|
||||
feature_flags.workspace = true
|
||||
feedback.workspace = true
|
||||
file_finder.workspace = true
|
||||
fs.workspace = true
|
||||
|
||||
@@ -19,6 +19,7 @@ use collections::VecDeque;
|
||||
use debugger_ui::debugger_panel::DebugPanel;
|
||||
use editor::ProposedChangesEditorToolbar;
|
||||
use editor::{Editor, MultiBuffer};
|
||||
use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag};
|
||||
use futures::future::Either;
|
||||
use futures::{StreamExt, channel::mpsc, select_biased};
|
||||
use git_ui::git_panel::GitPanel;
|
||||
@@ -53,9 +54,12 @@ use settings::{
|
||||
initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
|
||||
update_settings_file,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{self, AtomicBool};
|
||||
use std::{borrow::Cow, path::Path, sync::Arc};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::atomic::{self, AtomicBool},
|
||||
};
|
||||
use terminal_view::terminal_panel::{self, TerminalPanel};
|
||||
use theme::{ActiveTheme, ThemeSettings};
|
||||
use ui::{PopoverMenuHandle, prelude::*};
|
||||
@@ -120,11 +124,9 @@ pub fn init(cx: &mut App) {
|
||||
cx.on_action(quit);
|
||||
|
||||
cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
|
||||
|
||||
if ReleaseChannel::global(cx) == ReleaseChannel::Dev {
|
||||
cx.on_action(test_panic);
|
||||
if ReleaseChannel::global(cx) == ReleaseChannel::Dev || cx.has_flag::<PanicFeatureFlag>() {
|
||||
cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"));
|
||||
}
|
||||
|
||||
cx.on_action(|_: &OpenLog, cx| {
|
||||
with_active_or_new_workspace(cx, |workspace, window, cx| {
|
||||
open_log_file(workspace, window, cx);
|
||||
@@ -987,10 +989,6 @@ fn about(
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn test_panic(_: &TestPanic, _: &mut App) {
|
||||
panic!("Ran the TestPanic action")
|
||||
}
|
||||
|
||||
fn install_cli(
|
||||
_: &mut Workspace,
|
||||
_: &install_cli::Install,
|
||||
|
||||
Reference in New Issue
Block a user