Compare commits

...

35 Commits

Author SHA1 Message Date
Danilo Leal
5ac874ab31 Agent panel message editing checkpoint
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
2025-07-24 13:59:57 -03:00
Kirill Bulatov
2658b2801e Shutdown language servers better (#35038)
Follow-up of https://github.com/zed-industries/zed/pull/33417

* adjust prettier mock LSP to handle `shutdown` and `exit` messages
* removed another `?.log_err()` backtrace from logs and improved the
logging info
* always handle the last parts of the shutdown logic even if the
shutdown response had failed

Release Notes:

- N/A
2025-07-24 15:24:53 +00:00
Richard Feldman
2a9355a3d2 Don't auto-retry in certain circumstances (#35037)
Someone encountered this in production, which should not happen:

<img width="1266" height="623" alt="Screenshot 2025-07-24 at 10 38
40 AM"
src="https://github.com/user-attachments/assets/40f3f977-5110-4808-a456-7e708d953b3b"
/>

This moves certain errors into the category of "never retry" and reduces
the number of retries for some others. Also it adds some diagnostic
logging for retry policy.

It's not a complete fix for the above, because the underlying issues is
that the server is sending a HTTP 403 response and although we were
already treating 403s as "do not retry" it was deciding to retry with 2
attempts anyway. So further debugging is needed to figure out why it
wasn't going down the 403 branch by the time the request got here.

Release Notes:

- N/A
2025-07-24 11:11:26 -04:00
Smit Barmase
fa788a39a4 project_panel: Reuse index_for_entry in index_for_selection (#35034)
Just refactor I came across while working on another issue.
`index_for_entry` and `index_for_selection` have the exact same logic,
here we can simply reuse `index_for_entry` for `index_for_selection`.

Release Notes:

- N/A
2025-07-24 20:07:38 +05:30
Pablo Ramón Guevara
7cdd808db2 helix: Fix replace in helix mode (#34789)
Closes https://github.com/zed-industries/zed/issues/33076

Release Notes:

- Fixed replace command on helix mode: now it actually replaces what was
selected and keeps the replaced text selected to better match helix
2025-07-24 08:29:58 -06:00
Danilo Leal
29332c1962 ai onboarding: Add overall fixes to the whole flow (#34996)
Closes https://github.com/zed-industries/zed/issues/34979

Release Notes:

- N/A

---------

Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Ben Kunkle <Ben.kunkle@gmail.com>
2025-07-24 11:26:15 -03:00
Peter Tripp
fab450e39d Fix invalid regular expressions highlighting all search fields (#35001)
Closes https://github.com/zed-industries/zed/issues/34969
Closes https://github.com/zed-industries/zed/issues/34970

Only highlight the search field on regex error (buffer search and
project search).
Clear errors when the buffer search hidden so stale errors aren't shown
on next search.

Before (all fields highlighted red): 
<img width="631" height="180" alt="Screenshot 2025-07-23 at 22 59 45"
src="https://github.com/user-attachments/assets/a91d3090-1bae-4718-a1f5-0a1237559ff2"
/>

After (only query field highlighted red): 
<img width="632" height="187" alt="Screenshot 2025-07-23 at 23 10 49"
src="https://github.com/user-attachments/assets/6ae72c84-9333-4cdb-907b-afb7a0a6e808"
/>

Release Notes:

- Improved highlighting of regex errors in search dialogs
2025-07-24 09:20:25 -04:00
Danilo Leal
4fb540d6d2 dock: Add divider between panels on the right side, too (#35003)
A while ago, we added a divider in the left side of the status bar
between the icon buttons that open panels vs. those that open something
else (tabs, popover menus, etc.). There isn't a super good reason why we
wouldn't do the same in the right side, even more so given that (at
least by default) we usually have more buttons on that side; buttons
that _don't_ open panels (regardless of being docked to the bottom or
right). So, this PR does this!

<img width="700" height="314" alt="CleanShot 2025-07-24 at 1  59 10@2x"
src="https://github.com/user-attachments/assets/5f8bd4bc-a983-4000-a8f9-05a36b9e4b81"
/>

Release Notes:

- N/A
2025-07-24 09:39:10 -03:00
Piotr Osiewicz
1e2b0fcab6 sum_tree: Remove Unit type (#35027)
This solves one ~TODO, as Unit type was a workaround for a lack of
ability to implement Summary for ().


Release Notes:

- N/A
2025-07-24 14:30:49 +02:00
Oleksiy Syvokon
0af690080b linux: Fix ctrl-0..9, ctrl-[, ctrl-^ (#35028)
There were two different underlying reasons for the issues with
ctrl-number and ctrl-punctuation:

1. Some keys in the ctrl-0..9 range send codes in the `\1b`..`\1f`
range. For example, `ctrl-2` sends keycode for `ctrl-[` (0x1b), but we
want to map it to `2`, not to `[`.

2. `ctrl-[` and four other ctrl-punctuation were incorrectly mapped,
since the expected conversion is by adding 0x40

Closes #35012

Release Notes:

- N/A
2025-07-24 12:22:57 +00:00
Finn Evers
dd52fb58fe terminal_view: Ensure breadcrumbs are updated on settings change (#35016)
Currently, terminal breadcrumbs are only updated after a settings change
once the terminal view is focused again. This change ensures that the
breadcrumbs are updated instantaneously once the breadcrumb settings
changes.

Release Notes:

- Fixed an issue where terminal breadcrumbs would not react instantly to
settings changes.
2025-07-24 08:51:40 +00:00
Joseph T. Lyons
913b9296d7 Add editor: convert to sentence case (#35015)
This PR adds an `editor: convert to sentence case` action.

I frequently find myself copying branch names and then removing the
hyphens and ensuring the first letter is capitalized, and then using the
result text for the commit message.

For example:

<img width="927" height="482" alt="image"
src="https://github.com/user-attachments/assets/adf14a37-a92e-44df-8c0e-267b5c7677fb"
/>

You can achieve this with a combination of other text manipulation
commands, but this action makes it even easier.

Also, moved `toggle_case` down into the area where all other commands
internally using `manipulate_text` are located.

Release Notes:

- Added `editor: convert to sentence case`
2025-07-24 08:49:04 +00:00
Joseph T. Lyons
5c9363b1c4 Differentiate between file and selection diff events (#35014)
Release Notes:

- N/A
2025-07-24 08:43:28 +00:00
Finn Evers
cd9bcc7f09 agent_ui: Improve wrapping behavior in provider configuration header (#34989)
This ensures that the "Add provider" button does not move offscreen too
fast and ensures the text wraps for smaller panel sizes.

| Before | After |
| --- | --- |
| <img width="413" height="84" alt="image"
src="https://github.com/user-attachments/assets/565f7503-bddb-4b05-83c1-8f8745ac3ce3"
/> | <img width="392" height="84" alt="image"
src="https://github.com/user-attachments/assets/18469e4d-d94c-4641-9081-1af8981bfffd"
/> |

Release Notes:

- N/A
2025-07-24 10:40:36 +02:00
Jason Lee
65759d4316 gpui: Fix Interactivity prepaint to update scroll_handle bounds (#35013)
It took a long time to check this problem.

Finally, I found that due to a detail missing when changing #34832, the
bounds of `ScrollHandle` was not updated in the Interactivity `prepaint`
phase.

```diff
- scroll_handle_state.padded_content_size = padded_content_size;
+ scroll_handle_state.max_offset = scroll_max;
```

It was correct before the change, because the `padded_content_size`
(including `bounds.size`) was saved before, and the bounds was missing
after changing to `max_offset`, but the bounds were not updated
anywhere.

So when `scroll_handle.bounds()` is obtained outside, it is always 0px
here.

@MrSubidubi

Release Notes:

- N/A
2025-07-24 08:27:29 +00:00
Joseph T. Lyons
ddd50aabba Fix some bugs with editor: diff clipboard with selection (#34999)
Improves testing around `editor: diff clipboard with selection` as well.

Release Notes:

- Fixed some bugs with `editor: diff clipboard with selection`
2025-07-24 02:52:02 -04:00
Conrad Irwin
34bf6ebba6 Disable auto-close in search (#35005)
Currently if you type `\(`, it auto-closes to `\()` which is broken.

It's arguably nice that if you type `(` it auto-closes to `()`, but I am
much more likely to be looking for a function call `name\(` than to be
starting a group in search.

Release Notes:

- search: Regex search will no longer try to close parenthesis
automatically.
2025-07-24 05:45:01 +00:00
Pablo Ramón Guevara
a6956eebcb Improve Helix insert (#34765)
Closes #34763 

Release Notes:

- Improved insert in `helix_mode` when a selection exists to better
match helix's behavior: collapse selection to avoid replacing it
- Improved append (`insert_after`) to better match helix's behavior:
move cursor to end of selection if it exists
2025-07-23 23:27:07 -06:00
AidanV
8b0ec287a5 vim: Add :norm support (#33232)
Closes #21198

Release Notes:

- Adds support for `:norm`
- Allows for vim and zed style modified keys specified in issue
  - Vim style <C-w> and zed style <ctrl-w>
- Differs from vim in how multi-line is handled 
  - vim is sequential
  - zed is combinational (with multi-cursor)
2025-07-23 23:06:05 -06:00
versecafe
c08851a85e ollama: Add Magistral to Ollama (#35000)
See also: #34983

Release Notes:

- Added magistral support to ollama
2025-07-24 00:17:54 -04:00
Peter Tripp
b93e1c736b mistral: Add support for magistral-small and magistral-medium (#34983)
Release Notes:

- mistral: Added support for magistral-small and magistral-medium
2025-07-23 23:13:49 -04:00
Piotr Osiewicz
67027bb241 agent: Fix Zed header in settings view (#34993)
Follow-up to taffy bump (#34939), fixes an issue reported by @MrSubidubi


Release Notes:

- N/A
2025-07-24 00:13:47 +00:00
Smit Barmase
31afda3c0c project_panel: Automatically open project panel when Rename or Duplicate is triggered from workspace (#34988)
In project panel, `rename` and `duplicate` action further needs user
input for editing, so if panel is closed we should open it.

Release Notes:

- Fixed project panel not opening when `project panel: rename` and
`project panel: duplicate` actions are triggered from workspace.
2025-07-24 05:26:12 +05:30
Marshall Bowers
3d4266bb8f collab: Remove POST /billing/subscriptions/manage endpoint (#34986)
This PR removes the `POST /billing/subscriptions/manage` endpoint, as it
has been moved to `cloud.zed.dev`.

Release Notes:

- N/A
2025-07-23 23:30:00 +00:00
Maksim Bondarenkov
4a87397d37 livekit_client: Revert a change that broke MinGW builds (#34977)
the change was made in https://github.com/zed-industries/zed/pull/34223
for unknown reason. it wasn't required actually, and the code can be
safely left as before

update: after this revert Zed compiles with MinGW as before

Release Notes:

- N/A
2025-07-24 01:53:13 +03:00
Piotr Osiewicz
3da23cc65b Re-land taffy 0.8.3 (#34939)
Re #34938
- **chore: Bump taffy to 0.8.3**
- **editor: Fix sticky multi-buffer header not extending to the full
width**


Release Notes:

- N/A
2025-07-24 00:33:43 +02:00
Smit Barmase
b63d820be2 editor: Fix move line up panic when selection is at end of line next to fold marker (#34982)
Closes #34826

In move line up method, make use of `prev_line_boundary` which accounts
for fold map, etc., for selection start row so that we don't incorrectly
calculate row range to move up.

Release Notes:

- Fixed an issue where `editor: move line up` action sometimes crashed
if the cursor was at the end of a line beside a fold marker.
2025-07-24 03:46:29 +05:30
Renato Lochetti
7e9d6cc25c mistral: Add support for Mistral Devstral Medium (#34888)
Mistral released their new DevstralMedium model to be used via API:
https://mistral.ai/news/devstral-2507

Release Notes:

- Add support for Mistral Devstral Medium
2025-07-23 17:27:25 -04:00
Danilo Leal
8bf7dcb613 agent: Fix follow button disabled state (#34978)
Release Notes:

- N/A
2025-07-23 17:09:05 -04:00
Peter Tripp
edceb7284f Redact secrets from environment in LSP Server Info (#34971)
In "Server Info" view of LSP logs:
- Redacts sensitive values from environment
- Sorts environment by name

| Before | After | 
| - | - | 
| <img width="797" height="327" alt="Screenshot 2025-07-23 at 14 10 14"
src="https://github.com/user-attachments/assets/75781f30-9099-4994-9824-94d9c46f63e1"
/> | <img width="972" height="571" alt="image"
src="https://github.com/user-attachments/assets/c5bef744-a1b7-415f-9eb7-8314275c59b9"
/> |


Release Notes:

- Improved display of environment variables in LSP Logs: Server Info
view
2025-07-23 16:55:13 -04:00
Joseph T. Lyons
50985b7d23 Fix telemetry event type names (#34974)
Release Notes:

- N/A
2025-07-23 20:30:21 +00:00
Nicolas Rodriguez
be0d9eecb7 Add collapse functionality to outline entries (#33490)
partly Closes #23075 

Release Notes:

- Now provides collapse and enables functionality to outline entries
- Add a new expand_outlines_with_depth setting to customize how deep the
tree is expanded by when a file is opened

part 2 is in #34164 

**Visual examples**

![image](https://github.com/user-attachments/assets/5dcdb83b-6e3e-4bfd-8ef4-76ae2ce4d3e6)

![image](https://github.com/user-attachments/assets/7b786a5a-1a8c-4f34-aaa5-4a8d0afa9668)

![image](https://github.com/user-attachments/assets/1817be06-ac71-4480-8f17-0bd862e913c8)
2025-07-23 18:52:44 +00:00
Umesh Yadav
9863c8a44e agent_ui: Show keybindings for NewThread and NewTextThread in new thread button (#34967)
I believe in this PR: #34829 we moved to context menu entry from action
but the side effect of that was we also removed the Keybindings from
showing it in the new thread button dropdown. This PR fixes that. cc
@danilo-leal

| Before | After |
|--------|--------|
| <img width="900" height="1962" alt="CleanShot 2025-07-23 at 23 36
28@2x"
src="https://github.com/user-attachments/assets/760cbe75-09b9-404b-9d33-1db73785234f"
/> | <img width="850" height="1964" alt="CleanShot 2025-07-23 at 23 37
17@2x"
src="https://github.com/user-attachments/assets/24a7e871-aebc-475c-845f-b76f02527b8f"
/> |

Release Notes:

- N/A
2025-07-23 18:28:05 +00:00
Joseph T. Lyons
a48247a313 Bump Zed to v0.198 (#34964)
Release Notes:

-N/A
2025-07-23 18:14:39 +00:00
Julia Ryan
5f0edd38f8 Add TestPanic feature flag (#34963)
Now the `dev: panic` action can be run on all release channels if the
user has the feature flag enabled.

Release Notes:

- N/A
2025-07-23 18:01:16 +00:00
55 changed files with 3014 additions and 1069 deletions

13
Cargo.lock generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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.

View File

@@ -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, .. }

View File

@@ -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 (

View File

@@ -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

View File

@@ -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(

View File

@@ -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))

View File

@@ -123,6 +123,8 @@ actions!(
ContinueWithBurnMode,
/// Toggles burn mode for faster responses.
ToggleBurnMode,
EditAssistantMessage,
EditUserMessage,
]
);

View File

@@ -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,

View File

@@ -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::*;

View File

@@ -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)
}
}),
),
)
}
}

View File

@@ -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())
}
}

View File

@@ -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())

View File

@@ -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")

View File

@@ -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?;

View File

@@ -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'");
}
};
}

View File

@@ -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)]

View File

@@ -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.

View File

@@ -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(

View File

@@ -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)

View File

@@ -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;

View File

@@ -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()

View File

@@ -85,6 +85,11 @@ impl FeatureFlag for ThreadAutoCaptureFeatureFlag {
false
}
}
pub struct PanicFeatureFlag;
impl FeatureFlag for PanicFeatureFlag {
const NAME: &'static str = "panic";
}
pub struct JjUiFeatureFlag {}

View File

@@ -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
);
});
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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
}

View File

@@ -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())
}
}
}

View File

@@ -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()
};

View File

@@ -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)]

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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, ..

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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)
}))

View File

@@ -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, _: &()) {}

View File

@@ -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();
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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 }) => {

View 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"}}

View File

@@ -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))

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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,