From 3d48f142481984fc8612f5f007e6a3bd8209d4c3 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 30 Jul 2025 12:07:29 -0400 Subject: [PATCH 01/56] v0.198.x preview --- crates/zed/RELEASE_CHANNEL | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/RELEASE_CHANNEL b/crates/zed/RELEASE_CHANNEL index 38f8e886e1..4de2f126df 100644 --- a/crates/zed/RELEASE_CHANNEL +++ b/crates/zed/RELEASE_CHANNEL @@ -1 +1 @@ -dev +preview \ No newline at end of file From 910507d7e5168491ab0a9ba6289a3018f3f6c498 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Thu, 31 Jul 2025 01:22:41 +0530 Subject: [PATCH 02/56] linux: Fix caps lock not working consistently for certain X11 systems (cherry-pick #35361) (#35365) Cherry-picked linux: Fix caps lock not working consistently for certain X11 systems (#35361) Closes #35316 Bug in https://github.com/zed-industries/zed/pull/34514 Turns out you are not supposed to call `update_key` for modifiers on `KeyPress`/`KeyRelease`, as modifiers are already updated in `XkbStateNotify` events. Not sure why this only causes issues on a few systems and works on others. Tested on Ubuntu 24.04.2 LTS (initial bug) and Kubuntu 25.04 (worked fine before too). Release Notes: - Fixed an issue where caps lock stopped working consistently on some Linux X11 systems. Co-authored-by: Smit Barmase --- crates/gpui/src/platform/linux/x11/client.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index d1cb7d00cc..16a7a768e2 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1004,12 +1004,13 @@ impl X11Client { let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); - // should be called after key_get_one_sym - state.xkb.update_key(code, xkbc::KeyDirection::Down); - if keysym.is_modifier_key() { return Some(()); } + + // should be called after key_get_one_sym + state.xkb.update_key(code, xkbc::KeyDirection::Down); + if let Some(mut compose_state) = state.compose_state.take() { compose_state.feed(keysym); match compose_state.status() { @@ -1067,12 +1068,13 @@ impl X11Client { let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); - // should be called after key_get_one_sym - state.xkb.update_key(code, xkbc::KeyDirection::Up); - if keysym.is_modifier_key() { return Some(()); } + + // should be called after key_get_one_sym + state.xkb.update_key(code, xkbc::KeyDirection::Up); + keystroke }; drop(state); From 5c450693fad70b4c3ead7b28713b816d16039b09 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 31 Jul 2025 18:29:51 -0400 Subject: [PATCH 03/56] jetbrains: Unmap cmd-k in Jetbrains keymap (#35443) This only works after a delay in most situations because of the all chorded `cmd-k` mappings in the so disable them for now. Reported by @jer-k: https://x.com/J_Kreutzbender/status/1951033355434336606 Release Notes: - Undo mapping of `cmd-k` for Git Panel in default Jetbrains keymap (thanks [@jer-k](https://github.com/jer-k)) --- assets/keymaps/linux/jetbrains.json | 2 +- assets/keymaps/macos/jetbrains.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index f81f363ae0..9bc1f24bfb 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -95,7 +95,7 @@ "ctrl-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "alt-shift-f10": "task::Spawn", "ctrl-e": "file_finder::Toggle", - "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + // "ctrl-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "ctrl-shift-n": "file_finder::Toggle", "ctrl-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 5795d2ac7e..b1cd51a338 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -97,7 +97,7 @@ "cmd-shift-r": ["pane::DeploySearch", { "replace_enabled": true }], "ctrl-alt-r": "task::Spawn", "cmd-e": "file_finder::Toggle", - "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor + // "cmd-k": "git_panel::ToggleFocus", // bug: This should also focus commit editor "cmd-shift-o": "file_finder::Toggle", "cmd-shift-a": "command_palette::Toggle", "shift shift": "command_palette::Toggle", From 059a4092350b825f39b516dbe3e89db7424f94a2 Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 08:39:10 +0300 Subject: [PATCH 04/56] Fix panic with completion ranges and autoclose regions interop (cherry-pick #35408) (#35414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked Fix panic with completion ranges and autoclose regions interop (#35408) As reported [in Discord](https://discord.com/channels/869392257814519848/1106226198494859355/1398470747227426948) C projects with `"` as "brackets" that autoclose, may invoke panics when edited at the end of the file. With a single selection-caret (`ˇ`), at the end of the file, ```c ifndef BAR_H #define BAR_H #include int fn_branch(bool do_branch1, bool do_branch2); #endif // BAR_H #include"ˇ" ``` gets an LSP response from clangd ```jsonc { "filterText": "AGL/", "insertText": "AGL/", "insertTextFormat": 1, "kind": 17, "label": " AGL/", "labelDetails": {}, "score": 0.78725427389144897, "sortText": "40b67681AGL/", "textEdit": { "newText": "AGL/", "range": { "end": { "character": 11, "line": 8 }, "start": { "character": 10, "line": 8 } } } } ``` which replaces `"` after the caret (character/column 11, 0-indexed). This is reasonable, as regular follow-up (proposed in further completions), is a suffix + a closing `"`: image Yet when Zed handles user input of `"`, it panics due to multiple reasons: * after applying any snippet text edit, Zed did a selection change: https://github.com/zed-industries/zed/blob/55379876301bd4dcfe054a146b66288d2e60a523/crates/editor/src/editor.rs#L9539-L9545 which caused eventual autoclose region invalidation: https://github.com/zed-industries/zed/blob/55379876301bd4dcfe054a146b66288d2e60a523/crates/editor/src/editor.rs#L2970 This covers all cases that insert the `include""` text. * after applying any user input and "plain" text edit, Zed did not invalidate any autoclose regions at all, relying on the "bracket" (which includes `"`) autoclose logic to rule edge cases out * bracket autoclose logic detects previous `"` and considers the new user input as a valid closure, hence no autoclose region needed. But there is an autoclose bracket data after the plaintext completion insertion (`AGL/`) really, and it's not invalidated after `"` handling * in addition to that, `Anchor::is_valid` method in `text` panicked, and required `fn try_fragment_id_for_anchor` to handle "pointing at odd, after the end of the file, offset" cases as `false` A test reproducing the feedback and 2 fixes added: proper, autoclose region invalidation call which required the invalidation logic tweaked a bit, and "superficial", "do not apply bad selections that cause panics" fix in the editor to be more robust Release Notes: - Fixed panic with completion ranges and autoclose regions interop --------- Co-authored-by: Max Brunsfeld Co-authored-by: Kirill Bulatov Co-authored-by: Max Brunsfeld --- Cargo.lock | 1 + crates/editor/Cargo.toml | 3 + crates/editor/src/editor.rs | 79 +++++++++----- crates/editor/src/editor_tests.rs | 172 ++++++++++++++++++++++++++++++ crates/multi_buffer/src/anchor.rs | 8 +- crates/project/src/lsp_command.rs | 6 +- crates/text/src/anchor.rs | 4 +- crates/text/src/text.rs | 33 +++--- 8 files changed, 258 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ca7a21a7d..e7bc19e9c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4960,6 +4960,7 @@ dependencies = [ "theme", "time", "tree-sitter-bash", + "tree-sitter-c", "tree-sitter-html", "tree-sitter-python", "tree-sitter-rust", diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 0692c7fbe6..ab2d1c8ecb 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -22,6 +22,7 @@ test-support = [ "theme/test-support", "util/test-support", "workspace/test-support", + "tree-sitter-c", "tree-sitter-rust", "tree-sitter-typescript", "tree-sitter-html", @@ -76,6 +77,7 @@ telemetry.workspace = true text.workspace = true time.workspace = true theme.workspace = true +tree-sitter-c = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true } tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } @@ -106,6 +108,7 @@ settings = { workspace = true, features = ["test-support"] } tempfile.workspace = true text = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } +tree-sitter-c.workspace = true tree-sitter-html.workspace = true tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b515044d2d..3516eff45c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1305,6 +1305,7 @@ impl Default for SelectionHistoryMode { /// /// Similarly, you might want to disable scrolling if you don't want the viewport to /// move. +#[derive(Clone)] pub struct SelectionEffects { nav_history: Option, completions: bool, @@ -2944,10 +2945,12 @@ impl Editor { } } + let selection_anchors = self.selections.disjoint_anchors(); + if self.focus_handle.is_focused(window) && self.leader_id.is_none() { self.buffer.update(cx, |buffer, cx| { buffer.set_active_selections( - &self.selections.disjoint_anchors(), + &selection_anchors, self.selections.line_mode, self.cursor_shape, cx, @@ -2964,9 +2967,8 @@ impl Editor { self.select_next_state = None; self.select_prev_state = None; self.select_syntax_node_history.try_clear(); - self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), buffer); - self.snippet_stack - .invalidate(&self.selections.disjoint_anchors(), buffer); + self.invalidate_autoclose_regions(&selection_anchors, buffer); + self.snippet_stack.invalidate(&selection_anchors, buffer); self.take_rename(false, window, cx); let newest_selection = self.selections.newest_anchor(); @@ -4047,7 +4049,8 @@ impl Editor { // then don't insert that closing bracket again; just move the selection // past the closing bracket. let should_skip = selection.end == region.range.end.to_point(&snapshot) - && text.as_ref() == region.pair.end.as_str(); + && text.as_ref() == region.pair.end.as_str() + && snapshot.contains_str_at(region.range.end, text.as_ref()); if should_skip { let anchor = snapshot.anchor_after(selection.end); new_selections @@ -4973,13 +4976,17 @@ impl Editor { }) } - /// Remove any autoclose regions that no longer contain their selection. + /// Remove any autoclose regions that no longer contain their selection or have invalid anchors in ranges. fn invalidate_autoclose_regions( &mut self, mut selections: &[Selection], buffer: &MultiBufferSnapshot, ) { self.autoclose_regions.retain(|state| { + if !state.range.start.is_valid(buffer) || !state.range.end.is_valid(buffer) { + return false; + } + let mut i = 0; while let Some(selection) = selections.get(i) { if selection.end.cmp(&state.range.start, buffer).is_lt() { @@ -5891,18 +5898,20 @@ impl Editor { text: new_text[common_prefix_len..].into(), }); - self.transact(window, cx, |this, window, cx| { + self.transact(window, cx, |editor, window, cx| { if let Some(mut snippet) = snippet { snippet.text = new_text.to_string(); - this.insert_snippet(&ranges, snippet, window, cx).log_err(); + editor + .insert_snippet(&ranges, snippet, window, cx) + .log_err(); } else { - this.buffer.update(cx, |buffer, cx| { + editor.buffer.update(cx, |multi_buffer, cx| { let auto_indent = match completion.insert_text_mode { Some(InsertTextMode::AS_IS) => None, - _ => this.autoindent_mode.clone(), + _ => editor.autoindent_mode.clone(), }; let edits = ranges.into_iter().map(|range| (range, new_text.as_str())); - buffer.edit(edits, auto_indent, cx); + multi_buffer.edit(edits, auto_indent, cx); }); } for (buffer, edits) in linked_edits { @@ -5921,8 +5930,9 @@ impl Editor { }) } - this.refresh_inline_completion(true, false, window, cx); + editor.refresh_inline_completion(true, false, window, cx); }); + self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), &snapshot); let show_new_completions_on_confirm = completion .confirm @@ -9562,27 +9572,46 @@ impl Editor { // Check whether the just-entered snippet ends with an auto-closable bracket. if self.autoclose_regions.is_empty() { let snapshot = self.buffer.read(cx).snapshot(cx); - for selection in &mut self.selections.all::(cx) { + let mut all_selections = self.selections.all::(cx); + for selection in &mut all_selections { let selection_head = selection.head(); let Some(scope) = snapshot.language_scope_at(selection_head) else { continue; }; let mut bracket_pair = None; - let next_chars = snapshot.chars_at(selection_head).collect::(); - let prev_chars = snapshot - .reversed_chars_at(selection_head) - .collect::(); - for (pair, enabled) in scope.brackets() { - if enabled - && pair.close - && prev_chars.starts_with(pair.start.as_str()) - && next_chars.starts_with(pair.end.as_str()) - { - bracket_pair = Some(pair.clone()); - break; + let max_lookup_length = scope + .brackets() + .map(|(pair, _)| { + pair.start + .as_str() + .chars() + .count() + .max(pair.end.as_str().chars().count()) + }) + .max(); + if let Some(max_lookup_length) = max_lookup_length { + let next_text = snapshot + .chars_at(selection_head) + .take(max_lookup_length) + .collect::(); + let prev_text = snapshot + .reversed_chars_at(selection_head) + .take(max_lookup_length) + .collect::(); + + for (pair, enabled) in scope.brackets() { + if enabled + && pair.close + && prev_text.starts_with(pair.start.as_str()) + && next_text.starts_with(pair.end.as_str()) + { + bracket_pair = Some(pair.clone()); + break; + } } } + if let Some(pair) = bracket_pair { let snapshot_settings = snapshot.language_settings_at(selection_head, cx); let autoclose_enabled = diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index a13708c580..503fe2abc3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -13400,6 +13400,178 @@ async fn test_as_is_completions(cx: &mut TestAppContext) { cx.assert_editor_state("fn a() {}\n unsafeˇ"); } +#[gpui::test] +async fn test_panic_during_c_completions(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let language = + Arc::try_unwrap(languages::language("c", tree_sitter_c::LANGUAGE.into())).unwrap(); + let mut cx = EditorLspTestContext::new( + language, + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + ..lsp::CompletionOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + cx.set_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +ˇ", + ); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("#", window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("i", window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.handle_input("n", window, cx); + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#inˇ", + ); + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: false, + item_defaults: None, + items: vec![lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::SNIPPET), + label_details: Some(lsp::CompletionItemLabelDetails { + detail: Some("header".to_string()), + description: None, + }), + label: " include".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 8, + character: 1, + }, + end: lsp::Position { + line: 8, + character: 1, + }, + }, + new_text: "include \"$0\"".to_string(), + })), + sort_text: Some("40b67681include".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::SNIPPET), + filter_text: Some("include".to_string()), + insert_text: Some("include \"$0\"".to_string()), + ..lsp::CompletionItem::default() + }], + }))) + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + "#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include \"ˇ\"", + ); + + cx.lsp + .set_request_handler::(move |_, _| async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + is_incomplete: true, + item_defaults: None, + items: vec![lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::FILE), + label: "AGL/".to_string(), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 8, + character: 10, + }, + end: lsp::Position { + line: 8, + character: 11, + }, + }, + new_text: "AGL/".to_string(), + })), + sort_text: Some("40b67681AGL/".to_string()), + insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), + filter_text: Some("AGL/".to_string()), + insert_text: Some("AGL/".to_string()), + ..lsp::CompletionItem::default() + }], + }))) + }); + cx.update_editor(|editor, window, cx| { + editor.show_completions(&ShowCompletions { trigger: None }, window, cx); + }); + cx.executor().run_until_parked(); + cx.update_editor(|editor, window, cx| { + editor.confirm_completion(&ConfirmCompletion::default(), window, cx) + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + r##"#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include "AGL/ˇ"##, + ); + + cx.update_editor(|editor, window, cx| { + editor.handle_input("\"", window, cx); + }); + cx.executor().run_until_parked(); + cx.assert_editor_state( + r##"#ifndef BAR_H +#define BAR_H + +#include + +int fn_branch(bool do_branch1, bool do_branch2); + +#endif // BAR_H +#include "AGL/"ˇ"##, + ); +} + #[gpui::test] async fn test_no_duplicated_completion_requests(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/multi_buffer/src/anchor.rs b/crates/multi_buffer/src/anchor.rs index 9e28295c56..1305328d38 100644 --- a/crates/multi_buffer/src/anchor.rs +++ b/crates/multi_buffer/src/anchor.rs @@ -167,10 +167,10 @@ impl Anchor { if *self == Anchor::min() || *self == Anchor::max() { true } else if let Some(excerpt) = snapshot.excerpt(self.excerpt_id) { - excerpt.contains(self) - && (self.text_anchor == excerpt.range.context.start - || self.text_anchor == excerpt.range.context.end - || self.text_anchor.is_valid(&excerpt.buffer)) + (self.text_anchor == excerpt.range.context.start + || self.text_anchor == excerpt.range.context.end + || self.text_anchor.is_valid(&excerpt.buffer)) + && excerpt.contains(self) } else { false } diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index a2f6de44c9..958921a0e6 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -2269,7 +2269,7 @@ impl LspCommand for GetCompletions { // the range based on the syntax tree. None => { if self.position != clipped_position { - log::info!("completion out of expected range"); + log::info!("completion out of expected range "); return false; } @@ -2483,7 +2483,9 @@ pub(crate) fn parse_completion_text_edit( let start = snapshot.clip_point_utf16(range.start, Bias::Left); let end = snapshot.clip_point_utf16(range.end, Bias::Left); if start != range.start.0 || end != range.end.0 { - log::info!("completion out of expected range"); + log::info!( + "completion out of expected range, start: {start:?}, end: {end:?}, range: {range:?}" + ); return None; } snapshot.anchor_before(start)..snapshot.anchor_after(end) diff --git a/crates/text/src/anchor.rs b/crates/text/src/anchor.rs index 5807d3aae0..bf17336f9d 100644 --- a/crates/text/src/anchor.rs +++ b/crates/text/src/anchor.rs @@ -99,7 +99,9 @@ impl Anchor { } else if self.buffer_id != Some(buffer.remote_id) { false } else { - let fragment_id = buffer.fragment_id_for_anchor(self); + let Some(fragment_id) = buffer.try_fragment_id_for_anchor(self) else { + return false; + }; let mut fragment_cursor = buffer.fragments.cursor::<(Option<&Locator>, usize)>(&None); fragment_cursor.seek(&Some(fragment_id), Bias::Left); fragment_cursor diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index c1da0649da..aded03d46a 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2330,10 +2330,19 @@ impl BufferSnapshot { } fn fragment_id_for_anchor(&self, anchor: &Anchor) -> &Locator { + self.try_fragment_id_for_anchor(anchor).unwrap_or_else(|| { + panic!( + "invalid anchor {:?}. buffer id: {}, version: {:?}", + anchor, self.remote_id, self.version, + ) + }) + } + + fn try_fragment_id_for_anchor(&self, anchor: &Anchor) -> Option<&Locator> { if *anchor == Anchor::MIN { - Locator::min_ref() + Some(Locator::min_ref()) } else if *anchor == Anchor::MAX { - Locator::max_ref() + Some(Locator::max_ref()) } else { let anchor_key = InsertionFragmentKey { timestamp: anchor.timestamp, @@ -2354,20 +2363,12 @@ impl BufferSnapshot { insertion_cursor.prev(); } - let Some(insertion) = insertion_cursor.item().filter(|insertion| { - if cfg!(debug_assertions) { - insertion.timestamp == anchor.timestamp - } else { - true - } - }) else { - panic!( - "invalid anchor {:?}. buffer id: {}, version: {:?}", - anchor, self.remote_id, self.version - ); - }; - - &insertion.fragment_id + insertion_cursor + .item() + .filter(|insertion| { + !cfg!(debug_assertions) || insertion.timestamp == anchor.timestamp + }) + .map(|insertion| &insertion.fragment_id) } } From 87c9f6a52e247c8468e84d54e0ec7d2a92561e3a Mon Sep 17 00:00:00 2001 From: "gcp-cherry-pick-bot[bot]" <98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com> Date: Fri, 1 Aug 2025 11:23:30 -0400 Subject: [PATCH 05/56] debugger: Send initialized event from fake server at a more realistic time (cherry-pick #35446) (#35447) Cherry-picked debugger: Send initialized event from fake server at a more realistic time (#35446) The spec says: > :arrow_left: Initialized Event > This event indicates that the debug adapter is ready to accept configuration requests (e.g. setBreakpoints, setExceptionBreakpoints). > > A debug adapter is expected to send this event when it is ready to accept configuration requests (but not before the initialize request has finished). Previously in tests, `intercept_debug_sessions` was just spawning off a background task to send the event after setting up the client, so the event wasn't actually synchronized with the flow of messages in the way the spec says it should be. This PR makes it so that the `FakeTransport` injects the event right after a successful response to the initialize request, and doesn't send it otherwise. Release Notes: - N/A Co-authored-by: Cole Miller --- crates/dap/src/client.rs | 2 +- crates/dap/src/transport.rs | 20 ++++++++++++++++++++ crates/project/src/debugger/test.rs | 10 +--------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs index 86a15b2d8a..7b791450ec 100644 --- a/crates/dap/src/client.rs +++ b/crates/dap/src/client.rs @@ -295,7 +295,7 @@ mod tests { request: dap_types::StartDebuggingRequestArgumentsRequest::Launch, }, }, - Box::new(|_| panic!("Did not expect to hit this code path")), + Box::new(|_| {}), &mut cx.to_async(), ) .await diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs index 6dadf1cf35..f9fbbfc842 100644 --- a/crates/dap/src/transport.rs +++ b/crates/dap/src/transport.rs @@ -883,6 +883,7 @@ impl FakeTransport { break Err(anyhow!("exit in response to request")); } }; + let success = response.success; let message = serde_json::to_string(&Message::Response(response)).unwrap(); @@ -893,6 +894,25 @@ impl FakeTransport { ) .await .unwrap(); + + if request.command == dap_types::requests::Initialize::COMMAND + && success + { + let message = serde_json::to_string(&Message::Event(Box::new( + dap_types::messages::Events::Initialized(Some( + Default::default(), + )), + ))) + .unwrap(); + writer + .write_all( + TransportDelegate::build_rpc_message(message) + .as_bytes(), + ) + .await + .unwrap(); + } + writer.flush().await.unwrap(); } } diff --git a/crates/project/src/debugger/test.rs b/crates/project/src/debugger/test.rs index 3b9425e369..53b88323e6 100644 --- a/crates/project/src/debugger/test.rs +++ b/crates/project/src/debugger/test.rs @@ -1,7 +1,7 @@ use std::{path::Path, sync::Arc}; use dap::client::DebugAdapterClient; -use gpui::{App, AppContext, Subscription}; +use gpui::{App, Subscription}; use super::session::{Session, SessionStateEvent}; @@ -19,14 +19,6 @@ pub fn intercept_debug_sessions) + 'static>( let client = session.adapter_client().unwrap(); register_default_handlers(session, &client, cx); configure(&client); - cx.background_spawn(async move { - client - .fake_event(dap::messages::Events::Initialized( - Some(Default::default()), - )) - .await - }) - .detach(); } }) .detach(); From 7d58eb200b5dbf291d8123bcb7d700ac26982f32 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 1 Aug 2025 14:15:54 -0400 Subject: [PATCH 06/56] zed 0.198.1 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7bc19e9c1..5eea46a513 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20194,7 +20194,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.198.0" +version = "0.198.1" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index a864ece683..6d087a3e5e 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.198.0" +version = "0.198.1" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 863e39b774057f92b8fa3c6591b2d480c04cb9ae Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 4 Aug 2025 11:20:15 -0600 Subject: [PATCH 07/56] Cherry-pick #35513 onto v0.198.x (#35591) Release Notes: - N/A --- Cargo.lock | 1 - crates/agent_ui/src/agent_panel.rs | 4 +-- crates/agent_ui/src/agent_ui.rs | 3 +- crates/agent_ui/src/inline_assistant.rs | 4 +-- crates/client/src/client.rs | 28 ----------------- crates/copilot/src/copilot.rs | 2 +- crates/editor/src/editor.rs | 13 ++++++-- crates/git_ui/Cargo.toml | 1 - crates/git_ui/src/commit_modal.rs | 2 +- crates/git_ui/src/git_panel.rs | 7 ++--- crates/inline_completion_button/Cargo.toml | 1 + .../src/inline_completion_button.rs | 3 +- crates/project/src/project.rs | 30 ++++++++++++++++++- crates/welcome/src/welcome.rs | 3 +- .../zed/src/zed/inline_completion_registry.rs | 3 +- crates/zed/src/zed/quick_action_bar.rs | 2 +- crates/zeta/src/init.rs | 2 +- 17 files changed, 59 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5eea46a513..2169bdfdbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6363,7 +6363,6 @@ dependencies = [ "buffer_diff", "call", "chrono", - "client", "cloud_llm_client", "collections", "command_palette_hooks", diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e7b1943561..190bb58ed1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -43,7 +43,7 @@ use anyhow::{Result, anyhow}; use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; -use client::{DisableAiSettings, UserStore, zed_urls}; +use client::{UserStore, zed_urls}; use cloud_llm_client::{CompletionIntent, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use feature_flags::{self, FeatureFlagAppExt}; @@ -58,7 +58,7 @@ use language::LanguageRegistry; use language_model::{ ConfigurationError, ConfiguredModel, LanguageModelProviderTosView, LanguageModelRegistry, }; -use project::{Project, ProjectPath, Worktree}; +use project::{DisableAiSettings, Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; use proto::Plan; use rules_library::{RulesLibrary, open_rules_library}; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 0800031abe..8f74492346 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -31,7 +31,7 @@ use std::sync::Arc; use agent::{Thread, ThreadId}; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelSelection}; use assistant_slash_command::SlashCommandRegistry; -use client::{Client, DisableAiSettings}; +use client::Client; use command_palette_hooks::CommandPaletteFilter; use feature_flags::FeatureFlagAppExt as _; use fs::Fs; @@ -40,6 +40,7 @@ use language::LanguageRegistry; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelId, LanguageModelProviderId, LanguageModelRegistry, }; +use project::DisableAiSettings; use prompt_store::PromptBuilder; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index ffa654d12b..159ccd0635 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -16,7 +16,7 @@ use agent::{ }; use agent_settings::AgentSettings; use anyhow::{Context as _, Result}; -use client::{DisableAiSettings, telemetry::Telemetry}; +use client::telemetry::Telemetry; use collections::{HashMap, HashSet, VecDeque, hash_map}; use editor::SelectionEffects; use editor::{ @@ -39,7 +39,7 @@ use language_model::{ }; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; -use project::{CodeAction, LspAction, Project, ProjectTransaction}; +use project::{CodeAction, DisableAiSettings, LspAction, Project, ProjectTransaction}; use prompt_store::{PromptBuilder, PromptStore}; use settings::{Settings, SettingsStore}; use telemetry_events::{AssistantEventData, AssistantKind, AssistantPhase}; diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 07e708f11b..462ddb3c32 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -151,7 +151,6 @@ impl Settings for ProxySettings { pub fn init_settings(cx: &mut App) { TelemetrySettings::register(cx); - DisableAiSettings::register(cx); ClientSettings::register(cx); ProxySettings::register(cx); } @@ -549,33 +548,6 @@ impl settings::Settings for TelemetrySettings { } } -/// Whether to disable all AI features in Zed. -/// -/// Default: false -#[derive(Copy, Clone, Debug)] -pub struct DisableAiSettings { - pub disable_ai: bool, -} - -impl settings::Settings for DisableAiSettings { - const KEY: Option<&'static str> = Some("disable_ai"); - - type FileContent = Option; - - fn load(sources: SettingsSources, _: &mut App) -> Result { - Ok(Self { - disable_ai: sources - .user - .or(sources.server) - .copied() - .flatten() - .unwrap_or(sources.default.ok_or_else(Self::missing_default)?), - }) - } - - fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} -} - impl Client { pub fn new( clock: Arc, diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index e11242cb15..a33f513e3e 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -6,7 +6,6 @@ mod sign_in; use crate::sign_in::initiate_sign_in_within_workspace; use ::fs::Fs; use anyhow::{Context as _, Result, anyhow}; -use client::DisableAiSettings; use collections::{HashMap, HashSet}; use command_palette_hooks::CommandPaletteFilter; use futures::{Future, FutureExt, TryFutureExt, channel::oneshot, future::Shared}; @@ -24,6 +23,7 @@ use language::{ use lsp::{LanguageServer, LanguageServerBinary, LanguageServerId, LanguageServerName}; use node_runtime::NodeRuntime; use parking_lot::Mutex; +use project::DisableAiSettings; use request::StatusNotification; use serde_json::json; use settings::Settings; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3516eff45c..3ab2fffa82 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -56,7 +56,7 @@ use aho_corasick::AhoCorasick; use anyhow::{Context as _, Result, anyhow}; use blink_manager::BlinkManager; use buffer_diff::DiffHunkStatus; -use client::{Collaborator, DisableAiSettings, ParticipantIndex}; +use client::{Collaborator, ParticipantIndex}; use clock::{AGENT_REPLICA_ID, ReplicaId}; use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use convert_case::{Case, Casing}; @@ -125,7 +125,7 @@ use markdown::Markdown; use mouse_context_menu::MouseContextMenu; use persistence::DB; use project::{ - BreakpointWithPosition, CompletionResponse, ProjectPath, + BreakpointWithPosition, CompletionResponse, DisableAiSettings, ProjectPath, debugger::{ breakpoint_store::{ BreakpointEditAction, BreakpointSessionState, BreakpointState, BreakpointStore, @@ -7003,6 +7003,10 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Option<()> { + if DisableAiSettings::get_global(cx).disable_ai { + return None; + } + let provider = self.edit_prediction_provider()?; let cursor = self.selections.newest_anchor().head(); let (buffer, cursor_buffer_position) = @@ -7060,6 +7064,7 @@ impl Editor { pub fn update_edit_prediction_settings(&mut self, cx: &mut Context) { if self.edit_prediction_provider.is_none() || DisableAiSettings::get_global(cx).disable_ai { self.edit_prediction_settings = EditPredictionSettings::Disabled; + self.discard_inline_completion(false, cx); } else { let selection = self.selections.newest_anchor(); let cursor = selection.head(); @@ -7677,6 +7682,10 @@ impl Editor { _window: &mut Window, cx: &mut Context, ) -> Option<()> { + if DisableAiSettings::get_global(cx).disable_ai { + return None; + } + let selection = self.selections.newest_anchor(); let cursor = selection.head(); let multibuffer = self.buffer.read(cx).snapshot(cx); diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 4c919249ee..e6547e7ae9 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -23,7 +23,6 @@ askpass.workspace = true buffer_diff.workspace = true call.workspace = true chrono.workspace = true -client.workspace = true cloud_llm_client.workspace = true collections.workspace = true command_palette_hooks.workspace = true diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 88ec2dc84e..5dfa800ae5 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -1,9 +1,9 @@ use crate::branch_picker::{self, BranchList}; use crate::git_panel::{GitPanel, commit_message_editor}; -use client::DisableAiSettings; use git::repository::CommitOptions; use git::{Amend, Commit, GenerateCommitMessage, Signoff}; use panel::{panel_button, panel_editor_style}; +use project::DisableAiSettings; use settings::Settings; use ui::{ ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e196a5b139..e869f18da9 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -12,7 +12,6 @@ use crate::{ use agent_settings::AgentSettings; use anyhow::Context as _; use askpass::AskPassDelegate; -use client::DisableAiSettings; use db::kvp::KEY_VALUE_STORE; use editor::{ Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar, @@ -51,10 +50,9 @@ use panel::{ PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button, panel_icon_button, }; -use project::git_store::{RepositoryEvent, RepositoryId}; use project::{ - Fs, Project, ProjectPath, - git_store::{GitStoreEvent, Repository}, + DisableAiSettings, Fs, Project, ProjectPath, + git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId}, }; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; @@ -5115,7 +5113,6 @@ mod tests { language::init(cx); editor::init(cx); Project::init_settings(cx); - client::DisableAiSettings::register(cx); crate::init(cx); }); } diff --git a/crates/inline_completion_button/Cargo.toml b/crates/inline_completion_button/Cargo.toml index b34e59336b..7b6ae43465 100644 --- a/crates/inline_completion_button/Cargo.toml +++ b/crates/inline_completion_button/Cargo.toml @@ -25,6 +25,7 @@ indoc.workspace = true inline_completion.workspace = true language.workspace = true paths.workspace = true +project.workspace = true regex.workspace = true settings.workspace = true supermaven.workspace = true diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index 81d9181cfc..a9a5ae5a49 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use client::{DisableAiSettings, UserStore, zed_urls}; +use client::{UserStore, zed_urls}; use cloud_llm_client::UsageLimit; use copilot::{Copilot, Status}; use editor::{ @@ -19,6 +19,7 @@ use language::{ EditPredictionsMode, File, Language, language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings}, }; +use project::DisableAiSettings; use regex::Regex; use settings::{Settings, SettingsStore, update_settings_file}; use std::{ diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 6b943216b3..f567d29438 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -97,7 +97,7 @@ use rpc::{ }; use search::{SearchInputKind, SearchQuery, SearchResult}; use search_history::SearchHistory; -use settings::{InvalidSettingsError, Settings, SettingsLocation, SettingsStore}; +use settings::{InvalidSettingsError, Settings, SettingsLocation, SettingsSources, SettingsStore}; use smol::channel::Receiver; use snippet::Snippet; use snippet_provider::SnippetProvider; @@ -942,10 +942,38 @@ pub enum PulledDiagnostics { }, } +/// Whether to disable all AI features in Zed. +/// +/// Default: false +#[derive(Copy, Clone, Debug)] +pub struct DisableAiSettings { + pub disable_ai: bool, +} + +impl settings::Settings for DisableAiSettings { + const KEY: Option<&'static str> = Some("disable_ai"); + + type FileContent = Option; + + fn load(sources: SettingsSources, _: &mut App) -> Result { + Ok(Self { + disable_ai: sources + .user + .or(sources.server) + .copied() + .flatten() + .unwrap_or(sources.default.ok_or_else(Self::missing_default)?), + }) + } + + fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {} +} + impl Project { pub fn init_settings(cx: &mut App) { WorktreeSettings::register(cx); ProjectSettings::register(cx); + DisableAiSettings::register(cx); } pub fn init(client: &Arc, cx: &mut App) { diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index 49bf2031ab..3a299441eb 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -1,10 +1,11 @@ -use client::{DisableAiSettings, TelemetrySettings, telemetry::Telemetry}; +use client::{TelemetrySettings, telemetry::Telemetry}; use db::kvp::KEY_VALUE_STORE; use gpui::{ Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, Task, WeakEntity, Window, actions, svg, }; use language::language_settings::{EditPredictionProvider, all_language_settings}; +use project::DisableAiSettings; use settings::{Settings, SettingsStore}; use std::sync::Arc; use ui::{CheckboxWithLabel, ElevationIndex, Tooltip, prelude::*}; diff --git a/crates/zed/src/zed/inline_completion_registry.rs b/crates/zed/src/zed/inline_completion_registry.rs index 52b7166a11..a8037f0f90 100644 --- a/crates/zed/src/zed/inline_completion_registry.rs +++ b/crates/zed/src/zed/inline_completion_registry.rs @@ -1,9 +1,10 @@ -use client::{Client, DisableAiSettings, UserStore}; +use client::{Client, UserStore}; use collections::HashMap; use copilot::{Copilot, CopilotCompletionProvider}; use editor::Editor; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; use language::language_settings::{EditPredictionProvider, all_language_settings}; +use project::DisableAiSettings; use settings::{Settings as _, SettingsStore}; use smol::stream::StreamExt; use std::{cell::RefCell, rc::Rc, sync::Arc}; diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index aff124a0bc..79559809ba 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -2,7 +2,6 @@ mod preview; mod repl_menu; use agent_settings::AgentSettings; -use client::DisableAiSettings; use editor::actions::{ AddSelectionAbove, AddSelectionBelow, CodeActionSource, DuplicateLineDown, GoToDiagnostic, GoToHunk, GoToPreviousDiagnostic, GoToPreviousHunk, MoveLineDown, MoveLineUp, SelectAll, @@ -16,6 +15,7 @@ use gpui::{ FocusHandle, Focusable, InteractiveElement, ParentElement, Render, Styled, Subscription, WeakEntity, Window, anchored, deferred, point, }; +use project::DisableAiSettings; use project::project_settings::DiagnosticSeverity; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, SettingsStore}; diff --git a/crates/zeta/src/init.rs b/crates/zeta/src/init.rs index 4a65771223..a01e3a89a2 100644 --- a/crates/zeta/src/init.rs +++ b/crates/zeta/src/init.rs @@ -1,10 +1,10 @@ use std::any::{Any, TypeId}; -use client::DisableAiSettings; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{FeatureFlagAppExt as _, PredictEditsRateCompletionsFeatureFlag}; use gpui::actions; use language::language_settings::{AllLanguageSettings, EditPredictionProvider}; +use project::DisableAiSettings; use settings::{Settings, SettingsStore, update_settings_file}; use ui::App; use workspace::Workspace; From 900fe328c0110037b06828dd629f64d2fc5f84d9 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Mon, 4 Aug 2025 11:37:34 -0400 Subject: [PATCH 08/56] Fix escape in terminal with JetBrains keymap (#35585) Closes https://github.com/zed-industries/zed/issues/35429 Closes https://github.com/zed-industries/zed/issues/35091 Follow-up to: https://github.com/zed-industries/zed/pull/35230 Release Notes: - Fix `escape` in Terminal broken in JetBrains compatability keymaps --- assets/keymaps/linux/jetbrains.json | 2 +- assets/keymaps/macos/jetbrains.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 9bc1f24bfb..3df1243fed 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -166,7 +166,7 @@ { "context": "Diagnostics > Editor", "bindings": { "alt-6": "pane::CloseActiveItem" } }, { "context": "OutlinePanel", "bindings": { "alt-7": "workspace::CloseActiveDock" } }, { - "context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", + "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", "shift-escape": "workspace::CloseActiveDock" diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index b1cd51a338..66962811f4 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -167,7 +167,7 @@ { "context": "Diagnostics > Editor", "bindings": { "cmd-6": "pane::CloseActiveItem" } }, { "context": "OutlinePanel", "bindings": { "cmd-7": "workspace::CloseActiveDock" } }, { - "context": "Dock || Workspace || Terminal || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", + "context": "Dock || Workspace || OutlinePanel || ProjectPanel || CollabPanel || (Editor && mode == auto_height)", "bindings": { "escape": "editor::ToggleFocus", "shift-escape": "workspace::CloseActiveDock" From 0c24950911c767bc026e1cc151a2500742d22e32 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 5 Aug 2025 14:09:21 -0400 Subject: [PATCH 09/56] ci: Double Buildjet ARM runner size (24GB to 48GB ram) (#35654) Release Notes: - N/A --- .github/actionlint.yml | 1 - .github/workflows/ci.yml | 2 +- .github/workflows/release_nightly.yml | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/actionlint.yml b/.github/actionlint.yml index d93ec5b15e..6bfbc27705 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -24,7 +24,6 @@ self-hosted-runner: - buildjet-8vcpu-ubuntu-2204-arm - buildjet-16vcpu-ubuntu-2204-arm - buildjet-32vcpu-ubuntu-2204-arm - - buildjet-64vcpu-ubuntu-2204-arm # Self Hosted Runners - self-mini-macos - self-32vcpu-windows-2022 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dfc33e0d2..0e4fed45ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -649,7 +649,7 @@ jobs: timeout-minutes: 60 name: Linux arm64 release bundle runs-on: - - buildjet-16vcpu-ubuntu-2204-arm + - buildjet-32vcpu-ubuntu-2204-arm if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 4f7506967b..e2fdc98536 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -167,7 +167,7 @@ jobs: name: Create a Linux *.tar.gz bundle for ARM if: github.repository_owner == 'zed-industries' runs-on: - - buildjet-16vcpu-ubuntu-2204-arm + - buildjet-32vcpu-ubuntu-2204-arm needs: tests steps: - name: Checkout repo From 25f3f88a347ebeac27f890a2ad6bc97fba10d0a1 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 5 Aug 2025 14:16:47 -0400 Subject: [PATCH 10/56] Add Claude Opus 4.1 (#35653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-08-05 at 1 55 35 PM Release Notes: - Added support for Claude Opus 4.1 Co-authored-by: Marshall Bowers --- crates/anthropic/src/anthropic.rs | 30 +++++++++++++++++++++ crates/bedrock/src/models.rs | 44 ++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index c73f606045..3ff1666755 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -36,11 +36,18 @@ pub enum AnthropicModelMode { pub enum Model { #[serde(rename = "claude-opus-4", alias = "claude-opus-4-latest")] ClaudeOpus4, + #[serde(rename = "claude-opus-4-1", alias = "claude-opus-4-1-latest")] + ClaudeOpus4_1, #[serde( rename = "claude-opus-4-thinking", alias = "claude-opus-4-thinking-latest" )] ClaudeOpus4Thinking, + #[serde( + rename = "claude-opus-4-1-thinking", + alias = "claude-opus-4-1-thinking-latest" + )] + ClaudeOpus4_1Thinking, #[default] #[serde(rename = "claude-sonnet-4", alias = "claude-sonnet-4-latest")] ClaudeSonnet4, @@ -91,10 +98,18 @@ impl Model { } pub fn from_id(id: &str) -> Result { + if id.starts_with("claude-opus-4-1-thinking") { + return Ok(Self::ClaudeOpus4_1Thinking); + } + if id.starts_with("claude-opus-4-thinking") { return Ok(Self::ClaudeOpus4Thinking); } + if id.starts_with("claude-opus-4-1") { + return Ok(Self::ClaudeOpus4_1); + } + if id.starts_with("claude-opus-4") { return Ok(Self::ClaudeOpus4); } @@ -141,7 +156,9 @@ impl Model { pub fn id(&self) -> &str { match self { Self::ClaudeOpus4 => "claude-opus-4-latest", + Self::ClaudeOpus4_1 => "claude-opus-4-1-latest", Self::ClaudeOpus4Thinking => "claude-opus-4-thinking-latest", + Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-thinking-latest", Self::ClaudeSonnet4 => "claude-sonnet-4-latest", Self::ClaudeSonnet4Thinking => "claude-sonnet-4-thinking-latest", Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest", @@ -159,6 +176,7 @@ impl Model { pub fn request_id(&self) -> &str { match self { Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking => "claude-opus-4-20250514", + Self::ClaudeOpus4_1 | Self::ClaudeOpus4_1Thinking => "claude-opus-4-1-20250805", Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => "claude-sonnet-4-20250514", Self::Claude3_5Sonnet => "claude-3-5-sonnet-latest", Self::Claude3_7Sonnet | Self::Claude3_7SonnetThinking => "claude-3-7-sonnet-latest", @@ -173,7 +191,9 @@ impl Model { pub fn display_name(&self) -> &str { match self { Self::ClaudeOpus4 => "Claude Opus 4", + Self::ClaudeOpus4_1 => "Claude Opus 4.1", Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking", + Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking", Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking", Self::Claude3_7Sonnet => "Claude 3.7 Sonnet", @@ -192,7 +212,9 @@ impl Model { pub fn cache_configuration(&self) -> Option { match self { Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::Claude3_5Sonnet @@ -215,7 +237,9 @@ impl Model { pub fn max_token_count(&self) -> u64 { match self { Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::Claude3_5Sonnet @@ -232,7 +256,9 @@ impl Model { pub fn max_output_tokens(&self) -> u64 { match self { Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::Claude3_5Sonnet @@ -249,7 +275,9 @@ impl Model { pub fn default_temperature(&self) -> f32 { match self { Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::Claude3_5Sonnet @@ -269,6 +297,7 @@ impl Model { pub fn mode(&self) -> AnthropicModelMode { match self { Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 | Self::ClaudeSonnet4 | Self::Claude3_5Sonnet | Self::Claude3_7Sonnet @@ -277,6 +306,7 @@ impl Model { | Self::Claude3Sonnet | Self::Claude3Haiku => AnthropicModelMode::Default, Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4Thinking | Self::Claude3_7SonnetThinking => AnthropicModelMode::Thinking { budget_tokens: Some(4_096), diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index b6eeafa2d6..69d2ffb845 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -32,11 +32,18 @@ pub enum Model { ClaudeSonnet4Thinking, #[serde(rename = "claude-opus-4", alias = "claude-opus-4-latest")] ClaudeOpus4, + #[serde(rename = "claude-opus-4-1", alias = "claude-opus-4-1-latest")] + ClaudeOpus4_1, #[serde( rename = "claude-opus-4-thinking", alias = "claude-opus-4-thinking-latest" )] ClaudeOpus4Thinking, + #[serde( + rename = "claude-opus-4-1-thinking", + alias = "claude-opus-4-1-thinking-latest" + )] + ClaudeOpus4_1Thinking, #[serde(rename = "claude-3-5-sonnet-v2", alias = "claude-3-5-sonnet-latest")] Claude3_5SonnetV2, #[serde(rename = "claude-3-7-sonnet", alias = "claude-3-7-sonnet-latest")] @@ -147,7 +154,9 @@ impl Model { Model::ClaudeSonnet4 => "claude-4-sonnet", Model::ClaudeSonnet4Thinking => "claude-4-sonnet-thinking", Model::ClaudeOpus4 => "claude-4-opus", + Model::ClaudeOpus4_1 => "claude-4-opus-1", Model::ClaudeOpus4Thinking => "claude-4-opus-thinking", + Model::ClaudeOpus4_1Thinking => "claude-4-opus-1-thinking", Model::Claude3_5SonnetV2 => "claude-3-5-sonnet-v2", Model::Claude3_5Sonnet => "claude-3-5-sonnet", Model::Claude3Opus => "claude-3-opus", @@ -208,6 +217,9 @@ impl Model { Model::ClaudeOpus4 | Model::ClaudeOpus4Thinking => { "anthropic.claude-opus-4-20250514-v1:0" } + Model::ClaudeOpus4_1 | Model::ClaudeOpus4_1Thinking => { + "anthropic.claude-opus-4-1-20250805-v1:0" + } Model::Claude3_5SonnetV2 => "anthropic.claude-3-5-sonnet-20241022-v2:0", Model::Claude3_5Sonnet => "anthropic.claude-3-5-sonnet-20240620-v1:0", Model::Claude3Opus => "anthropic.claude-3-opus-20240229-v1:0", @@ -266,7 +278,9 @@ impl Model { Self::ClaudeSonnet4 => "Claude Sonnet 4", Self::ClaudeSonnet4Thinking => "Claude Sonnet 4 Thinking", Self::ClaudeOpus4 => "Claude Opus 4", + Self::ClaudeOpus4_1 => "Claude Opus 4.1", Self::ClaudeOpus4Thinking => "Claude Opus 4 Thinking", + Self::ClaudeOpus4_1Thinking => "Claude Opus 4.1 Thinking", Self::Claude3_5SonnetV2 => "Claude 3.5 Sonnet v2", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", Self::Claude3Opus => "Claude 3 Opus", @@ -330,8 +344,10 @@ impl Model { | Self::Claude3_7Sonnet | Self::ClaudeSonnet4 | Self::ClaudeOpus4 + | Self::ClaudeOpus4_1 | Self::ClaudeSonnet4Thinking - | Self::ClaudeOpus4Thinking => 200_000, + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1Thinking => 200_000, Self::AmazonNovaPremier => 1_000_000, Self::PalmyraWriterX5 => 1_000_000, Self::PalmyraWriterX4 => 128_000, @@ -348,7 +364,9 @@ impl Model { | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeOpus4 - | Model::ClaudeOpus4Thinking => 128_000, + | Model::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1 + | Model::ClaudeOpus4_1Thinking => 128_000, Self::Claude3_5SonnetV2 | Self::PalmyraWriterX4 | Self::PalmyraWriterX5 => 8_192, Self::Custom { max_output_tokens, .. @@ -366,6 +384,8 @@ impl Model { | Self::Claude3_7Sonnet | Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking => 1.0, Self::Custom { @@ -387,6 +407,8 @@ impl Model { | Self::Claude3_7SonnetThinking | Self::ClaudeOpus4 | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_1Thinking | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::Claude3_5Haiku => true, @@ -420,7 +442,9 @@ impl Model { | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeOpus4 - | Self::ClaudeOpus4Thinking => true, + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_1Thinking => true, // Custom models - check if they have cache configuration Self::Custom { @@ -440,7 +464,9 @@ impl Model { | Self::ClaudeSonnet4 | Self::ClaudeSonnet4Thinking | Self::ClaudeOpus4 - | Self::ClaudeOpus4Thinking => Some(BedrockModelCacheConfiguration { + | Self::ClaudeOpus4Thinking + | Self::ClaudeOpus4_1 + | Self::ClaudeOpus4_1Thinking => Some(BedrockModelCacheConfiguration { max_cache_anchors: 4, min_total_token: 1024, }), @@ -467,9 +493,11 @@ impl Model { Model::ClaudeSonnet4Thinking => BedrockModelMode::Thinking { budget_tokens: Some(4096), }, - Model::ClaudeOpus4Thinking => BedrockModelMode::Thinking { - budget_tokens: Some(4096), - }, + Model::ClaudeOpus4Thinking | Model::ClaudeOpus4_1Thinking => { + BedrockModelMode::Thinking { + budget_tokens: Some(4096), + } + } _ => BedrockModelMode::Default, } } @@ -518,6 +546,8 @@ impl Model { | Model::ClaudeSonnet4Thinking | Model::ClaudeOpus4 | Model::ClaudeOpus4Thinking + | Model::ClaudeOpus4_1 + | Model::ClaudeOpus4_1Thinking | Model::Claude3Haiku | Model::Claude3Opus | Model::Claude3Sonnet From 8c159d0fbd65c8be07e61e679ed6596d13b851cc Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 5 Aug 2025 15:08:47 -0400 Subject: [PATCH 11/56] zed 0.198.2 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2169bdfdbe..8bca1a8030 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20193,7 +20193,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.198.1" +version = "0.198.2" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 6d087a3e5e..ab7143264f 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.198.1" +version = "0.198.2" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 85261bb5cc1f87096e8a80e6566bb5f062ea9a78 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 5 Aug 2025 00:32:42 +0000 Subject: [PATCH 12/56] assistant_tool: Fix rejecting edits deletes newly created and accepted files (#35622) Closes #34108 Closes #33234 This PR fixes a bug where a file remained in a Created state after accept, causing following reject actions to incorrectly delete the file instead of reverting back to previous state. Now it changes it to Modified state upon "Accept All" and "Accept Hunk" (when all edits are accepted). - [x] Tests Release Notes: - Fixed issue where rejecting AI edits on newly created files would delete the file instead of reverting to previous accepted state. --- crates/assistant_tool/src/action_log.rs | 136 ++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs index 672c048872..025aba060d 100644 --- a/crates/assistant_tool/src/action_log.rs +++ b/crates/assistant_tool/src/action_log.rs @@ -630,6 +630,11 @@ impl ActionLog { false } }); + if tracked_buffer.unreviewed_edits.is_empty() { + if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status { + tracked_buffer.status = TrackedBufferStatus::Modified; + } + } tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); } } @@ -775,6 +780,9 @@ impl ActionLog { .retain(|_buffer, tracked_buffer| match tracked_buffer.status { TrackedBufferStatus::Deleted => false, _ => { + if let TrackedBufferStatus::Created { .. } = &mut tracked_buffer.status { + tracked_buffer.status = TrackedBufferStatus::Modified; + } tracked_buffer.unreviewed_edits.clear(); tracked_buffer.diff_base = tracked_buffer.snapshot.as_rope().clone(); tracked_buffer.schedule_diff_update(ChangeAuthor::User, cx); @@ -2075,6 +2083,134 @@ mod tests { assert_eq!(content, "ai content\nuser added this line"); } + #[gpui::test] + async fn test_reject_after_accepting_hunk_on_created_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| { + project.find_project_path("dir/new_file", cx) + }) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx)) + .await + .unwrap(); + + // AI creates file with initial content + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx)); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + assert_ne!(unreviewed_hunks(&action_log, cx), vec![]); + + // User accepts the single hunk + action_log.update(cx, |log, cx| { + log.keep_edits_in_range(buffer.clone(), Anchor::MIN..Anchor::MAX, cx) + }); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + assert!(fs.is_file(path!("/dir/new_file").as_ref()).await); + + // AI modifies the file + cx.update(|cx| { + buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx)); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + assert_ne!(unreviewed_hunks(&action_log, cx), vec![]); + + // User rejects the hunk + action_log + .update(cx, |log, cx| { + log.reject_edits_in_ranges(buffer.clone(), vec![Anchor::MIN..Anchor::MAX], cx) + }) + .await + .unwrap(); + cx.run_until_parked(); + assert!(fs.is_file(path!("/dir/new_file").as_ref()).await,); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "ai content v1" + ); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + + #[gpui::test] + async fn test_reject_edits_on_previously_accepted_created_file(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| { + project.find_project_path("dir/new_file", cx) + }) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path.clone(), cx)) + .await + .unwrap(); + + // AI creates file with initial content + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_created(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| buffer.set_text("ai content v1", cx)); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + + // User clicks "Accept All" + action_log.update(cx, |log, cx| log.keep_all_edits(cx)); + cx.run_until_parked(); + assert!(fs.is_file(path!("/dir/new_file").as_ref()).await); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); // Hunks are cleared + + // AI modifies file again + cx.update(|cx| { + buffer.update(cx, |buffer, cx| buffer.set_text("ai content v2", cx)); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + project + .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) + .await + .unwrap(); + cx.run_until_parked(); + assert_ne!(unreviewed_hunks(&action_log, cx), vec![]); + + // User clicks "Reject All" + action_log + .update(cx, |log, cx| log.reject_all_edits(cx)) + .await; + cx.run_until_parked(); + assert!(fs.is_file(path!("/dir/new_file").as_ref()).await); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + "ai content v1" + ); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + #[gpui::test(iterations = 100)] async fn test_random_diffs(mut rng: StdRng, cx: &mut TestAppContext) { init_test(cx); From 295da0757e63e25d4c6fe9ea98e725d2dee294ff Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 5 Aug 2025 20:40:33 -0700 Subject: [PATCH 13/56] Respect paths' content masks when copying them from MSAA texture to drawable (#35688) Fixes a regression introduced in https://github.com/zed-industries/zed/pull/34992 Paths are rendered first to an intermediate MSAA texture, and then copied to the final drawable. Because paths can have transparency, it's important that pixels are not copied repeatedly if paths have overlapping bounding boxes. When N paths have the same draw order, we infer that they must have disjoint bounding boxes, so that we can copy them each individually (as opposed to copying a single rect that contains them all). Previously, the bounding box that we were using to copy paths was not accounting for the path's content mask (but it is accounted for in the bounds tree that determines their draw order). This cause bugs like this, where certain path pixels spuriously had their opacity doubled: https://github.com/user-attachments/assets/d792e60c-790b-49ad-b435-6695daba430f This PR fixes that bug. * [x] mac * [x] linux * [x] windows Release Notes: - Fixed a bug where a selection's opacity was computed incorrectly when it overlapped with another editor's selections in a certain way. --- .../gpui/src/platform/blade/blade_renderer.rs | 8 ++++---- crates/gpui/src/platform/mac/metal_renderer.rs | 6 +++--- crates/gpui/src/scene.rs | 17 ++++++++++++++++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/gpui/src/platform/blade/blade_renderer.rs b/crates/gpui/src/platform/blade/blade_renderer.rs index 2e18d2be22..46d3c16c72 100644 --- a/crates/gpui/src/platform/blade/blade_renderer.rs +++ b/crates/gpui/src/platform/blade/blade_renderer.rs @@ -606,7 +606,7 @@ impl BladeRenderer { xy_position: v.xy_position, st_position: v.st_position, color: path.color, - bounds: path.bounds.intersect(&path.content_mask.bounds), + bounds: path.clipped_bounds(), })); } let vertex_buf = unsafe { self.instance_belt.alloc_typed(&vertices, &self.gpu) }; @@ -735,13 +735,13 @@ impl BladeRenderer { paths .iter() .map(|path| PathSprite { - bounds: path.bounds, + bounds: path.clipped_bounds(), }) .collect() } else { - let mut bounds = first_path.bounds; + let mut bounds = first_path.clipped_bounds(); for path in paths.iter().skip(1) { - bounds = bounds.union(&path.bounds); + bounds = bounds.union(&path.clipped_bounds()); } vec![PathSprite { bounds }] }; diff --git a/crates/gpui/src/platform/mac/metal_renderer.rs b/crates/gpui/src/platform/mac/metal_renderer.rs index fb5cb852d6..629654014d 100644 --- a/crates/gpui/src/platform/mac/metal_renderer.rs +++ b/crates/gpui/src/platform/mac/metal_renderer.rs @@ -791,13 +791,13 @@ impl MetalRenderer { sprites = paths .iter() .map(|path| PathSprite { - bounds: path.bounds, + bounds: path.clipped_bounds(), }) .collect(); } else { - let mut bounds = first_path.bounds; + let mut bounds = first_path.clipped_bounds(); for path in paths.iter().skip(1) { - bounds = bounds.union(&path.bounds); + bounds = bounds.union(&path.clipped_bounds()); } sprites = vec![PathSprite { bounds }]; } diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index ec8d720cdf..c527dfe750 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -8,7 +8,12 @@ use crate::{ AtlasTextureId, AtlasTile, Background, Bounds, ContentMask, Corners, Edges, Hsla, Pixels, Point, Radians, ScaledPixels, Size, bounds_tree::BoundsTree, point, }; -use std::{fmt::Debug, iter::Peekable, ops::Range, slice}; +use std::{ + fmt::Debug, + iter::Peekable, + ops::{Add, Range, Sub}, + slice, +}; #[allow(non_camel_case_types, unused)] pub(crate) type PathVertex_ScaledPixels = PathVertex; @@ -793,6 +798,16 @@ impl Path { } } +impl Path +where + T: Clone + Debug + Default + PartialEq + PartialOrd + Add + Sub, +{ + #[allow(unused)] + pub(crate) fn clipped_bounds(&self) -> Bounds { + self.bounds.intersect(&self.content_mask.bounds) + } +} + impl From> for Primitive { fn from(path: Path) -> Self { Primitive::Path(path) From 1934e5c23e2d3de24368a68a14bbefe2baf93ebd Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 6 Aug 2025 08:40:17 -0400 Subject: [PATCH 14/56] v0.198.x stable --- crates/zed/RELEASE_CHANNEL | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/RELEASE_CHANNEL b/crates/zed/RELEASE_CHANNEL index 4de2f126df..870bbe4e50 100644 --- a/crates/zed/RELEASE_CHANNEL +++ b/crates/zed/RELEASE_CHANNEL @@ -1 +1 @@ -preview \ No newline at end of file +stable \ No newline at end of file From bf6f7159619876e86b241ee2487bea794680a6fd Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 7 Aug 2025 14:18:15 -0400 Subject: [PATCH 15/56] Add GPT-5 support through OpenAI API --- .../language_models/src/provider/open_ai.rs | 5 +++- crates/open_ai/src/open_ai.rs | 26 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 6c4d4c9b3e..5c519b84a0 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -673,7 +673,10 @@ pub fn count_open_ai_tokens( | Model::O1 | Model::O3 | Model::O3Mini - | Model::O4Mini => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), + | Model::O4Mini + | Model::Five + | Model::FiveMini + | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), } .map(|tokens| tokens as u64) }) diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 12a5cf52d2..2661ab936e 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -74,6 +74,12 @@ pub enum Model { O3, #[serde(rename = "o4-mini")] O4Mini, + #[serde(rename = "gpt-5")] + Five, + #[serde(rename = "gpt-5-mini")] + FiveMini, + #[serde(rename = "gpt-5-nano")] + FiveNano, #[serde(rename = "custom")] Custom { @@ -105,6 +111,9 @@ impl Model { "o3-mini" => Ok(Self::O3Mini), "o3" => Ok(Self::O3), "o4-mini" => Ok(Self::O4Mini), + "gpt-5" => Ok(Self::Five), + "gpt-5-mini" => Ok(Self::FiveMini), + "gpt-5-nano" => Ok(Self::FiveNano), invalid_id => anyhow::bail!("invalid model id '{invalid_id}'"), } } @@ -123,6 +132,9 @@ impl Model { Self::O3Mini => "o3-mini", Self::O3 => "o3", Self::O4Mini => "o4-mini", + Self::Five => "gpt-5", + Self::FiveMini => "gpt-5-mini", + Self::FiveNano => "gpt-5-nano", Self::Custom { name, .. } => name, } } @@ -141,6 +153,9 @@ impl Model { Self::O3Mini => "o3-mini", Self::O3 => "o3", Self::O4Mini => "o4-mini", + Self::Five => "gpt-5", + Self::FiveMini => "gpt-5-mini", + Self::FiveNano => "gpt-5-nano", Self::Custom { name, display_name, .. } => display_name.as_ref().unwrap_or(name), @@ -161,6 +176,9 @@ impl Model { Self::O3Mini => 200_000, Self::O3 => 200_000, Self::O4Mini => 200_000, + Self::Five => 256_000, + Self::FiveMini => 256_000, + Self::FiveNano => 256_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -182,6 +200,9 @@ impl Model { Self::O3Mini => Some(100_000), Self::O3 => Some(100_000), Self::O4Mini => Some(100_000), + Self::Five => Some(65_536), + Self::FiveMini => Some(65_536), + Self::FiveNano => Some(65_536), } } @@ -197,7 +218,10 @@ impl Model { | Self::FourOmniMini | Self::FourPointOne | Self::FourPointOneMini - | Self::FourPointOneNano => true, + | Self::FourPointOneNano + | Self::Five + | Self::FiveMini + | Self::FiveNano => true, Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false, } } From d5b6a4d71066473e25e292bef4d482c23fd5f8f2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 7 Aug 2025 14:27:16 -0400 Subject: [PATCH 16/56] Update GPT-5 input/output token counts --- crates/open_ai/src/open_ai.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 2661ab936e..4697d71ed3 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -176,9 +176,9 @@ impl Model { Self::O3Mini => 200_000, Self::O3 => 200_000, Self::O4Mini => 200_000, - Self::Five => 256_000, - Self::FiveMini => 256_000, - Self::FiveNano => 256_000, + Self::Five => 272_000, + Self::FiveMini => 272_000, + Self::FiveNano => 272_000, Self::Custom { max_tokens, .. } => *max_tokens, } } @@ -200,9 +200,9 @@ impl Model { Self::O3Mini => Some(100_000), Self::O3 => Some(100_000), Self::O4Mini => Some(100_000), - Self::Five => Some(65_536), - Self::FiveMini => Some(65_536), - Self::FiveNano => Some(65_536), + Self::Five => Some(128_000), + Self::FiveMini => Some(128_000), + Self::FiveNano => Some(128_000), } } From a6b9668355894aaed1129d9537baeed9296258f0 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 7 Aug 2025 14:39:58 -0400 Subject: [PATCH 17/56] Use gpt-4o tokenizer for gpt-5 for now --- crates/language_models/src/provider/open_ai.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 5c519b84a0..5b20f8f9da 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -673,10 +673,11 @@ pub fn count_open_ai_tokens( | Model::O1 | Model::O3 | Model::O3Mini - | Model::O4Mini - | Model::Five - | Model::FiveMini - | Model::FiveNano => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), + | Model::O4Mini => tiktoken_rs::num_tokens_from_messages(model.id(), &messages), + // GPT-5 models don't have tiktoken support yet; fall back on gpt-4o tokenizer + Model::Five | Model::FiveMini | Model::FiveNano => { + tiktoken_rs::num_tokens_from_messages("gpt-4o", &messages) + } } .map(|tokens| tokens as u64) }) From 09845c0a3aa182d922a4a52c4cceb3cf93c30036 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Thu, 7 Aug 2025 15:16:01 -0400 Subject: [PATCH 18/56] zed 0.198.3 --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8bca1a8030..b7346f5c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20193,7 +20193,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.198.2" +version = "0.198.3" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index ab7143264f..8078e17c83 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.198.2" +version = "0.198.3" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 8b25b8172ba5bfe7ea05ee6499432335fa1b8238 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 7 Aug 2025 16:59:11 -0400 Subject: [PATCH 19/56] ci: Switch from BuildJet to GitHub runners (#35826) In response to an ongoing BuildJet outage, consider migrating CI to GitHub hosted runners. Also includes revert of (causing flaky tests): - https://github.com/zed-industries/zed/pull/35741 Downsides: - Cost (2x) - Force migration to Ubuntu 22.04 from 20.04 will bump our glibc minimum from 2.31 to 2.35. Which would break RHEL 9.x (glibc 2.34), Ubuntu 20.04 (EOL) and derivatives. Release Notes: - N/A --- .github/actionlint.yml | 5 +++++ .github/actions/build_docs/action.yml | 2 +- .github/workflows/bump_patch_version.yml | 2 +- .github/workflows/ci.yml | 19 ++++++++++--------- .github/workflows/deploy_cloudflare.yml | 2 +- .github/workflows/deploy_collab.yml | 4 ++-- .github/workflows/eval.yml | 4 ++-- .github/workflows/nix.yml | 2 +- .github/workflows/randomized_tests.yml | 2 +- .github/workflows/release_nightly.yml | 5 +++-- .github/workflows/unit_evals.yml | 4 ++-- 11 files changed, 29 insertions(+), 22 deletions(-) diff --git a/.github/actionlint.yml b/.github/actionlint.yml index 6bfbc27705..06b48b9b54 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -5,6 +5,11 @@ self-hosted-runner: # GitHub-hosted Runners - github-8vcpu-ubuntu-2404 - github-16vcpu-ubuntu-2404 + - github-32vcpu-ubuntu-2404 + - github-8vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 + - github-32vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204-arm - windows-2025-16 - windows-2025-32 - windows-2025-64 diff --git a/.github/actions/build_docs/action.yml b/.github/actions/build_docs/action.yml index 9a2d7e1ec7..80bd63837f 100644 --- a/.github/actions/build_docs/action.yml +++ b/.github/actions/build_docs/action.yml @@ -13,7 +13,7 @@ runs: uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - cache-provider: "buildjet" + # cache-provider: "buildjet" - name: Install Linux dependencies shell: bash -euxo pipefail {0} diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index 8a48ff96f1..bc44066ea6 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -16,7 +16,7 @@ jobs: bump_patch_version: if: github.repository_owner == 'zed-industries' runs-on: - - buildjet-16vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e4fed45ce..f5f030d5a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,7 @@ jobs: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' runs-on: - - buildjet-8vcpu-ubuntu-2204 + - github-8vcpu-ubuntu-2204 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -167,7 +167,7 @@ jobs: needs: [job_spec] if: github.repository_owner == 'zed-industries' runs-on: - - buildjet-8vcpu-ubuntu-2204 + - github-8vcpu-ubuntu-2204 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -220,7 +220,7 @@ jobs: github.repository_owner == 'zed-industries' && (needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true') runs-on: - - buildjet-8vcpu-ubuntu-2204 + - github-8vcpu-ubuntu-2204 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -327,7 +327,7 @@ jobs: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' runs-on: - - buildjet-16vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -341,7 +341,7 @@ jobs: uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - cache-provider: "buildjet" + # cache-provider: "buildjet" - name: Install Linux dependencies run: ./script/linux @@ -379,7 +379,7 @@ jobs: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' runs-on: - - buildjet-8vcpu-ubuntu-2204 + - github-8vcpu-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -393,7 +393,7 @@ jobs: uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - cache-provider: "buildjet" + # cache-provider: "buildjet" - name: Install Clang & Mold run: ./script/remote-server && ./script/install-mold 2.34.0 @@ -596,7 +596,8 @@ jobs: timeout-minutes: 60 name: Linux x86_x64 release bundle runs-on: - - buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc + - github-16vcpu-ubuntu-2204 + # - buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') @@ -649,7 +650,7 @@ jobs: timeout-minutes: 60 name: Linux arm64 release bundle runs-on: - - buildjet-32vcpu-ubuntu-2204-arm + - github-16vcpu-ubuntu-2204-arm if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index fe443d493e..3a294fdc17 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -9,7 +9,7 @@ jobs: deploy-docs: name: Deploy Docs if: github.repository_owner == 'zed-industries' - runs-on: buildjet-16vcpu-ubuntu-2204 + runs-on: github-16vcpu-ubuntu-2204 steps: - name: Checkout repo diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index f7348a1069..2e65b62d81 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -61,7 +61,7 @@ jobs: - style - tests runs-on: - - buildjet-16vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 steps: - name: Install doctl uses: digitalocean/action-doctl@v2 @@ -94,7 +94,7 @@ jobs: needs: - publish runs-on: - - buildjet-16vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 steps: - name: Checkout repo diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index 2ad302a602..196e00519b 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -32,7 +32,7 @@ jobs: github.repository_owner == 'zed-industries' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-eval')) runs-on: - - buildjet-16vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -46,7 +46,7 @@ jobs: uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - cache-provider: "buildjet" + # cache-provider: "buildjet" - name: Install Linux dependencies run: ./script/linux diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index beacd27774..6fb12c47e2 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -20,7 +20,7 @@ jobs: matrix: system: - os: x86 Linux - runner: buildjet-16vcpu-ubuntu-2204 + runner: github-16vcpu-ubuntu-2204 install_nix: true - os: arm Mac runner: [macOS, ARM64, test] diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml index db4d44318e..3a7b476ba0 100644 --- a/.github/workflows/randomized_tests.yml +++ b/.github/workflows/randomized_tests.yml @@ -20,7 +20,7 @@ jobs: name: Run randomized tests if: github.repository_owner == 'zed-industries' runs-on: - - buildjet-16vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 steps: - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index e2fdc98536..f7e0298f69 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -127,7 +127,8 @@ jobs: name: Create a Linux *.tar.gz bundle for x86 if: github.repository_owner == 'zed-industries' runs-on: - - buildjet-16vcpu-ubuntu-2004 + - github-16vcpu-ubuntu-2204 + # - buildjet-16vcpu-ubuntu-2004 needs: tests steps: - name: Checkout repo @@ -167,7 +168,7 @@ jobs: name: Create a Linux *.tar.gz bundle for ARM if: github.repository_owner == 'zed-industries' runs-on: - - buildjet-32vcpu-ubuntu-2204-arm + - github-16vcpu-ubuntu-2204-arm needs: tests steps: - name: Checkout repo diff --git a/.github/workflows/unit_evals.yml b/.github/workflows/unit_evals.yml index cb4e39d151..225fca558f 100644 --- a/.github/workflows/unit_evals.yml +++ b/.github/workflows/unit_evals.yml @@ -23,7 +23,7 @@ jobs: timeout-minutes: 60 name: Run unit evals runs-on: - - buildjet-16vcpu-ubuntu-2204 + - github-16vcpu-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -37,7 +37,7 @@ jobs: uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2 with: save-if: ${{ github.ref == 'refs/heads/main' }} - cache-provider: "buildjet" + # cache-provider: "buildjet" - name: Install Linux dependencies run: ./script/linux From 52a60e511591795e3fd8ed69eff0a082fce1c886 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 7 Aug 2025 19:16:25 -0400 Subject: [PATCH 20/56] ci: Switch to Namespace (#35835) Follow-up to: - https://github.com/zed-industries/zed/pull/35826 Release Notes: - N/A --- .github/actionlint.yml | 27 ++++++++++-------------- .github/workflows/bump_patch_version.yml | 2 +- .github/workflows/ci.yml | 15 ++++++------- .github/workflows/deploy_cloudflare.yml | 2 +- .github/workflows/deploy_collab.yml | 4 ++-- .github/workflows/eval.yml | 2 +- .github/workflows/nix.yml | 2 +- .github/workflows/randomized_tests.yml | 2 +- .github/workflows/release_nightly.yml | 5 ++--- .github/workflows/unit_evals.yml | 2 +- 10 files changed, 28 insertions(+), 35 deletions(-) diff --git a/.github/actionlint.yml b/.github/actionlint.yml index 06b48b9b54..ad09545902 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -13,22 +13,17 @@ self-hosted-runner: - windows-2025-16 - windows-2025-32 - windows-2025-64 - # Buildjet Ubuntu 20.04 - AMD x86_64 - - buildjet-2vcpu-ubuntu-2004 - - buildjet-4vcpu-ubuntu-2004 - - buildjet-8vcpu-ubuntu-2004 - - buildjet-16vcpu-ubuntu-2004 - - buildjet-32vcpu-ubuntu-2004 - # Buildjet Ubuntu 22.04 - AMD x86_64 - - buildjet-2vcpu-ubuntu-2204 - - buildjet-4vcpu-ubuntu-2204 - - buildjet-8vcpu-ubuntu-2204 - - buildjet-16vcpu-ubuntu-2204 - - buildjet-32vcpu-ubuntu-2204 - # Buildjet Ubuntu 22.04 - Graviton aarch64 - - buildjet-8vcpu-ubuntu-2204-arm - - buildjet-16vcpu-ubuntu-2204-arm - - buildjet-32vcpu-ubuntu-2204-arm + # Namespace Ubuntu 20.04 (Release builds) + - namespace-profile-16x32-ubuntu-2004 + - namespace-profile-32x64-ubuntu-2004 + - namespace-profile-16x32-ubuntu-2004-arm + - namespace-profile-32x64-ubuntu-2004-arm + # Namespace Ubuntu 22.04 (Everything else) + - namespace-profile-2x4-ubuntu-2204 + - namespace-profile-4x8-ubuntu-2204 + - namespace-profile-8x16-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 + - namespace-profile-32x64-ubuntu-2204 # Self Hosted Runners - self-mini-macos - self-32vcpu-windows-2022 diff --git a/.github/workflows/bump_patch_version.yml b/.github/workflows/bump_patch_version.yml index bc44066ea6..bfaf7a271b 100644 --- a/.github/workflows/bump_patch_version.yml +++ b/.github/workflows/bump_patch_version.yml @@ -16,7 +16,7 @@ jobs: bump_patch_version: if: github.repository_owner == 'zed-industries' runs-on: - - github-16vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5f030d5a2..45e303949d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,7 +136,7 @@ jobs: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' runs-on: - - github-8vcpu-ubuntu-2204 + - namespace-profile-8x16-ubuntu-2204 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -167,7 +167,7 @@ jobs: needs: [job_spec] if: github.repository_owner == 'zed-industries' runs-on: - - github-8vcpu-ubuntu-2204 + - namespace-profile-4x8-ubuntu-2204 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -220,7 +220,7 @@ jobs: github.repository_owner == 'zed-industries' && (needs.job_spec.outputs.run_tests == 'true' || needs.job_spec.outputs.run_docs == 'true') runs-on: - - github-8vcpu-ubuntu-2204 + - namespace-profile-8x16-ubuntu-2204 steps: - name: Checkout repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -327,7 +327,7 @@ jobs: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' runs-on: - - github-16vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -379,7 +379,7 @@ jobs: github.repository_owner == 'zed-industries' && needs.job_spec.outputs.run_tests == 'true' runs-on: - - github-8vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" @@ -596,8 +596,7 @@ jobs: timeout-minutes: 60 name: Linux x86_x64 release bundle runs-on: - - github-16vcpu-ubuntu-2204 - # - buildjet-16vcpu-ubuntu-2004 # ubuntu 20.04 for minimal glibc + - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') @@ -650,7 +649,7 @@ jobs: timeout-minutes: 60 name: Linux arm64 release bundle runs-on: - - github-16vcpu-ubuntu-2204-arm + - namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index 3a294fdc17..df35d44ca9 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -9,7 +9,7 @@ jobs: deploy-docs: name: Deploy Docs if: github.repository_owner == 'zed-industries' - runs-on: github-16vcpu-ubuntu-2204 + runs-on: namespace-profile-16x32-ubuntu-2204 steps: - name: Checkout repo diff --git a/.github/workflows/deploy_collab.yml b/.github/workflows/deploy_collab.yml index 2e65b62d81..8f56cac5d9 100644 --- a/.github/workflows/deploy_collab.yml +++ b/.github/workflows/deploy_collab.yml @@ -61,7 +61,7 @@ jobs: - style - tests runs-on: - - github-16vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Install doctl uses: digitalocean/action-doctl@v2 @@ -94,7 +94,7 @@ jobs: needs: - publish runs-on: - - github-16vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Checkout repo diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index 196e00519b..b5da9e7b7c 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -32,7 +32,7 @@ jobs: github.repository_owner == 'zed-industries' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-eval')) runs-on: - - github-16vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 6fb12c47e2..30acdac23f 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -20,7 +20,7 @@ jobs: matrix: system: - os: x86 Linux - runner: github-16vcpu-ubuntu-2204 + runner: namespace-profile-16x32-ubuntu-2204 install_nix: true - os: arm Mac runner: [macOS, ARM64, test] diff --git a/.github/workflows/randomized_tests.yml b/.github/workflows/randomized_tests.yml index 3a7b476ba0..de96c3df78 100644 --- a/.github/workflows/randomized_tests.yml +++ b/.github/workflows/randomized_tests.yml @@ -20,7 +20,7 @@ jobs: name: Run randomized tests if: github.repository_owner == 'zed-industries' runs-on: - - github-16vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index f7e0298f69..32059ad19a 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -127,8 +127,7 @@ jobs: name: Create a Linux *.tar.gz bundle for x86 if: github.repository_owner == 'zed-industries' runs-on: - - github-16vcpu-ubuntu-2204 - # - buildjet-16vcpu-ubuntu-2004 + - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc needs: tests steps: - name: Checkout repo @@ -168,7 +167,7 @@ jobs: name: Create a Linux *.tar.gz bundle for ARM if: github.repository_owner == 'zed-industries' runs-on: - - github-16vcpu-ubuntu-2204-arm + - namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc needs: tests steps: - name: Checkout repo diff --git a/.github/workflows/unit_evals.yml b/.github/workflows/unit_evals.yml index 225fca558f..2e03fb028f 100644 --- a/.github/workflows/unit_evals.yml +++ b/.github/workflows/unit_evals.yml @@ -23,7 +23,7 @@ jobs: timeout-minutes: 60 name: Run unit evals runs-on: - - github-16vcpu-ubuntu-2204 + - namespace-profile-16x32-ubuntu-2204 steps: - name: Add Rust to the PATH run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" From f14a8148b666f914628b84e5d011db70632c02ea Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 6 Aug 2025 20:27:48 +0200 Subject: [PATCH 21/56] lsp: Correctly serialize errors for LSP requests + improve handling of unrecognized methods (#35738) We used to not respond at all to requests that we didn't have a handler for, which is yuck. It may have left the language server waiting for the response for no good reason. The other (worse) finding is that we did not have a full definition of an Error type for LSP, which made it so that a spec-compliant language server would fail to deserialize our response (with an error). This then could lead to all sorts of funkiness, including hangs and crashes on the language server's part. Co-authored-by: Lukas Co-authored-by: Remco Smits Co-authored-by: Anthony Eid Closes #ISSUE Release Notes: - Improved reporting of errors to language servers, which should improve the stability of LSPs ran by Zed. --------- Co-authored-by: Lukas Co-authored-by: Remco Smits Co-authored-by: Anthony Eid --- crates/lsp/src/input_handler.rs | 11 +++--- crates/lsp/src/lsp.rs | 70 ++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/crates/lsp/src/input_handler.rs b/crates/lsp/src/input_handler.rs index db3f1190fc..001ebf1fc9 100644 --- a/crates/lsp/src/input_handler.rs +++ b/crates/lsp/src/input_handler.rs @@ -13,14 +13,15 @@ use parking_lot::Mutex; use smol::io::BufReader; use crate::{ - AnyNotification, AnyResponse, CONTENT_LEN_HEADER, IoHandler, IoKind, RequestId, ResponseHandler, + AnyResponse, CONTENT_LEN_HEADER, IoHandler, IoKind, NotificationOrRequest, RequestId, + ResponseHandler, }; const HEADER_DELIMITER: &[u8; 4] = b"\r\n\r\n"; /// Handler for stdout of language server. pub struct LspStdoutHandler { pub(super) loop_handle: Task>, - pub(super) notifications_channel: UnboundedReceiver, + pub(super) incoming_messages: UnboundedReceiver, } async fn read_headers(reader: &mut BufReader, buffer: &mut Vec) -> Result<()> @@ -54,13 +55,13 @@ impl LspStdoutHandler { let loop_handle = cx.spawn(Self::handler(stdout, tx, response_handlers, io_handlers)); Self { loop_handle, - notifications_channel, + incoming_messages: notifications_channel, } } async fn handler( stdout: Input, - notifications_sender: UnboundedSender, + notifications_sender: UnboundedSender, response_handlers: Arc>>>, io_handlers: Arc>>, ) -> anyhow::Result<()> @@ -96,7 +97,7 @@ impl LspStdoutHandler { } } - if let Ok(msg) = serde_json::from_slice::(&buffer) { + if let Ok(msg) = serde_json::from_slice::(&buffer) { notifications_sender.unbounded_send(msg)?; } else if let Ok(AnyResponse { id, error, result, .. diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index b9701a83d2..3f45d2e6fc 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -242,7 +242,7 @@ struct Notification<'a, T> { /// Language server RPC notification message before it is deserialized into a concrete type. #[derive(Debug, Clone, Deserialize)] -struct AnyNotification { +struct NotificationOrRequest { #[serde(default)] id: Option, method: String, @@ -252,7 +252,10 @@ struct AnyNotification { #[derive(Debug, Serialize, Deserialize)] struct Error { + code: i64, message: String, + #[serde(default)] + data: Option, } pub trait LspRequestFuture: Future> { @@ -364,6 +367,7 @@ impl LanguageServer { notification.method, serde_json::to_string_pretty(¬ification.params).unwrap(), ); + false }, ); @@ -389,7 +393,7 @@ impl LanguageServer { Stdin: AsyncWrite + Unpin + Send + 'static, Stdout: AsyncRead + Unpin + Send + 'static, Stderr: AsyncRead + Unpin + Send + 'static, - F: FnMut(AnyNotification) + 'static + Send + Sync + Clone, + F: Fn(&NotificationOrRequest) -> bool + 'static + Send + Sync + Clone, { let (outbound_tx, outbound_rx) = channel::unbounded::(); let (output_done_tx, output_done_rx) = barrier::channel(); @@ -400,14 +404,34 @@ impl LanguageServer { let io_handlers = Arc::new(Mutex::new(HashMap::default())); let stdout_input_task = cx.spawn({ - let on_unhandled_notification = on_unhandled_notification.clone(); + let unhandled_notification_wrapper = { + let response_channel = outbound_tx.clone(); + async move |msg: NotificationOrRequest| { + let did_handle = on_unhandled_notification(&msg); + if !did_handle && let Some(message_id) = msg.id { + let response = AnyResponse { + jsonrpc: JSON_RPC_VERSION, + id: message_id, + error: Some(Error { + code: -32601, + message: format!("Unrecognized method `{}`", msg.method), + data: None, + }), + result: None, + }; + if let Ok(response) = serde_json::to_string(&response) { + response_channel.send(response).await.ok(); + } + } + } + }; let notification_handlers = notification_handlers.clone(); let response_handlers = response_handlers.clone(); let io_handlers = io_handlers.clone(); async move |cx| { - Self::handle_input( + Self::handle_incoming_messages( stdout, - on_unhandled_notification, + unhandled_notification_wrapper, notification_handlers, response_handlers, io_handlers, @@ -433,7 +457,7 @@ impl LanguageServer { stdout.or(stderr) }); let output_task = cx.background_spawn({ - Self::handle_output( + Self::handle_outgoing_messages( stdin, outbound_rx, output_done_tx, @@ -479,9 +503,9 @@ impl LanguageServer { self.code_action_kinds.clone() } - async fn handle_input( + async fn handle_incoming_messages( stdout: Stdout, - mut on_unhandled_notification: F, + on_unhandled_notification: impl AsyncFn(NotificationOrRequest) + 'static + Send, notification_handlers: Arc>>, response_handlers: Arc>>>, io_handlers: Arc>>, @@ -489,7 +513,6 @@ impl LanguageServer { ) -> anyhow::Result<()> where Stdout: AsyncRead + Unpin + Send + 'static, - F: FnMut(AnyNotification) + 'static + Send, { use smol::stream::StreamExt; let stdout = BufReader::new(stdout); @@ -506,15 +529,19 @@ impl LanguageServer { cx.background_executor().clone(), ); - while let Some(msg) = input_handler.notifications_channel.next().await { - { + while let Some(msg) = input_handler.incoming_messages.next().await { + let unhandled_message = { let mut notification_handlers = notification_handlers.lock(); if let Some(handler) = notification_handlers.get_mut(msg.method.as_str()) { handler(msg.id, msg.params.unwrap_or(Value::Null), cx); + None } else { - drop(notification_handlers); - on_unhandled_notification(msg); + Some(msg) } + }; + + if let Some(msg) = unhandled_message { + on_unhandled_notification(msg).await; } // Don't starve the main thread when receiving lots of notifications at once. @@ -558,7 +585,7 @@ impl LanguageServer { } } - async fn handle_output( + async fn handle_outgoing_messages( stdin: Stdin, outbound_rx: channel::Receiver, output_done_tx: barrier::Sender, @@ -1036,7 +1063,9 @@ impl LanguageServer { jsonrpc: JSON_RPC_VERSION, id, value: LspResult::Error(Some(Error { + code: lsp_types::error_codes::REQUEST_FAILED, message: error.to_string(), + data: None, })), }, }; @@ -1057,7 +1086,9 @@ impl LanguageServer { id, result: None, error: Some(Error { + code: -32700, // Parse error message: error.to_string(), + data: None, }), }; if let Some(response) = serde_json::to_string(&response).log_err() { @@ -1559,7 +1590,7 @@ impl FakeLanguageServer { root, Some(workspace_folders.clone()), cx, - |_| {}, + |_| false, ); server.process_name = process_name; let fake = FakeLanguageServer { @@ -1582,9 +1613,10 @@ impl FakeLanguageServer { notifications_tx .try_send(( msg.method.to_string(), - msg.params.unwrap_or(Value::Null).to_string(), + msg.params.as_ref().unwrap_or(&Value::Null).to_string(), )) .ok(); + true }, ); server.process_name = name.as_str().into(); @@ -1862,7 +1894,7 @@ mod tests { #[gpui::test] fn test_deserialize_string_digit_id() { let json = r#"{"jsonrpc":"2.0","id":"2","method":"workspace/configuration","params":{"items":[{"scopeUri":"file:///Users/mph/Devel/personal/hello-scala/","section":"metals"}]}}"#; - let notification = serde_json::from_str::(json) + let notification = serde_json::from_str::(json) .expect("message with string id should be parsed"); let expected_id = RequestId::Str("2".to_string()); assert_eq!(notification.id, Some(expected_id)); @@ -1871,7 +1903,7 @@ mod tests { #[gpui::test] fn test_deserialize_string_id() { let json = r#"{"jsonrpc":"2.0","id":"anythingAtAll","method":"workspace/configuration","params":{"items":[{"scopeUri":"file:///Users/mph/Devel/personal/hello-scala/","section":"metals"}]}}"#; - let notification = serde_json::from_str::(json) + let notification = serde_json::from_str::(json) .expect("message with string id should be parsed"); let expected_id = RequestId::Str("anythingAtAll".to_string()); assert_eq!(notification.id, Some(expected_id)); @@ -1880,7 +1912,7 @@ mod tests { #[gpui::test] fn test_deserialize_int_id() { let json = r#"{"jsonrpc":"2.0","id":2,"method":"workspace/configuration","params":{"items":[{"scopeUri":"file:///Users/mph/Devel/personal/hello-scala/","section":"metals"}]}}"#; - let notification = serde_json::from_str::(json) + let notification = serde_json::from_str::(json) .expect("message with string id should be parsed"); let expected_id = RequestId::Int(2); assert_eq!(notification.id, Some(expected_id)); From 35ea2acd1c7c20073e77f47e921cda32478f572a Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 30 Jul 2025 14:57:51 -0400 Subject: [PATCH 22/56] Add `cloud_api_client` and `cloud_api_types` crates (#35357) This PR adds two new crates for interacting with Cloud: - `cloud_api_client` - The client that will be used to talk to Cloud. - `cloud_api_types` - The types for the Cloud API that are shared between Zed and Cloud. Release Notes: - N/A --- Cargo.lock | 22 ++++++ Cargo.toml | 4 + crates/client/Cargo.toml | 1 + crates/client/src/client.rs | 5 ++ crates/cloud_api_client/Cargo.toml | 21 +++++ crates/cloud_api_client/LICENSE-APACHE | 1 + .../cloud_api_client/src/cloud_api_client.rs | 76 +++++++++++++++++++ crates/cloud_api_types/Cargo.toml | 16 ++++ crates/cloud_api_types/LICENSE-APACHE | 1 + crates/cloud_api_types/src/cloud_api_types.rs | 14 ++++ crates/http_client/src/http_client.rs | 16 ++++ 11 files changed, 177 insertions(+) create mode 100644 crates/cloud_api_client/Cargo.toml create mode 120000 crates/cloud_api_client/LICENSE-APACHE create mode 100644 crates/cloud_api_client/src/cloud_api_client.rs create mode 100644 crates/cloud_api_types/Cargo.toml create mode 120000 crates/cloud_api_types/LICENSE-APACHE create mode 100644 crates/cloud_api_types/src/cloud_api_types.rs diff --git a/Cargo.lock b/Cargo.lock index b7346f5c20..1c81a24e28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2976,6 +2976,7 @@ dependencies = [ "base64 0.22.1", "chrono", "clock", + "cloud_api_client", "cloud_llm_client", "cocoa 0.26.0", "collections", @@ -3031,6 +3032,27 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cloud_api_client" +version = "0.1.0" +dependencies = [ + "anyhow", + "cloud_api_types", + "futures 0.3.31", + "http_client", + "parking_lot", + "serde_json", + "workspace-hack", +] + +[[package]] +name = "cloud_api_types" +version = "0.1.0" +dependencies = [ + "serde", + "workspace-hack", +] + [[package]] name = "cloud_llm_client" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a6428d897b..cf1ee5956f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ members = [ "crates/cli", "crates/client", "crates/clock", + "crates/cloud_api_client", + "crates/cloud_api_types", "crates/cloud_llm_client", "crates/collab", "crates/collab_ui", @@ -251,6 +253,8 @@ channel = { path = "crates/channel" } cli = { path = "crates/cli" } client = { path = "crates/client" } clock = { path = "crates/clock" } +cloud_api_client = { path = "crates/cloud_api_client" } +cloud_api_types = { path = "crates/cloud_api_types" } cloud_llm_client = { path = "crates/cloud_llm_client" } collab = { path = "crates/collab" } collab_ui = { path = "crates/collab_ui" } diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index dd97bd9ca4..3ff03114ea 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -22,6 +22,7 @@ async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manua base64.workspace = true chrono = { workspace = true, features = ["serde"] } clock.workspace = true +cloud_api_client.workspace = true cloud_llm_client.workspace = true collections.workspace = true credentials_provider.workspace = true diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 462ddb3c32..94552a3c7c 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -15,6 +15,7 @@ use async_tungstenite::tungstenite::{ }; use chrono::{DateTime, Utc}; use clock::SystemClock; +use cloud_api_client::CloudApiClient; use credentials_provider::CredentialsProvider; use futures::{ AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt, @@ -212,6 +213,7 @@ pub struct Client { id: AtomicU64, peer: Arc, http: Arc, + cloud_client: Arc, telemetry: Arc, credentials_provider: ClientCredentialsProvider, state: RwLock, @@ -558,6 +560,7 @@ impl Client { id: AtomicU64::new(0), peer: Peer::new(0), telemetry: Telemetry::new(clock, http.clone(), cx), + cloud_client: Arc::new(CloudApiClient::new(http.clone())), http, credentials_provider: ClientCredentialsProvider::new(cx), state: Default::default(), @@ -902,6 +905,8 @@ impl Client { } let credentials = credentials.unwrap(); self.set_id(credentials.user_id); + self.cloud_client + .set_credentials(credentials.user_id as u32, credentials.access_token.clone()); if was_disconnected { self.set_status(Status::Connecting, cx); diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml new file mode 100644 index 0000000000..d56aa94c6e --- /dev/null +++ b/crates/cloud_api_client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cloud_api_client" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/cloud_api_client.rs" + +[dependencies] +anyhow.workspace = true +cloud_api_types.workspace = true +futures.workspace = true +http_client.workspace = true +parking_lot.workspace = true +serde_json.workspace = true +workspace-hack.workspace = true diff --git a/crates/cloud_api_client/LICENSE-APACHE b/crates/cloud_api_client/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/cloud_api_client/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs new file mode 100644 index 0000000000..b11e954468 --- /dev/null +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; + +use anyhow::{Result, anyhow}; +pub use cloud_api_types::*; +use futures::AsyncReadExt as _; +use http_client::{AsyncBody, HttpClientWithUrl, Method, Request}; +use parking_lot::RwLock; + +struct Credentials { + user_id: u32, + access_token: String, +} + +pub struct CloudApiClient { + credentials: RwLock>, + http_client: Arc, +} + +impl CloudApiClient { + pub fn new(http_client: Arc) -> Self { + Self { + credentials: RwLock::new(None), + http_client, + } + } + + pub fn set_credentials(&self, user_id: u32, access_token: String) { + *self.credentials.write() = Some(Credentials { + user_id, + access_token, + }); + } + + fn authorization_header(&self) -> Result { + let guard = self.credentials.read(); + let credentials = guard + .as_ref() + .ok_or_else(|| anyhow!("No credentials provided"))?; + + Ok(format!( + "{} {}", + credentials.user_id, credentials.access_token + )) + } + + pub async fn get_authenticated_user(&self) -> Result { + let request = Request::builder() + .method(Method::GET) + .uri( + self.http_client + .build_zed_cloud_url("/client/users/me", &[])? + .as_ref(), + ) + .header("Content-Type", "application/json") + .header("Authorization", self.authorization_header()?) + .body(AsyncBody::default())?; + + let mut response = self.http_client.send(request).await?; + + if !response.status().is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + anyhow::bail!( + "Failed to get authenticated user.\nStatus: {:?}\nBody: {body}", + response.status() + ) + } + + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + let response: GetAuthenticatedUserResponse = serde_json::from_str(&body)?; + + Ok(response.user) + } +} diff --git a/crates/cloud_api_types/Cargo.toml b/crates/cloud_api_types/Cargo.toml new file mode 100644 index 0000000000..0fe0b1fd6a --- /dev/null +++ b/crates/cloud_api_types/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cloud_api_types" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/cloud_api_types.rs" + +[dependencies] +serde.workspace = true +workspace-hack.workspace = true diff --git a/crates/cloud_api_types/LICENSE-APACHE b/crates/cloud_api_types/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/cloud_api_types/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs new file mode 100644 index 0000000000..5c9ca7893c --- /dev/null +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct GetAuthenticatedUserResponse { + pub user: AuthenticatedUser, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct AuthenticatedUser { + pub id: i32, + pub avatar_url: String, + pub github_login: String, + pub name: Option, +} diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 434bd74fc8..06875718d9 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -236,6 +236,22 @@ impl HttpClientWithUrl { )?) } + /// Builds a Zed Cloud URL using the given path. + pub fn build_zed_cloud_url(&self, path: &str, query: &[(&str, &str)]) -> Result { + let base_url = self.base_url(); + let base_api_url = match base_url.as_ref() { + "https://zed.dev" => "https://cloud.zed.dev", + "https://staging.zed.dev" => "https://cloud.zed.dev", + "http://localhost:3000" => "http://localhost:8787", + other => other, + }; + + Ok(Url::parse_with_params( + &format!("{}{}", base_api_url, path), + query, + )?) + } + /// Builds a Zed LLM URL using the given path. pub fn build_zed_llm_url(&self, path: &str, query: &[(&str, &str)]) -> Result { let base_url = self.base_url(); From 03693498d674eb4a9c4e1d677c41490b375b4e9a Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 30 Jul 2025 18:43:10 -0400 Subject: [PATCH 23/56] client: Add `CloudUserStore` (#35370) This PR adds a new `CloudUserStore` for storing information about the user retrieved from Cloud instead of Collab. Release Notes: - N/A --- crates/client/src/client.rs | 6 +++ crates/client/src/cloud.rs | 3 ++ crates/client/src/cloud/user_store.rs | 41 +++++++++++++++++++ .../cloud_api_client/src/cloud_api_client.rs | 9 ++-- crates/collab/src/tests/test_server.rs | 3 ++ crates/workspace/src/workspace.rs | 6 +++ crates/zed/src/main.rs | 4 +- 7 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 crates/client/src/cloud.rs create mode 100644 crates/client/src/cloud/user_store.rs diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 94552a3c7c..7888aa9354 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,6 +1,7 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; +mod cloud; mod proxy; pub mod telemetry; pub mod user; @@ -52,6 +53,7 @@ use tokio::net::TcpStream; use url::Url; use util::{ConnectionResult, ResultExt}; +pub use cloud::*; pub use rpc::*; pub use telemetry_events::Event; pub use user::*; @@ -593,6 +595,10 @@ impl Client { self.http.clone() } + pub fn cloud_client(&self) -> Arc { + self.cloud_client.clone() + } + pub fn set_id(&self, id: u64) -> &Self { self.id.store(id, Ordering::SeqCst); self diff --git a/crates/client/src/cloud.rs b/crates/client/src/cloud.rs new file mode 100644 index 0000000000..39c9d04887 --- /dev/null +++ b/crates/client/src/cloud.rs @@ -0,0 +1,3 @@ +mod user_store; + +pub use user_store::*; diff --git a/crates/client/src/cloud/user_store.rs b/crates/client/src/cloud/user_store.rs new file mode 100644 index 0000000000..025bf79b5e --- /dev/null +++ b/crates/client/src/cloud/user_store.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use cloud_api_client::{AuthenticatedUser, CloudApiClient}; +use gpui::{Context, Task}; +use util::{ResultExt as _, maybe}; + +pub struct CloudUserStore { + authenticated_user: Option, + _fetch_authenticated_user_task: Task<()>, +} + +impl CloudUserStore { + pub fn new(cloud_client: Arc, cx: &mut Context) -> Self { + Self { + authenticated_user: None, + _fetch_authenticated_user_task: cx.spawn(async move |this, cx| { + maybe!(async move { + loop { + if cloud_client.has_credentials() { + break; + } + + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + } + + let response = cloud_client.get_authenticated_user().await?; + this.update(cx, |this, _cx| { + this.authenticated_user = Some(response.user); + }) + }) + .await + .context("failed to fetch authenticated user") + .log_err(); + }), + } + } +} diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index b11e954468..b92136b02f 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -24,6 +24,10 @@ impl CloudApiClient { } } + pub fn has_credentials(&self) -> bool { + self.credentials.read().is_some() + } + pub fn set_credentials(&self, user_id: u32, access_token: String) { *self.credentials.write() = Some(Credentials { user_id, @@ -43,7 +47,7 @@ impl CloudApiClient { )) } - pub async fn get_authenticated_user(&self) -> Result { + pub async fn get_authenticated_user(&self) -> Result { let request = Request::builder() .method(Method::GET) .uri( @@ -69,8 +73,7 @@ impl CloudApiClient { let mut body = String::new(); response.body_mut().read_to_string(&mut body).await?; - let response: GetAuthenticatedUserResponse = serde_json::from_str(&body)?; - Ok(response.user) + Ok(serde_json::from_str(&body)?) } } diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 5192db16a7..ab6bf1b912 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -8,6 +8,7 @@ use crate::{ use anyhow::anyhow; use call::ActiveCall; use channel::{ChannelBuffer, ChannelStore}; +use client::CloudUserStore; use client::{ self, ChannelId, Client, Connection, Credentials, EstablishConnectionError, UserStore, proto::PeerId, @@ -281,12 +282,14 @@ impl TestServer { .register_hosting_provider(Arc::new(git_hosting_providers::Github::public_instance())); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); let app_state = Arc::new(workspace::AppState { client: client.clone(), user_store: user_store.clone(), + cloud_user_store, workspace_store, languages: language_registry, fs: fs.clone(), diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e58014e7b8..5271141620 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -15,6 +15,7 @@ mod toast_layer; mod toolbar; mod workspace_settings; +use client::CloudUserStore; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; use anyhow::{Context as _, Result, anyhow}; @@ -839,6 +840,7 @@ pub struct AppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, + pub cloud_user_store: Entity, pub workspace_store: Entity, pub fs: Arc, pub build_window_options: fn(Option, &mut App) -> WindowOptions, @@ -911,6 +913,7 @@ impl AppState { let client = Client::new(clock, http_client.clone(), cx); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); theme::init(theme::LoadThemes::JustBase, cx); @@ -922,6 +925,7 @@ impl AppState { fs, languages, user_store, + cloud_user_store, workspace_store, node_runtime: NodeRuntime::unavailable(), build_window_options: |_, _| Default::default(), @@ -5689,6 +5693,7 @@ impl Workspace { let client = project.read(cx).client(); let user_store = project.read(cx).user_store(); + let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); @@ -5696,6 +5701,7 @@ impl Workspace { let app_state = Arc::new(AppState { languages: project.read(cx).languages().clone(), workspace_store, + cloud_user_store, client, user_store, fs: project.read(cx).fs().clone(), diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index d0b9c53397..1fd9fd3d97 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -5,7 +5,7 @@ use agent_ui::AgentPanel; use anyhow::{Context as _, Result}; use clap::{Parser, command}; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; -use client::{Client, ProxySettings, UserStore, parse_zed_link}; +use client::{Client, CloudUserStore, ProxySettings, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; use collections::HashMap; use db::kvp::{GLOBAL_KEY_VALUE_STORE, KEY_VALUE_STORE}; @@ -457,6 +457,7 @@ pub fn main() { language::init(cx); languages::init(languages.clone(), node_runtime.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); language_extension::init( @@ -516,6 +517,7 @@ pub fn main() { languages: languages.clone(), client: client.clone(), user_store: user_store.clone(), + cloud_user_store, fs: fs.clone(), build_window_options, workspace_store, From 2aac7b2ea1867b2aad7eceb5de42042a4c9843a1 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 30 Jul 2025 20:31:22 -0400 Subject: [PATCH 24/56] Use the user from the `CloudUserStore` to drive the user menu (#35375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates the user menu in the title bar to base the "signed in" state on the user in the `CloudUserStore` rather than the `UserStore`. This makes it possible to be signed-in—at least, as far as the user menu is concerned—even when disconnected from Collab. Release Notes: - N/A --- crates/client/src/client.rs | 1 + crates/client/src/cloud/user_store.rs | 41 ++++++++++++++----- .../cloud_api_client/src/cloud_api_client.rs | 4 ++ crates/title_bar/src/title_bar.rs | 36 ++++++++-------- 4 files changed, 56 insertions(+), 26 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 7888aa9354..34fab17632 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1463,6 +1463,7 @@ impl Client { pub async fn sign_out(self: &Arc, cx: &AsyncApp) { self.state.write().credentials = None; + self.cloud_client.clear_credentials(); self.disconnect(cx); if self.has_credentials(cx).await { diff --git a/crates/client/src/cloud/user_store.rs b/crates/client/src/cloud/user_store.rs index 025bf79b5e..da468ad618 100644 --- a/crates/client/src/cloud/user_store.rs +++ b/crates/client/src/cloud/user_store.rs @@ -7,35 +7,56 @@ use gpui::{Context, Task}; use util::{ResultExt as _, maybe}; pub struct CloudUserStore { - authenticated_user: Option, - _fetch_authenticated_user_task: Task<()>, + authenticated_user: Option>, + _maintain_authenticated_user_task: Task<()>, } impl CloudUserStore { pub fn new(cloud_client: Arc, cx: &mut Context) -> Self { Self { authenticated_user: None, - _fetch_authenticated_user_task: cx.spawn(async move |this, cx| { + _maintain_authenticated_user_task: cx.spawn(async move |this, cx| { maybe!(async move { loop { + let Some(this) = this.upgrade() else { + return anyhow::Ok(()); + }; + if cloud_client.has_credentials() { - break; + if let Some(response) = cloud_client + .get_authenticated_user() + .await + .context("failed to fetch authenticated user") + .log_err() + { + this.update(cx, |this, _cx| { + this.authenticated_user = Some(Arc::new(response.user)); + }) + .ok(); + } + } else { + this.update(cx, |this, _cx| { + this.authenticated_user = None; + }) + .ok(); } cx.background_executor() .timer(Duration::from_millis(100)) .await; } - - let response = cloud_client.get_authenticated_user().await?; - this.update(cx, |this, _cx| { - this.authenticated_user = Some(response.user); - }) }) .await - .context("failed to fetch authenticated user") .log_err(); }), } } + + pub fn is_authenticated(&self) -> bool { + self.authenticated_user.is_some() + } + + pub fn authenticated_user(&self) -> Option> { + self.authenticated_user.clone() + } } diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index b92136b02f..2d017cf2ee 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -35,6 +35,10 @@ impl CloudApiClient { }); } + pub fn clear_credentials(&self) { + *self.credentials.write() = None; + } + fn authorization_header(&self) -> Result { let guard = self.credentials.read(); let credentials = guard diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 17c4c85b6d..6e03b52ef8 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -20,7 +20,7 @@ use crate::application_menu::{ use auto_update::AutoUpdateStatus; use call::ActiveCall; -use client::{Client, UserStore, zed_urls}; +use client::{Client, CloudUserStore, UserStore, zed_urls}; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, @@ -126,6 +126,7 @@ pub struct TitleBar { platform_titlebar: Entity, project: Entity, user_store: Entity, + cloud_user_store: Entity, client: Arc, workspace: WeakEntity, application_menu: Option>, @@ -179,24 +180,25 @@ impl Render for TitleBar { children.push(self.banner.clone().into_any_element()) } + let is_authenticated = self.cloud_user_store.read(cx).is_authenticated(); + let status = self.client.status(); + let status = &*status.borrow(); + + let show_sign_in = !is_authenticated || !matches!(status, client::Status::Connected { .. }); + children.push( h_flex() .gap_1() .pr_1() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .children(self.render_call_controls(window, cx)) - .map(|el| { - let status = self.client.status(); - let status = &*status.borrow(); - if matches!(status, client::Status::Connected { .. }) { - el.child(self.render_user_menu_button(cx)) - } else { - el.children(self.render_connection_status(status, cx)) - .when(TitleBarSettings::get_global(cx).show_sign_in, |el| { - el.child(self.render_sign_in_button(cx)) - }) - .child(self.render_user_menu_button(cx)) - } + .children(self.render_connection_status(status, cx)) + .when( + show_sign_in && TitleBarSettings::get_global(cx).show_sign_in, + |el| el.child(self.render_sign_in_button(cx)), + ) + .when(is_authenticated, |parent| { + parent.child(self.render_user_menu_button(cx)) }) .into_any_element(), ); @@ -246,6 +248,7 @@ impl TitleBar { ) -> Self { let project = workspace.project().clone(); let user_store = workspace.app_state().user_store.clone(); + let cloud_user_store = workspace.app_state().cloud_user_store.clone(); let client = workspace.app_state().client.clone(); let active_call = ActiveCall::global(cx); @@ -293,6 +296,7 @@ impl TitleBar { workspace: workspace.weak_handle(), project, user_store, + cloud_user_store, client, _subscriptions: subscriptions, banner, @@ -628,15 +632,15 @@ impl TitleBar { } pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { - let user_store = self.user_store.read(cx); - if let Some(user) = user_store.current_user() { + let cloud_user_store = self.cloud_user_store.read(cx); + if let Some(user) = cloud_user_store.authenticated_user() { let has_subscription_period = self.user_store.read(cx).subscription_period().is_some(); let plan = self.user_store.read(cx).current_plan().filter(|_| { // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. has_subscription_period }); - let user_avatar = user.avatar_uri.clone(); + let user_avatar = user.avatar_url.clone(); let free_chip_bg = cx .theme() .colors() From 3d8a3a45740d89010759e7750950477fc4d5cdc1 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 30 Jul 2025 23:37:02 -0400 Subject: [PATCH 25/56] client: Don't fetch the authenticated user once we have them (#35385) This PR makes it so we don't keep fetching the authenticated user once we have them. Release Notes: - N/A --- crates/client/src/cloud/user_store.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/crates/client/src/cloud/user_store.rs b/crates/client/src/cloud/user_store.rs index da468ad618..ef4e92299a 100644 --- a/crates/client/src/cloud/user_store.rs +++ b/crates/client/src/cloud/user_store.rs @@ -23,16 +23,23 @@ impl CloudUserStore { }; if cloud_client.has_credentials() { - if let Some(response) = cloud_client - .get_authenticated_user() - .await - .context("failed to fetch authenticated user") - .log_err() - { - this.update(cx, |this, _cx| { - this.authenticated_user = Some(Arc::new(response.user)); - }) - .ok(); + let already_fetched_authenticated_user = this + .read_with(cx, |this, _cx| this.authenticated_user().is_some()) + .unwrap_or(false); + + if already_fetched_authenticated_user { + // We already fetched the authenticated user; nothing to do. + } else { + let authenticated_user_result = cloud_client + .get_authenticated_user() + .await + .context("failed to fetch authenticated user"); + if let Some(response) = authenticated_user_result.log_err() { + this.update(cx, |this, _cx| { + this.authenticated_user = Some(Arc::new(response.user)); + }) + .ok(); + } } } else { this.update(cx, |this, _cx| { From 4d229f84d78ade2b2e8b379eabdc252f03f59c2e Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 30 Jul 2025 23:38:51 -0400 Subject: [PATCH 26/56] cloud_api_types: Add more data to the `GetAuthenticatedUserResponse` (#35384) This PR adds more data to the `GetAuthenticatedUserResponse`. We now return more information about the authenticated user, as well as their plan information. Release Notes: - N/A --- Cargo.lock | 4 + crates/cloud_api_types/Cargo.toml | 6 + crates/cloud_api_types/src/cloud_api_types.rs | 26 +++ crates/cloud_api_types/src/timestamp.rs | 166 ++++++++++++++++++ .../cloud_llm_client/src/cloud_llm_client.rs | 4 +- 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 crates/cloud_api_types/src/timestamp.rs diff --git a/Cargo.lock b/Cargo.lock index 1c81a24e28..3fe71ffaf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3049,7 +3049,11 @@ dependencies = [ name = "cloud_api_types" version = "0.1.0" dependencies = [ + "chrono", + "cloud_llm_client", + "pretty_assertions", "serde", + "serde_json", "workspace-hack", ] diff --git a/crates/cloud_api_types/Cargo.toml b/crates/cloud_api_types/Cargo.toml index 0fe0b1fd6a..868797df3b 100644 --- a/crates/cloud_api_types/Cargo.toml +++ b/crates/cloud_api_types/Cargo.toml @@ -12,5 +12,11 @@ workspace = true path = "src/cloud_api_types.rs" [dependencies] +chrono.workspace = true +cloud_llm_client.workspace = true serde.workspace = true workspace-hack.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true +serde_json.workspace = true diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 5c9ca7893c..9f79770315 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -1,14 +1,40 @@ +mod timestamp; + use serde::{Deserialize, Serialize}; +pub use crate::timestamp::Timestamp; + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct GetAuthenticatedUserResponse { pub user: AuthenticatedUser, + pub feature_flags: Vec, + pub plan: PlanInfo, } #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct AuthenticatedUser { pub id: i32, + pub metrics_id: String, pub avatar_url: String, pub github_login: String, pub name: Option, + pub is_staff: bool, + pub accepted_tos_at: Option, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct PlanInfo { + pub plan: cloud_llm_client::Plan, + pub subscription_period: Option, + pub usage: cloud_llm_client::CurrentUsage, + pub trial_started_at: Option, + pub is_usage_based_billing_enabled: bool, + pub is_account_too_young: bool, + pub has_overdue_invoices: bool, +} + +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] +pub struct SubscriptionPeriod { + pub started_at: Timestamp, + pub ended_at: Timestamp, } diff --git a/crates/cloud_api_types/src/timestamp.rs b/crates/cloud_api_types/src/timestamp.rs new file mode 100644 index 0000000000..1f055d58ef --- /dev/null +++ b/crates/cloud_api_types/src/timestamp.rs @@ -0,0 +1,166 @@ +use chrono::{DateTime, NaiveDateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A timestamp with a serialized representation in RFC 3339 format. +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] +pub struct Timestamp(pub DateTime); + +impl Timestamp { + pub fn new(datetime: DateTime) -> Self { + Self(datetime) + } +} + +impl From> for Timestamp { + fn from(value: DateTime) -> Self { + Self(value) + } +} + +impl From for Timestamp { + fn from(value: NaiveDateTime) -> Self { + Self(value.and_utc()) + } +} + +impl Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let rfc3339_string = self.0.to_rfc3339_opts(SecondsFormat::Millis, true); + serializer.serialize_str(&rfc3339_string) + } +} + +impl<'de> Deserialize<'de> for Timestamp { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let datetime = DateTime::parse_from_rfc3339(&value) + .map_err(serde::de::Error::custom)? + .to_utc(); + Ok(Self(datetime)) + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_timestamp_serialization() { + let datetime = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z") + .unwrap() + .to_utc(); + let timestamp = Timestamp::new(datetime); + + let json = serde_json::to_string(×tamp).unwrap(); + assert_eq!(json, "\"2023-12-25T14:30:45.123Z\""); + } + + #[test] + fn test_timestamp_deserialization() { + let json = "\"2023-12-25T14:30:45.123Z\""; + let timestamp: Timestamp = serde_json::from_str(json).unwrap(); + + let expected = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z") + .unwrap() + .to_utc(); + + assert_eq!(timestamp.0, expected); + } + + #[test] + fn test_timestamp_roundtrip() { + let original = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z") + .unwrap() + .to_utc(); + + let timestamp = Timestamp::new(original); + let json = serde_json::to_string(×tamp).unwrap(); + let deserialized: Timestamp = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.0, original); + } + + #[test] + fn test_timestamp_from_datetime_utc() { + let datetime = DateTime::parse_from_rfc3339("2023-12-25T14:30:45.123Z") + .unwrap() + .to_utc(); + + let timestamp = Timestamp::from(datetime); + assert_eq!(timestamp.0, datetime); + } + + #[test] + fn test_timestamp_from_naive_datetime() { + let naive_dt = NaiveDate::from_ymd_opt(2023, 12, 25) + .unwrap() + .and_hms_milli_opt(14, 30, 45, 123) + .unwrap(); + + let timestamp = Timestamp::from(naive_dt); + let expected = naive_dt.and_utc(); + + assert_eq!(timestamp.0, expected); + } + + #[test] + fn test_timestamp_serialization_with_microseconds() { + // Test that microseconds are truncated to milliseconds + let datetime = NaiveDate::from_ymd_opt(2023, 12, 25) + .unwrap() + .and_hms_micro_opt(14, 30, 45, 123456) + .unwrap() + .and_utc(); + + let timestamp = Timestamp::new(datetime); + let json = serde_json::to_string(×tamp).unwrap(); + + // Should be truncated to milliseconds + assert_eq!(json, "\"2023-12-25T14:30:45.123Z\""); + } + + #[test] + fn test_timestamp_deserialization_without_milliseconds() { + let json = "\"2023-12-25T14:30:45Z\""; + let timestamp: Timestamp = serde_json::from_str(json).unwrap(); + + let expected = NaiveDate::from_ymd_opt(2023, 12, 25) + .unwrap() + .and_hms_opt(14, 30, 45) + .unwrap() + .and_utc(); + + assert_eq!(timestamp.0, expected); + } + + #[test] + fn test_timestamp_deserialization_with_timezone() { + let json = "\"2023-12-25T14:30:45.123+05:30\""; + let timestamp: Timestamp = serde_json::from_str(json).unwrap(); + + // Should be converted to UTC + let expected = NaiveDate::from_ymd_opt(2023, 12, 25) + .unwrap() + .and_hms_milli_opt(9, 0, 45, 123) // 14:30:45 + 5:30 = 20:00:45, but we want UTC so subtract 5:30 + .unwrap() + .and_utc(); + + assert_eq!(timestamp.0, expected); + } + + #[test] + fn test_timestamp_deserialization_with_invalid_format() { + let json = "\"invalid-date\""; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } +} diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 2488088a49..171c923154 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -308,13 +308,13 @@ pub struct GetSubscriptionResponse { pub usage: Option, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct CurrentUsage { pub model_requests: UsageData, pub edit_predictions: UsageData, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct UsageData { pub used: u32, pub limit: UsageLimit, From 2b8e8f03fab9fe2f888a80228d7c273f426dae89 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 09:56:53 -0400 Subject: [PATCH 27/56] title_bar: Show the plan from the `CloudUserStore` (#35401) This PR updates the user menu in the title bar to show the plan from the `CloudUserStore` instead of the `UserStore`. We're still leveraging the RPC connection to listen for `UpdateUserPlan` messages so that we can get live-updates from the server, but we are merely using this as a signal to re-fetch the information from Cloud. Release Notes: - N/A --- Cargo.lock | 1 + crates/client/src/cloud/user_store.rs | 81 ++++++++++++++++++++++++-- crates/client/src/user.rs | 2 + crates/collab/src/tests/test_server.rs | 3 +- crates/title_bar/Cargo.toml | 1 + crates/title_bar/src/title_bar.rs | 16 ++--- crates/workspace/src/workspace.rs | 6 +- crates/zed/src/main.rs | 3 +- 8 files changed, 94 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3fe71ffaf6..7ac29aeac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16563,6 +16563,7 @@ dependencies = [ "call", "chrono", "client", + "cloud_llm_client", "collections", "db", "gpui", diff --git a/crates/client/src/cloud/user_store.rs b/crates/client/src/cloud/user_store.rs index ef4e92299a..a9b13ca23c 100644 --- a/crates/client/src/cloud/user_store.rs +++ b/crates/client/src/cloud/user_store.rs @@ -2,19 +2,36 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Context as _; -use cloud_api_client::{AuthenticatedUser, CloudApiClient}; -use gpui::{Context, Task}; +use chrono::{DateTime, Utc}; +use cloud_api_client::{AuthenticatedUser, CloudApiClient, GetAuthenticatedUserResponse, PlanInfo}; +use cloud_llm_client::Plan; +use gpui::{Context, Entity, Subscription, Task}; use util::{ResultExt as _, maybe}; +use crate::UserStore; +use crate::user::Event as RpcUserStoreEvent; + pub struct CloudUserStore { + cloud_client: Arc, authenticated_user: Option>, + plan_info: Option>, _maintain_authenticated_user_task: Task<()>, + _rpc_plan_updated_subscription: Subscription, } impl CloudUserStore { - pub fn new(cloud_client: Arc, cx: &mut Context) -> Self { + pub fn new( + cloud_client: Arc, + rpc_user_store: Entity, + cx: &mut Context, + ) -> Self { + let rpc_plan_updated_subscription = + cx.subscribe(&rpc_user_store, Self::handle_rpc_user_store_event); + Self { + cloud_client: cloud_client.clone(), authenticated_user: None, + plan_info: None, _maintain_authenticated_user_task: cx.spawn(async move |this, cx| { maybe!(async move { loop { @@ -36,14 +53,15 @@ impl CloudUserStore { .context("failed to fetch authenticated user"); if let Some(response) = authenticated_user_result.log_err() { this.update(cx, |this, _cx| { - this.authenticated_user = Some(Arc::new(response.user)); + this.update_authenticated_user(response); }) .ok(); } } } else { this.update(cx, |this, _cx| { - this.authenticated_user = None; + this.authenticated_user.take(); + this.plan_info.take(); }) .ok(); } @@ -56,6 +74,7 @@ impl CloudUserStore { .await .log_err(); }), + _rpc_plan_updated_subscription: rpc_plan_updated_subscription, } } @@ -66,4 +85,56 @@ impl CloudUserStore { pub fn authenticated_user(&self) -> Option> { self.authenticated_user.clone() } + + pub fn plan(&self) -> Option { + self.plan_info.as_ref().map(|plan| plan.plan) + } + + pub fn subscription_period(&self) -> Option<(DateTime, DateTime)> { + self.plan_info + .as_ref() + .and_then(|plan| plan.subscription_period) + .map(|subscription_period| { + ( + subscription_period.started_at.0, + subscription_period.ended_at.0, + ) + }) + } + + fn update_authenticated_user(&mut self, response: GetAuthenticatedUserResponse) { + self.authenticated_user = Some(Arc::new(response.user)); + self.plan_info = Some(Arc::new(response.plan)); + } + + fn handle_rpc_user_store_event( + &mut self, + _: Entity, + event: &RpcUserStoreEvent, + cx: &mut Context, + ) { + match event { + RpcUserStoreEvent::PlanUpdated => { + cx.spawn(async move |this, cx| { + let cloud_client = + cx.update(|cx| this.read_with(cx, |this, _cx| this.cloud_client.clone()))??; + + let response = cloud_client + .get_authenticated_user() + .await + .context("failed to fetch authenticated user")?; + + cx.update(|cx| { + this.update(cx, |this, _cx| { + this.update_authenticated_user(response); + }) + })??; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + _ => {} + } + } } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index a7dab2a8d3..e025ec0523 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -145,6 +145,7 @@ pub enum Event { ShowContacts, ParticipantIndicesChanged, PrivateUserInfoUpdated, + PlanUpdated, } #[derive(Clone, Copy)] @@ -388,6 +389,7 @@ impl UserStore { .map(EditPredictionUsage); } + cx.emit(Event::PlanUpdated); cx.notify(); })?; Ok(()) diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index ab6bf1b912..00d1caa7c5 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -282,7 +282,8 @@ impl TestServer { .register_hosting_provider(Arc::new(git_hosting_providers::Github::public_instance())); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 8e95c6f79f..cf178e2850 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -32,6 +32,7 @@ auto_update.workspace = true call.workspace = true chrono.workspace = true client.workspace = true +cloud_llm_client.workspace = true db.workspace = true gpui = { workspace = true, features = ["screen-capture"] } notifications.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 6e03b52ef8..552ef915cb 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -21,6 +21,7 @@ use crate::application_menu::{ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, CloudUserStore, UserStore, zed_urls}; +use cloud_llm_client::Plan; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, @@ -28,7 +29,6 @@ use gpui::{ }; use onboarding_banner::OnboardingBanner; use project::Project; -use rpc::proto; use settings::Settings as _; use settings_ui::keybindings; use std::sync::Arc; @@ -634,8 +634,8 @@ impl TitleBar { pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { let cloud_user_store = self.cloud_user_store.read(cx); if let Some(user) = cloud_user_store.authenticated_user() { - let has_subscription_period = self.user_store.read(cx).subscription_period().is_some(); - let plan = self.user_store.read(cx).current_plan().filter(|_| { + let has_subscription_period = cloud_user_store.subscription_period().is_some(); + let plan = cloud_user_store.plan().filter(|_| { // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. has_subscription_period }); @@ -662,13 +662,9 @@ impl TitleBar { let user_login = user.github_login.clone(); let (plan_name, label_color, bg_color) = match plan { - None | Some(proto::Plan::Free) => { - ("Free", Color::Default, free_chip_bg) - } - Some(proto::Plan::ZedProTrial) => { - ("Pro Trial", Color::Accent, pro_chip_bg) - } - Some(proto::Plan::ZedPro) => ("Pro", Color::Accent, pro_chip_bg), + None | Some(Plan::ZedFree) => ("Free", Color::Default, free_chip_bg), + Some(Plan::ZedProTrial) => ("Pro Trial", Color::Accent, pro_chip_bg), + Some(Plan::ZedPro) => ("Pro", Color::Accent, pro_chip_bg), }; menu.custom_entry( diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5271141620..e9af72cd91 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -913,7 +913,8 @@ impl AppState { let client = Client::new(clock, http_client.clone(), cx); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); theme::init(theme::LoadThemes::JustBase, cx); @@ -5693,7 +5694,8 @@ impl Workspace { let client = project.read(cx).client(); let user_store = project.read(cx).user_store(); - let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 1fd9fd3d97..e62c15ae10 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -457,7 +457,8 @@ pub fn main() { language::init(cx); languages::init(languages.clone(), node_runtime.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = cx.new(|cx| CloudUserStore::new(client.cloud_client(), cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); language_extension::init( From 2e3c30e73308f11cd3f2e1409e3d814a95a2da2e Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 14:00:29 -0400 Subject: [PATCH 28/56] cloud_api_types: Add types for `POST /client/llm_tokens` endpoint (#35420) This PR adds some types for the new `POST /client/llm_tokens` endpoint. Release Notes: - N/A Co-authored-by: Richard --- crates/cloud_api_types/src/cloud_api_types.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index 9f79770315..e4d4a27af5 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; pub use crate::timestamp::Timestamp; +pub const ZED_SYSTEM_ID_HEADER_NAME: &str = "x-zed-system-id"; + #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct GetAuthenticatedUserResponse { pub user: AuthenticatedUser, @@ -38,3 +40,11 @@ pub struct SubscriptionPeriod { pub started_at: Timestamp, pub ended_at: Timestamp, } + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct LlmToken(pub String); + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct CreateLlmTokenResponse { + pub token: LlmToken, +} From cdeddaab2181794659409f70f017bfc7c69a61df Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 17:01:21 -0400 Subject: [PATCH 29/56] cloud_api_client: Add `create_llm_token` method (#35428) This PR adds a `create_llm_token` method to the `CloudApiClient`. Release Notes: - N/A --- .../cloud_api_client/src/cloud_api_client.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 2d017cf2ee..5a768810c0 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -80,4 +80,42 @@ impl CloudApiClient { Ok(serde_json::from_str(&body)?) } + + pub async fn create_llm_token( + &self, + system_id: Option, + ) -> Result { + let mut request_builder = Request::builder() + .method(Method::POST) + .uri( + self.http_client + .build_zed_cloud_url("/client/llm_tokens", &[])? + .as_ref(), + ) + .header("Content-Type", "application/json") + .header("Authorization", self.authorization_header()?); + + if let Some(system_id) = system_id { + request_builder = request_builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id); + } + + let request = request_builder.body(AsyncBody::default())?; + + let mut response = self.http_client.send(request).await?; + + if !response.status().is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + anyhow::bail!( + "Failed to create LLM token.\nStatus: {:?}\nBody: {body}", + response.status() + ) + } + + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + Ok(serde_json::from_str(&body)?) + } } From 7fdbfc9e8da3b9d1194ebf8d80339ac328a40c4a Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 18:12:04 -0400 Subject: [PATCH 30/56] Acquire LLM token from Cloud instead of Collab for Edit Predictions (#35431) This PR updates the Zed Edit Prediction provider to acquire the LLM token from Cloud instead of Collab to allow using Edit Predictions even when disconnected from or unable to connect to the Collab server. Release Notes: - N/A --------- Co-authored-by: Richard Feldman --- Cargo.lock | 2 +- crates/client/src/cloud/user_store.rs | 44 +++- crates/client/src/user.rs | 21 -- .../language_model/src/model/cloud_model.rs | 11 +- crates/zed/src/main.rs | 2 +- .../zed/src/zed/inline_completion_registry.rs | 31 ++- crates/zeta/Cargo.toml | 6 +- crates/zeta/src/zeta.rs | 219 +++++++++++------- 8 files changed, 211 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ac29aeac0..5ef5a8afd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20599,6 +20599,7 @@ dependencies = [ "call", "client", "clock", + "cloud_api_types", "cloud_llm_client", "collections", "command_palette_hooks", @@ -20619,7 +20620,6 @@ dependencies = [ "menu", "postage", "project", - "proto", "regex", "release_channel", "reqwest_client", diff --git a/crates/client/src/cloud/user_store.rs b/crates/client/src/cloud/user_store.rs index a9b13ca23c..ea432f71ed 100644 --- a/crates/client/src/cloud/user_store.rs +++ b/crates/client/src/cloud/user_store.rs @@ -8,13 +8,14 @@ use cloud_llm_client::Plan; use gpui::{Context, Entity, Subscription, Task}; use util::{ResultExt as _, maybe}; -use crate::UserStore; use crate::user::Event as RpcUserStoreEvent; +use crate::{EditPredictionUsage, RequestUsage, UserStore}; pub struct CloudUserStore { cloud_client: Arc, authenticated_user: Option>, plan_info: Option>, + edit_prediction_usage: Option, _maintain_authenticated_user_task: Task<()>, _rpc_plan_updated_subscription: Subscription, } @@ -32,6 +33,7 @@ impl CloudUserStore { cloud_client: cloud_client.clone(), authenticated_user: None, plan_info: None, + edit_prediction_usage: None, _maintain_authenticated_user_task: cx.spawn(async move |this, cx| { maybe!(async move { loop { @@ -102,8 +104,48 @@ impl CloudUserStore { }) } + pub fn has_accepted_tos(&self) -> bool { + self.authenticated_user + .as_ref() + .map(|user| user.accepted_tos_at.is_some()) + .unwrap_or_default() + } + + /// Returns whether the user's account is too new to use the service. + pub fn account_too_young(&self) -> bool { + self.plan_info + .as_ref() + .map(|plan| plan.is_account_too_young) + .unwrap_or_default() + } + + /// Returns whether the current user has overdue invoices and usage should be blocked. + pub fn has_overdue_invoices(&self) -> bool { + self.plan_info + .as_ref() + .map(|plan| plan.has_overdue_invoices) + .unwrap_or_default() + } + + pub fn edit_prediction_usage(&self) -> Option { + self.edit_prediction_usage + } + + pub fn update_edit_prediction_usage( + &mut self, + usage: EditPredictionUsage, + cx: &mut Context, + ) { + self.edit_prediction_usage = Some(usage); + cx.notify(); + } + fn update_authenticated_user(&mut self, response: GetAuthenticatedUserResponse) { self.authenticated_user = Some(Arc::new(response.user)); + self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage { + limit: response.plan.usage.edit_predictions.limit, + amount: response.plan.usage.edit_predictions.used as i32, + })); self.plan_info = Some(Arc::new(response.plan)); } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index e025ec0523..84f30f3530 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -114,7 +114,6 @@ pub struct UserStore { subscription_period: Option<(DateTime, DateTime)>, trial_started_at: Option>, model_request_usage: Option, - edit_prediction_usage: Option, is_usage_based_billing_enabled: Option, account_too_young: Option, has_overdue_invoices: Option, @@ -193,7 +192,6 @@ impl UserStore { subscription_period: None, trial_started_at: None, model_request_usage: None, - edit_prediction_usage: None, is_usage_based_billing_enabled: None, account_too_young: None, has_overdue_invoices: None, @@ -381,12 +379,6 @@ impl UserStore { RequestUsage::from_proto(usage.model_requests_usage_amount, limit) }) .map(ModelRequestUsage); - this.edit_prediction_usage = usage - .edit_predictions_usage_limit - .and_then(|limit| { - RequestUsage::from_proto(usage.model_requests_usage_amount, limit) - }) - .map(EditPredictionUsage); } cx.emit(Event::PlanUpdated); @@ -400,15 +392,6 @@ impl UserStore { cx.notify(); } - pub fn update_edit_prediction_usage( - &mut self, - usage: EditPredictionUsage, - cx: &mut Context, - ) { - self.edit_prediction_usage = Some(usage); - cx.notify(); - } - fn update_contacts(&mut self, message: UpdateContacts, cx: &Context) -> Task> { match message { UpdateContacts::Wait(barrier) => { @@ -797,10 +780,6 @@ impl UserStore { self.model_request_usage } - pub fn edit_prediction_usage(&self) -> Option { - self.edit_prediction_usage - } - pub fn watch_current_user(&self) -> watch::Receiver>> { self.current_user.clone() } diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index 72b7132c60..a5d2ac34f5 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -64,9 +64,14 @@ impl LlmApiToken { mut lock: RwLockWriteGuard<'_, Option>, client: &Arc, ) -> Result { - let response = client.request(proto::GetLlmToken {}).await?; - *lock = Some(response.token.clone()); - Ok(response.token.clone()) + let system_id = client + .telemetry() + .system_id() + .map(|system_id| system_id.to_string()); + + let response = client.cloud_client().create_llm_token(system_id).await?; + *lock = Some(response.token.0.clone()); + Ok(response.token.0.clone()) } } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e62c15ae10..219dc1e7ae 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -564,7 +564,7 @@ pub fn main() { snippet_provider::init(cx); inline_completion_registry::init( app_state.client.clone(), - app_state.user_store.clone(), + app_state.cloud_user_store.clone(), cx, ); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), stdout_is_a_pty(), cx); diff --git a/crates/zed/src/zed/inline_completion_registry.rs b/crates/zed/src/zed/inline_completion_registry.rs index a8037f0f90..89d6ff054b 100644 --- a/crates/zed/src/zed/inline_completion_registry.rs +++ b/crates/zed/src/zed/inline_completion_registry.rs @@ -1,4 +1,4 @@ -use client::{Client, UserStore}; +use client::{Client, CloudUserStore}; use collections::HashMap; use copilot::{Copilot, CopilotCompletionProvider}; use editor::Editor; @@ -14,12 +14,12 @@ use util::ResultExt; use workspace::Workspace; use zeta::{ProviderDataCollection, ZetaInlineCompletionProvider}; -pub fn init(client: Arc, user_store: Entity, cx: &mut App) { +pub fn init(client: Arc, cloud_user_store: Entity, cx: &mut App) { let editors: Rc, AnyWindowHandle>>> = Rc::default(); cx.observe_new({ let editors = editors.clone(); let client = client.clone(); - let user_store = user_store.clone(); + let cloud_user_store = cloud_user_store.clone(); move |editor: &mut Editor, window, cx: &mut Context| { if !editor.mode().is_full() { return; @@ -49,7 +49,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { editor, provider, &client, - user_store.clone(), + cloud_user_store.clone(), window, cx, ); @@ -61,7 +61,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let mut provider = all_language_settings(None, cx).edit_predictions.provider; cx.spawn({ - let user_store = user_store.clone(); + let cloud_user_store = cloud_user_store.clone(); let editors = editors.clone(); let client = client.clone(); @@ -73,7 +73,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { &editors, provider, &client, - user_store.clone(), + cloud_user_store.clone(), cx, ); }) @@ -86,15 +86,12 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { cx.observe_global::({ let editors = editors.clone(); let client = client.clone(); - let user_store = user_store.clone(); + let cloud_user_store = cloud_user_store.clone(); move |cx| { let new_provider = all_language_settings(None, cx).edit_predictions.provider; if new_provider != provider { - let tos_accepted = user_store - .read(cx) - .current_user_has_accepted_terms() - .unwrap_or(false); + let tos_accepted = cloud_user_store.read(cx).has_accepted_tos(); telemetry::event!( "Edit Prediction Provider Changed", @@ -108,7 +105,7 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { &editors, provider, &client, - user_store.clone(), + cloud_user_store.clone(), cx, ); @@ -149,7 +146,7 @@ fn assign_edit_prediction_providers( editors: &Rc, AnyWindowHandle>>>, provider: EditPredictionProvider, client: &Arc, - user_store: Entity, + cloud_user_store: Entity, cx: &mut App, ) { for (editor, window) in editors.borrow().iter() { @@ -159,7 +156,7 @@ fn assign_edit_prediction_providers( editor, provider, &client, - user_store.clone(), + cloud_user_store.clone(), window, cx, ); @@ -214,7 +211,7 @@ fn assign_edit_prediction_provider( editor: &mut Editor, provider: EditPredictionProvider, client: &Arc, - user_store: Entity, + cloud_user_store: Entity, window: &mut Window, cx: &mut Context, ) { @@ -245,7 +242,7 @@ fn assign_edit_prediction_provider( } } EditPredictionProvider::Zed => { - if client.status().borrow().is_connected() { + if cloud_user_store.read(cx).is_authenticated() { let mut worktree = None; if let Some(buffer) = &singleton_buffer { @@ -267,7 +264,7 @@ fn assign_edit_prediction_provider( .map(|workspace| workspace.downgrade()); let zeta = - zeta::Zeta::register(workspace, worktree, client.clone(), user_store, cx); + zeta::Zeta::register(workspace, worktree, client.clone(), cloud_user_store, cx); if let Some(buffer) = &singleton_buffer { if buffer.read(cx).file().is_some() { diff --git a/crates/zeta/Cargo.toml b/crates/zeta/Cargo.toml index 294d95aefd..26eeda3f22 100644 --- a/crates/zeta/Cargo.toml +++ b/crates/zeta/Cargo.toml @@ -40,7 +40,6 @@ log.workspace = true menu.workspace = true postage.workspace = true project.workspace = true -proto.workspace = true regex.workspace = true release_channel.workspace = true serde.workspace = true @@ -59,9 +58,11 @@ worktree.workspace = true zed_actions.workspace = true [dev-dependencies] -collections = { workspace = true, features = ["test-support"] } +call = { workspace = true, features = ["test-support"] } client = { workspace = true, features = ["test-support"] } clock = { workspace = true, features = ["test-support"] } +cloud_api_types.workspace = true +collections = { workspace = true, features = ["test-support"] } ctor.workspace = true editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } @@ -77,5 +78,4 @@ tree-sitter-rust.workspace = true unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } -call = { workspace = true, features = ["test-support"] } zlog.workspace = true diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index d5c6be278b..d295b7d17c 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -16,7 +16,7 @@ pub use rate_completion_modal::*; use anyhow::{Context as _, Result, anyhow}; use arrayvec::ArrayVec; -use client::{Client, EditPredictionUsage, UserStore}; +use client::{Client, CloudUserStore, EditPredictionUsage, UserStore}; use cloud_llm_client::{ AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsBody, PredictEditsResponse, ZED_VERSION_HEADER_NAME, @@ -226,12 +226,9 @@ pub struct Zeta { data_collection_choice: Entity, llm_token: LlmApiToken, _llm_token_subscription: Subscription, - /// Whether the terms of service have been accepted. - tos_accepted: bool, /// Whether an update to a newer version of Zed is required to continue using Zeta. update_required: bool, - user_store: Entity, - _user_store_subscription: Subscription, + cloud_user_store: Entity, license_detection_watchers: HashMap>, } @@ -244,11 +241,11 @@ impl Zeta { workspace: Option>, worktree: Option>, client: Arc, - user_store: Entity, + cloud_user_store: Entity, cx: &mut App, ) -> Entity { let this = Self::global(cx).unwrap_or_else(|| { - let entity = cx.new(|cx| Self::new(workspace, client, user_store, cx)); + let entity = cx.new(|cx| Self::new(workspace, client, cloud_user_store, cx)); cx.set_global(ZetaGlobal(entity.clone())); entity }); @@ -271,13 +268,13 @@ impl Zeta { } pub fn usage(&self, cx: &App) -> Option { - self.user_store.read(cx).edit_prediction_usage() + self.cloud_user_store.read(cx).edit_prediction_usage() } fn new( workspace: Option>, client: Arc, - user_store: Entity, + cloud_user_store: Entity, cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); @@ -306,24 +303,9 @@ impl Zeta { .detach_and_log_err(cx); }, ), - tos_accepted: user_store - .read(cx) - .current_user_has_accepted_terms() - .unwrap_or(false), update_required: false, - _user_store_subscription: cx.subscribe(&user_store, |this, user_store, event, cx| { - match event { - client::user::Event::PrivateUserInfoUpdated => { - this.tos_accepted = user_store - .read(cx) - .current_user_has_accepted_terms() - .unwrap_or(false); - } - _ => {} - } - }), license_detection_watchers: HashMap::default(), - user_store, + cloud_user_store, } } @@ -552,8 +534,8 @@ impl Zeta { if let Some(usage) = usage { this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); + this.cloud_user_store.update(cx, |cloud_user_store, cx| { + cloud_user_store.update_edit_prediction_usage(usage, cx); }); }) .ok(); @@ -894,8 +876,8 @@ and then another if response.status().is_success() { if let Some(usage) = EditPredictionUsage::from_headers(response.headers()).ok() { this.update(cx, |this, cx| { - this.user_store.update(cx, |user_store, cx| { - user_store.update_edit_prediction_usage(usage, cx); + this.cloud_user_store.update(cx, |cloud_user_store, cx| { + cloud_user_store.update_edit_prediction_usage(usage, cx); }); })?; } @@ -1573,7 +1555,12 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider } fn needs_terms_acceptance(&self, cx: &App) -> bool { - !self.zeta.read(cx).tos_accepted + !self + .zeta + .read(cx) + .cloud_user_store + .read(cx) + .has_accepted_tos() } fn is_refreshing(&self) -> bool { @@ -1588,7 +1575,7 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider _debounce: bool, cx: &mut Context, ) { - if !self.zeta.read(cx).tos_accepted { + if self.needs_terms_acceptance(cx) { return; } @@ -1599,9 +1586,9 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider if self .zeta .read(cx) - .user_store - .read_with(cx, |user_store, _| { - user_store.account_too_young() || user_store.has_overdue_invoices() + .cloud_user_store + .read_with(cx, |cloud_user_store, _cx| { + cloud_user_store.account_too_young() || cloud_user_store.has_overdue_invoices() }) { return; @@ -1819,15 +1806,51 @@ fn tokens_for_bytes(bytes: usize) -> usize { mod tests { use client::test::FakeServer; use clock::FakeSystemClock; + use cloud_api_types::{ + AuthenticatedUser, CreateLlmTokenResponse, GetAuthenticatedUserResponse, LlmToken, PlanInfo, + }; + use cloud_llm_client::{CurrentUsage, Plan, UsageData, UsageLimit}; use gpui::TestAppContext; use http_client::FakeHttpClient; use indoc::indoc; use language::Point; - use rpc::proto; use settings::SettingsStore; use super::*; + fn make_get_authenticated_user_response() -> GetAuthenticatedUserResponse { + GetAuthenticatedUserResponse { + user: AuthenticatedUser { + id: 1, + metrics_id: "metrics-id-1".to_string(), + avatar_url: "".to_string(), + github_login: "".to_string(), + name: None, + is_staff: false, + accepted_tos_at: None, + }, + feature_flags: vec![], + plan: PlanInfo { + plan: Plan::ZedPro, + subscription_period: None, + usage: CurrentUsage { + model_requests: UsageData { + used: 0, + limit: UsageLimit::Limited(500), + }, + edit_predictions: UsageData { + used: 250, + limit: UsageLimit::Unlimited, + }, + }, + trial_started_at: None, + is_usage_based_billing_enabled: false, + is_account_too_young: false, + has_overdue_invoices: false, + }, + } + } + #[gpui::test] async fn test_inline_completion_basic_interpolation(cx: &mut TestAppContext) { let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); @@ -2027,28 +2050,55 @@ mod tests { <|editable_region_end|> ```"}; - let http_client = FakeHttpClient::create(move |_| async move { - Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&PredictEditsResponse { - request_id: Uuid::parse_str("7e86480f-3536-4d2c-9334-8213e3445d45") - .unwrap(), - output_excerpt: completion_response.to_string(), - }) - .unwrap() - .into(), - ) - .unwrap()) + let http_client = FakeHttpClient::create(move |req| async move { + match (req.method(), req.uri().path()) { + (&Method::GET, "/client/users/me") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&make_get_authenticated_user_response()) + .unwrap() + .into(), + ) + .unwrap()), + (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&CreateLlmTokenResponse { + token: LlmToken("the-llm-token".to_string()), + }) + .unwrap() + .into(), + ) + .unwrap()), + (&Method::POST, "/predict_edits/v2") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&PredictEditsResponse { + request_id: Uuid::parse_str("7e86480f-3536-4d2c-9334-8213e3445d45") + .unwrap(), + output_excerpt: completion_response.to_string(), + }) + .unwrap() + .into(), + ) + .unwrap()), + _ => Ok(http_client::Response::builder() + .status(404) + .body("Not Found".into()) + .unwrap()), + } }); let client = cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client, cx)); cx.update(|cx| { RefreshLlmTokenListener::register(client.clone(), cx); }); - let server = FakeServer::for_client(42, &client, cx).await; + // Construct the fake server to authenticate. + let _server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(None, client, user_store, cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); + let zeta = cx.new(|cx| Zeta::new(None, client, cloud_user_store, cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); @@ -2056,13 +2106,6 @@ mod tests { zeta.request_completion(None, &buffer, cursor, false, cx) }); - server.receive::().await.unwrap(); - let token_request = server.receive::().await.unwrap(); - server.respond( - token_request.receipt(), - proto::GetLlmTokenResponse { token: "".into() }, - ); - let completion = completion_task.await.unwrap().unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit(completion.edits.iter().cloned(), None, cx) @@ -2079,20 +2122,44 @@ mod tests { cx: &mut TestAppContext, ) -> Vec<(Range, String)> { let completion_response = completion_response.to_string(); - let http_client = FakeHttpClient::create(move |_| { + let http_client = FakeHttpClient::create(move |req| { let completion = completion_response.clone(); async move { - Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&PredictEditsResponse { - request_id: Uuid::new_v4(), - output_excerpt: completion, - }) - .unwrap() - .into(), - ) - .unwrap()) + match (req.method(), req.uri().path()) { + (&Method::GET, "/client/users/me") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&make_get_authenticated_user_response()) + .unwrap() + .into(), + ) + .unwrap()), + (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&CreateLlmTokenResponse { + token: LlmToken("the-llm-token".to_string()), + }) + .unwrap() + .into(), + ) + .unwrap()), + (&Method::POST, "/predict_edits/v2") => Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&PredictEditsResponse { + request_id: Uuid::new_v4(), + output_excerpt: completion, + }) + .unwrap() + .into(), + ) + .unwrap()), + _ => Ok(http_client::Response::builder() + .status(404) + .body("Not Found".into()) + .unwrap()), + } } }); @@ -2100,9 +2167,12 @@ mod tests { cx.update(|cx| { RefreshLlmTokenListener::register(client.clone(), cx); }); - let server = FakeServer::for_client(42, &client, cx).await; + // Construct the fake server to authenticate. + let _server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(None, client, user_store, cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); + let zeta = cx.new(|cx| Zeta::new(None, client, cloud_user_store, cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); @@ -2111,13 +2181,6 @@ mod tests { zeta.request_completion(None, &buffer, cursor, false, cx) }); - server.receive::().await.unwrap(); - let token_request = server.receive::().await.unwrap(); - server.respond( - token_request.receipt(), - proto::GetLlmTokenResponse { token: "".into() }, - ); - let completion = completion_task.await.unwrap().unwrap(); completion .edits From 6e0999fb4fb556404c7a135bafba8f35afc96957 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 20:55:17 -0400 Subject: [PATCH 31/56] Rework authentication for local Cloud/Collab development (#35450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR reworks authentication for developing Zed against a local version of Cloud and/or Collab. You will still connect the same way—using the `zed-local` script—but will need to be running an instance of Cloud locally. Release Notes: - N/A --- crates/client/src/client.rs | 116 ++++++++---------------------------- script/zed-local | 2 +- 2 files changed, 25 insertions(+), 93 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 34fab17632..8aa46ecda5 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -7,14 +7,13 @@ pub mod telemetry; pub mod user; pub mod zed_urls; -use anyhow::{Context as _, Result, anyhow, bail}; +use anyhow::{Context as _, Result, anyhow}; use async_recursion::async_recursion; use async_tungstenite::tungstenite::{ client::IntoClientRequest, error::Error as WebsocketError, http::{HeaderValue, Request, StatusCode}, }; -use chrono::{DateTime, Utc}; use clock::SystemClock; use cloud_api_client::CloudApiClient; use credentials_provider::CredentialsProvider; @@ -23,7 +22,7 @@ use futures::{ channel::oneshot, future::BoxFuture, }; use gpui::{App, AsyncApp, Entity, Global, Task, WeakEntity, actions}; -use http_client::{AsyncBody, HttpClient, HttpClientWithUrl, http}; +use http_client::{HttpClient, HttpClientWithUrl, http}; use parking_lot::RwLock; use postage::watch; use proxy::connect_proxy_stream; @@ -1351,96 +1350,31 @@ impl Client { self: &Arc, http: Arc, login: String, - mut api_token: String, + api_token: String, ) -> Result { - #[derive(Deserialize)] - struct AuthenticatedUserResponse { - user: User, + #[derive(Serialize)] + struct ImpersonateUserBody { + github_login: String, } #[derive(Deserialize)] - struct User { - id: u64, + struct ImpersonateUserResponse { + user_id: u64, + access_token: String, } - let github_user = { - #[derive(Deserialize)] - struct GithubUser { - id: i32, - login: String, - created_at: DateTime, - } - - let request = { - let mut request_builder = - Request::get(&format!("https://api.github.com/users/{login}")); - if let Ok(github_token) = std::env::var("GITHUB_TOKEN") { - request_builder = - request_builder.header("Authorization", format!("Bearer {}", github_token)); - } - - request_builder.body(AsyncBody::empty())? - }; - - let mut response = http - .send(request) - .await - .context("error fetching GitHub user")?; - - let mut body = Vec::new(); - response - .body_mut() - .read_to_end(&mut body) - .await - .context("error reading GitHub user")?; - - if !response.status().is_success() { - let text = String::from_utf8_lossy(body.as_slice()); - bail!( - "status error {}, response: {text:?}", - response.status().as_u16() - ); - } - - serde_json::from_slice::(body.as_slice()).map_err(|err| { - log::error!("Error deserializing: {:?}", err); - log::error!( - "GitHub API response text: {:?}", - String::from_utf8_lossy(body.as_slice()) - ); - anyhow!("error deserializing GitHub user") - })? - }; - - let query_params = [ - ("github_login", &github_user.login), - ("github_user_id", &github_user.id.to_string()), - ( - "github_user_created_at", - &github_user.created_at.to_rfc3339(), - ), - ]; - - // Use the collab server's admin API to retrieve the ID - // of the impersonated user. - let mut url = self.rpc_url(http.clone(), None).await?; - url.set_path("/user"); - url.set_query(Some( - &query_params - .iter() - .map(|(key, value)| { - format!( - "{}={}", - key, - url::form_urlencoded::byte_serialize(value.as_bytes()).collect::() - ) - }) - .collect::>() - .join("&"), - )); - let request: http_client::Request = Request::get(url.as_str()) - .header("Authorization", format!("token {api_token}")) - .body("".into())?; + let url = self + .http + .build_zed_cloud_url("/internal/users/impersonate", &[])?; + let request = Request::post(url.as_str()) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {api_token}")) + .body( + serde_json::to_string(&ImpersonateUserBody { + github_login: login, + })? + .into(), + )?; let mut response = http.send(request).await?; let mut body = String::new(); @@ -1451,13 +1385,11 @@ impl Client { response.status().as_u16(), body, ); - let response: AuthenticatedUserResponse = serde_json::from_str(&body)?; + let response: ImpersonateUserResponse = serde_json::from_str(&body)?; - // Use the admin API token to authenticate as the impersonated user. - api_token.insert_str(0, "ADMIN_TOKEN:"); Ok(Credentials { - user_id: response.user.id, - access_token: api_token, + user_id: response.user_id, + access_token: response.access_token, }) } diff --git a/script/zed-local b/script/zed-local index 2568931246..99d9308232 100755 --- a/script/zed-local +++ b/script/zed-local @@ -213,7 +213,7 @@ setTimeout(() => { platform === "win32" ? "http://127.0.0.1:8080/rpc" : "http://localhost:8080/rpc", - ZED_ADMIN_API_TOKEN: "secret", + ZED_ADMIN_API_TOKEN: "internal-api-key-secret", ZED_WINDOW_SIZE: size, ZED_CLIENT_CHECKSUM_SEED: "development-checksum-seed", RUST_LOG: process.env.RUST_LOG || "info", From 5a70f2131c12e485dac05b18e0b86c3fbb44f94c Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 21:44:43 -0400 Subject: [PATCH 32/56] Update Agent panel to work with `CloudUserStore` (#35436) This PR updates the Agent panel to work with the `CloudUserStore` instead of the `UserStore`, reducing its reliance on being connected to Collab to function. Release Notes: - N/A --------- Co-authored-by: Richard Feldman --- crates/agent/src/thread.rs | 32 ++++--- crates/agent/src/thread_store.rs | 18 +++- crates/agent_ui/src/active_thread.rs | 7 ++ crates/agent_ui/src/agent_diff.rs | 13 +++ crates/agent_ui/src/agent_panel.rs | 16 ++-- crates/agent_ui/src/message_editor.rs | 33 +++---- .../assistant_tools/src/edit_agent/evals.rs | 6 +- crates/client/src/cloud/user_store.rs | 31 ++++++- crates/client/src/user.rs | 21 ----- crates/eval/src/eval.rs | 13 ++- crates/eval/src/instance.rs | 1 + crates/language_models/src/language_models.rs | 25 +++++- crates/language_models/src/provider/cloud.rs | 87 +++++++++++-------- crates/zed/src/main.rs | 7 +- crates/zed/src/zed.rs | 7 +- .../preview_support/active_thread.rs | 3 +- 16 files changed, 212 insertions(+), 108 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 0e5da2d43b..ee16f83dc4 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -12,7 +12,7 @@ use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; use anyhow::{Result, anyhow}; use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet}; use chrono::{DateTime, Utc}; -use client::{ModelRequestUsage, RequestUsage}; +use client::{CloudUserStore, ModelRequestUsage, RequestUsage}; use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; use collections::HashMap; use feature_flags::{self, FeatureFlagAppExt}; @@ -374,6 +374,7 @@ pub struct Thread { completion_count: usize, pending_completions: Vec, project: Entity, + cloud_user_store: Entity, prompt_builder: Arc, tools: Entity, tool_use: ToolUseState, @@ -444,6 +445,7 @@ pub struct ExceededWindowError { impl Thread { pub fn new( project: Entity, + cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, system_prompt: SharedProjectContext, @@ -470,6 +472,7 @@ impl Thread { completion_count: 0, pending_completions: Vec::new(), project: project.clone(), + cloud_user_store, prompt_builder, tools: tools.clone(), last_restore_checkpoint: None, @@ -503,6 +506,7 @@ impl Thread { id: ThreadId, serialized: SerializedThread, project: Entity, + cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, project_context: SharedProjectContext, @@ -603,6 +607,7 @@ impl Thread { last_restore_checkpoint: None, pending_checkpoint: None, project: project.clone(), + cloud_user_store, prompt_builder, tools: tools.clone(), tool_use, @@ -3255,16 +3260,14 @@ impl Thread { } fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut Context) { - self.project.update(cx, |project, cx| { - project.user_store().update(cx, |user_store, cx| { - user_store.update_model_request_usage( - ModelRequestUsage(RequestUsage { - amount: amount as i32, - limit, - }), - cx, - ) - }) + self.cloud_user_store.update(cx, |cloud_user_store, cx| { + cloud_user_store.update_model_request_usage( + ModelRequestUsage(RequestUsage { + amount: amount as i32, + limit, + }), + cx, + ) }); } @@ -3883,6 +3886,7 @@ fn main() {{ thread.id.clone(), serialized, thread.project.clone(), + thread.cloud_user_store.clone(), thread.tools.clone(), thread.prompt_builder.clone(), thread.project_context.clone(), @@ -5479,10 +5483,16 @@ fn main() {{ let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (client, user_store) = + project.read_with(cx, |project, _cx| (project.client(), project.user_store())); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); + let thread_store = cx .update(|_, cx| { ThreadStore::load( project.clone(), + cloud_user_store, cx.new(|_| ToolWorkingSet::default()), None, Arc::new(PromptBuilder::new(None).unwrap()), diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index cc7cb50c91..6efa56f233 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -8,6 +8,7 @@ use agent_settings::{AgentProfileId, CompletionMode}; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{Tool, ToolId, ToolWorkingSet}; use chrono::{DateTime, Utc}; +use client::CloudUserStore; use collections::HashMap; use context_server::ContextServerId; use futures::{ @@ -104,6 +105,7 @@ pub type TextThreadStore = assistant_context::ContextStore; pub struct ThreadStore { project: Entity, + cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, prompt_store: Option>, @@ -124,6 +126,7 @@ impl EventEmitter for ThreadStore {} impl ThreadStore { pub fn load( project: Entity, + cloud_user_store: Entity, tools: Entity, prompt_store: Option>, prompt_builder: Arc, @@ -133,8 +136,14 @@ impl ThreadStore { let (thread_store, ready_rx) = cx.update(|cx| { let mut option_ready_rx = None; let thread_store = cx.new(|cx| { - let (thread_store, ready_rx) = - Self::new(project, tools, prompt_builder, prompt_store, cx); + let (thread_store, ready_rx) = Self::new( + project, + cloud_user_store, + tools, + prompt_builder, + prompt_store, + cx, + ); option_ready_rx = Some(ready_rx); thread_store }); @@ -147,6 +156,7 @@ impl ThreadStore { fn new( project: Entity, + cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, prompt_store: Option>, @@ -190,6 +200,7 @@ impl ThreadStore { let this = Self { project, + cloud_user_store, tools, prompt_builder, prompt_store, @@ -407,6 +418,7 @@ impl ThreadStore { cx.new(|cx| { Thread::new( self.project.clone(), + self.cloud_user_store.clone(), self.tools.clone(), self.prompt_builder.clone(), self.project_context.clone(), @@ -425,6 +437,7 @@ impl ThreadStore { ThreadId::new(), serialized, self.project.clone(), + self.cloud_user_store.clone(), self.tools.clone(), self.prompt_builder.clone(), self.project_context.clone(), @@ -456,6 +469,7 @@ impl ThreadStore { id.clone(), thread, this.project.clone(), + this.cloud_user_store.clone(), this.tools.clone(), this.prompt_builder.clone(), this.project_context.clone(), diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index 04a093c7d0..1669c24a1b 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -3820,6 +3820,7 @@ mod tests { use super::*; use agent::{MessageSegment, context::ContextLoadResult, thread_store}; use assistant_tool::{ToolRegistry, ToolWorkingSet}; + use client::CloudUserStore; use editor::EditorSettings; use fs::FakeFs; use gpui::{AppContext, TestAppContext, VisualTestContext}; @@ -4116,10 +4117,16 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (client, user_store) = + project.read_with(cx, |project, _cx| (project.client(), project.user_store())); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); + let thread_store = cx .update(|_, cx| { ThreadStore::load( project.clone(), + cloud_user_store, cx.new(|_| ToolWorkingSet::default()), None, Arc::new(PromptBuilder::new(None).unwrap()), diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index ec0a11f86b..5c8011cb18 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1893,6 +1893,7 @@ mod tests { use agent::thread_store::{self, ThreadStore}; use agent_settings::AgentSettings; use assistant_tool::ToolWorkingSet; + use client::CloudUserStore; use editor::EditorSettings; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{FakeFs, Project}; @@ -1932,11 +1933,17 @@ mod tests { }) .unwrap(); + let (client, user_store) = + project.read_with(cx, |project, _cx| (project.client(), project.user_store())); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); + let prompt_store = None; let thread_store = cx .update(|cx| { ThreadStore::load( project.clone(), + cloud_user_store, cx.new(|_| ToolWorkingSet::default()), prompt_store, Arc::new(PromptBuilder::new(None).unwrap()), @@ -2098,11 +2105,17 @@ mod tests { }) .unwrap(); + let (client, user_store) = + project.read_with(cx, |project, _cx| (project.client(), project.user_store())); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); + let prompt_store = None; let thread_store = cx .update(|cx| { ThreadStore::load( project.clone(), + cloud_user_store, cx.new(|_| ToolWorkingSet::default()), prompt_store, Arc::new(PromptBuilder::new(None).unwrap()), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 190bb58ed1..5eabd107b9 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -43,7 +43,7 @@ use anyhow::{Result, anyhow}; use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; -use client::{UserStore, zed_urls}; +use client::{CloudUserStore, UserStore, zed_urls}; use cloud_llm_client::{CompletionIntent, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use feature_flags::{self, FeatureFlagAppExt}; @@ -427,6 +427,7 @@ impl ActiveView { pub struct AgentPanel { workspace: WeakEntity, user_store: Entity, + cloud_user_store: Entity, project: Entity, fs: Arc, language_registry: Arc, @@ -486,6 +487,7 @@ impl AgentPanel { let project = workspace.project().clone(); ThreadStore::load( project, + workspace.app_state().cloud_user_store.clone(), tools.clone(), prompt_store.clone(), prompt_builder.clone(), @@ -553,6 +555,7 @@ impl AgentPanel { let thread = thread_store.update(cx, |this, cx| this.create_thread(cx)); let fs = workspace.app_state().fs.clone(); let user_store = workspace.app_state().user_store.clone(); + let cloud_user_store = workspace.app_state().cloud_user_store.clone(); let project = workspace.project(); let language_registry = project.read(cx).languages().clone(); let client = workspace.client().clone(); @@ -579,7 +582,7 @@ impl AgentPanel { MessageEditor::new( fs.clone(), workspace.clone(), - user_store.clone(), + cloud_user_store.clone(), message_editor_context_store.clone(), prompt_store.clone(), thread_store.downgrade(), @@ -706,6 +709,7 @@ impl AgentPanel { active_view, workspace, user_store, + cloud_user_store, project: project.clone(), fs: fs.clone(), language_registry, @@ -848,7 +852,7 @@ impl AgentPanel { MessageEditor::new( self.fs.clone(), self.workspace.clone(), - self.user_store.clone(), + self.cloud_user_store.clone(), context_store.clone(), self.prompt_store.clone(), self.thread_store.downgrade(), @@ -1122,7 +1126,7 @@ impl AgentPanel { MessageEditor::new( self.fs.clone(), self.workspace.clone(), - self.user_store.clone(), + self.cloud_user_store.clone(), context_store, self.prompt_store.clone(), self.thread_store.downgrade(), @@ -1821,8 +1825,8 @@ impl AgentPanel { } fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let user_store = self.user_store.read(cx); - let usage = user_store.model_request_usage(); + let cloud_user_store = self.cloud_user_store.read(cx); + let usage = cloud_user_store.model_request_usage(); let account_url = zed_urls::account_url(cx); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 082d1dfb51..e00a0087eb 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -17,7 +17,7 @@ use agent::{ use agent_settings::{AgentSettings, CompletionMode}; use ai_onboarding::ApiKeysWithProviders; use buffer_diff::BufferDiff; -use client::UserStore; +use client::CloudUserStore; use cloud_llm_client::CompletionIntent; use collections::{HashMap, HashSet}; use editor::actions::{MoveUp, Paste}; @@ -43,7 +43,6 @@ use language_model::{ use multi_buffer; use project::Project; use prompt_store::PromptStore; -use proto::Plan; use settings::Settings; use std::time::Duration; use theme::ThemeSettings; @@ -79,7 +78,7 @@ pub struct MessageEditor { editor: Entity, workspace: WeakEntity, project: Entity, - user_store: Entity, + cloud_user_store: Entity, context_store: Entity, prompt_store: Option>, history_store: Option>, @@ -159,7 +158,7 @@ impl MessageEditor { pub fn new( fs: Arc, workspace: WeakEntity, - user_store: Entity, + cloud_user_store: Entity, context_store: Entity, prompt_store: Option>, thread_store: WeakEntity, @@ -231,7 +230,7 @@ impl MessageEditor { Self { editor: editor.clone(), project: thread.read(cx).project().clone(), - user_store, + cloud_user_store, thread, incompatible_tools_state: incompatible_tools.clone(), workspace, @@ -1287,26 +1286,16 @@ impl MessageEditor { return None; } - let user_store = self.user_store.read(cx); - - let ubb_enable = user_store - .usage_based_billing_enabled() - .map_or(false, |enabled| enabled); - - if ubb_enable { + let cloud_user_store = self.cloud_user_store.read(cx); + if cloud_user_store.is_usage_based_billing_enabled() { return None; } - let plan = user_store - .current_plan() - .map(|plan| match plan { - Plan::Free => cloud_llm_client::Plan::ZedFree, - Plan::ZedPro => cloud_llm_client::Plan::ZedPro, - Plan::ZedProTrial => cloud_llm_client::Plan::ZedProTrial, - }) + let plan = cloud_user_store + .plan() .unwrap_or(cloud_llm_client::Plan::ZedFree); - let usage = user_store.model_request_usage()?; + let usage = cloud_user_store.model_request_usage()?; Some( div() @@ -1769,7 +1758,7 @@ impl AgentPreview for MessageEditor { ) -> Option { if let Some(workspace) = workspace.upgrade() { let fs = workspace.read(cx).app_state().fs.clone(); - let user_store = workspace.read(cx).app_state().user_store.clone(); + let cloud_user_store = workspace.read(cx).app_state().cloud_user_store.clone(); let project = workspace.read(cx).project().clone(); let weak_project = project.downgrade(); let context_store = cx.new(|_cx| ContextStore::new(weak_project, None)); @@ -1782,7 +1771,7 @@ impl AgentPreview for MessageEditor { MessageEditor::new( fs, workspace.downgrade(), - user_store, + cloud_user_store, context_store, None, thread_store.downgrade(), diff --git a/crates/assistant_tools/src/edit_agent/evals.rs b/crates/assistant_tools/src/edit_agent/evals.rs index eda7eee0e3..13619da25c 100644 --- a/crates/assistant_tools/src/edit_agent/evals.rs +++ b/crates/assistant_tools/src/edit_agent/evals.rs @@ -7,7 +7,7 @@ use crate::{ }; use Role::*; use assistant_tool::ToolRegistry; -use client::{Client, UserStore}; +use client::{Client, CloudUserStore, UserStore}; use collections::HashMap; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; @@ -1470,12 +1470,14 @@ impl EditAgentTest { client::init_settings(cx); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); settings::init(cx); Project::init_settings(cx); language::init(cx); language_model::init(client.clone(), cx); - language_models::init(user_store.clone(), client.clone(), cx); + language_models::init(user_store.clone(), cloud_user_store, client.clone(), cx); crate::init(client.http_client(), cx); }); diff --git a/crates/client/src/cloud/user_store.rs b/crates/client/src/cloud/user_store.rs index ea432f71ed..78444b3f95 100644 --- a/crates/client/src/cloud/user_store.rs +++ b/crates/client/src/cloud/user_store.rs @@ -9,12 +9,13 @@ use gpui::{Context, Entity, Subscription, Task}; use util::{ResultExt as _, maybe}; use crate::user::Event as RpcUserStoreEvent; -use crate::{EditPredictionUsage, RequestUsage, UserStore}; +use crate::{EditPredictionUsage, ModelRequestUsage, RequestUsage, UserStore}; pub struct CloudUserStore { cloud_client: Arc, authenticated_user: Option>, plan_info: Option>, + model_request_usage: Option, edit_prediction_usage: Option, _maintain_authenticated_user_task: Task<()>, _rpc_plan_updated_subscription: Subscription, @@ -33,6 +34,7 @@ impl CloudUserStore { cloud_client: cloud_client.clone(), authenticated_user: None, plan_info: None, + model_request_usage: None, edit_prediction_usage: None, _maintain_authenticated_user_task: cx.spawn(async move |this, cx| { maybe!(async move { @@ -104,6 +106,13 @@ impl CloudUserStore { }) } + pub fn trial_started_at(&self) -> Option> { + self.plan_info + .as_ref() + .and_then(|plan| plan.trial_started_at) + .map(|trial_started_at| trial_started_at.0) + } + pub fn has_accepted_tos(&self) -> bool { self.authenticated_user .as_ref() @@ -127,6 +136,22 @@ impl CloudUserStore { .unwrap_or_default() } + pub fn is_usage_based_billing_enabled(&self) -> bool { + self.plan_info + .as_ref() + .map(|plan| plan.is_usage_based_billing_enabled) + .unwrap_or_default() + } + + pub fn model_request_usage(&self) -> Option { + self.model_request_usage + } + + pub fn update_model_request_usage(&mut self, usage: ModelRequestUsage, cx: &mut Context) { + self.model_request_usage = Some(usage); + cx.notify(); + } + pub fn edit_prediction_usage(&self) -> Option { self.edit_prediction_usage } @@ -142,6 +167,10 @@ impl CloudUserStore { fn update_authenticated_user(&mut self, response: GetAuthenticatedUserResponse) { self.authenticated_user = Some(Arc::new(response.user)); + self.model_request_usage = Some(ModelRequestUsage(RequestUsage { + limit: response.plan.usage.model_requests.limit, + amount: response.plan.usage.model_requests.used as i32, + })); self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage { limit: response.plan.usage.edit_predictions.limit, amount: response.plan.usage.edit_predictions.used as i32, diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 84f30f3530..b8c2e3433b 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -113,7 +113,6 @@ pub struct UserStore { current_plan: Option, subscription_period: Option<(DateTime, DateTime)>, trial_started_at: Option>, - model_request_usage: Option, is_usage_based_billing_enabled: Option, account_too_young: Option, has_overdue_invoices: Option, @@ -191,7 +190,6 @@ impl UserStore { current_plan: None, subscription_period: None, trial_started_at: None, - model_request_usage: None, is_usage_based_billing_enabled: None, account_too_young: None, has_overdue_invoices: None, @@ -371,27 +369,12 @@ impl UserStore { this.account_too_young = message.payload.account_too_young; this.has_overdue_invoices = message.payload.has_overdue_invoices; - if let Some(usage) = message.payload.usage { - // limits are always present even though they are wrapped in Option - this.model_request_usage = usage - .model_requests_usage_limit - .and_then(|limit| { - RequestUsage::from_proto(usage.model_requests_usage_amount, limit) - }) - .map(ModelRequestUsage); - } - cx.emit(Event::PlanUpdated); cx.notify(); })?; Ok(()) } - pub fn update_model_request_usage(&mut self, usage: ModelRequestUsage, cx: &mut Context) { - self.model_request_usage = Some(usage); - cx.notify(); - } - fn update_contacts(&mut self, message: UpdateContacts, cx: &Context) -> Task> { match message { UpdateContacts::Wait(barrier) => { @@ -776,10 +759,6 @@ impl UserStore { self.is_usage_based_billing_enabled } - pub fn model_request_usage(&self) -> Option { - self.model_request_usage - } - pub fn watch_current_user(&self) -> watch::Receiver>> { self.current_user.clone() } diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index a02b4a7f0b..8d257a37a7 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -13,7 +13,7 @@ pub(crate) use tool_metrics::*; use ::fs::RealFs; use clap::Parser; -use client::{Client, ProxySettings, UserStore}; +use client::{Client, CloudUserStore, ProxySettings, UserStore}; use collections::{HashMap, HashSet}; use extension::ExtensionHostProxy; use futures::future; @@ -329,6 +329,7 @@ pub struct AgentAppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, + pub cloud_user_store: Entity, pub fs: Arc, pub node_runtime: NodeRuntime, @@ -383,6 +384,8 @@ pub fn init(cx: &mut App) -> Arc { let languages = Arc::new(languages); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + let cloud_user_store = + cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); extension::init(cx); @@ -422,7 +425,12 @@ pub fn init(cx: &mut App) -> Arc { languages.clone(), ); language_model::init(client.clone(), cx); - language_models::init(user_store.clone(), client.clone(), cx); + language_models::init( + user_store.clone(), + cloud_user_store.clone(), + client.clone(), + cx, + ); languages::init(languages.clone(), node_runtime.clone(), cx); prompt_store::init(cx); terminal_view::init(cx); @@ -447,6 +455,7 @@ pub fn init(cx: &mut App) -> Arc { languages, client, user_store, + cloud_user_store, fs, node_runtime, prompt_builder, diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 0f2b4c18ea..54d864ea21 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -221,6 +221,7 @@ impl ExampleInstance { let prompt_store = None; let thread_store = ThreadStore::load( project.clone(), + app_state.cloud_user_store.clone(), tools, prompt_store, app_state.prompt_builder.clone(), diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 18e6f47ed0..a88f12283a 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; -use client::{Client, UserStore}; +use client::{Client, CloudUserStore, UserStore}; use collections::HashSet; use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; @@ -26,11 +26,22 @@ use crate::provider::vercel::VercelLanguageModelProvider; use crate::provider::x_ai::XAiLanguageModelProvider; pub use crate::settings::*; -pub fn init(user_store: Entity, client: Arc, cx: &mut App) { +pub fn init( + user_store: Entity, + cloud_user_store: Entity, + client: Arc, + cx: &mut App, +) { crate::settings::init_settings(cx); let registry = LanguageModelRegistry::global(cx); registry.update(cx, |registry, cx| { - register_language_model_providers(registry, user_store, client.clone(), cx); + register_language_model_providers( + registry, + user_store, + cloud_user_store, + client.clone(), + cx, + ); }); let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx) @@ -100,11 +111,17 @@ fn register_openai_compatible_providers( fn register_language_model_providers( registry: &mut LanguageModelRegistry, user_store: Entity, + cloud_user_store: Entity, client: Arc, cx: &mut Context, ) { registry.register_provider( - CloudLanguageModelProvider::new(user_store.clone(), client.clone(), cx), + CloudLanguageModelProvider::new( + user_store.clone(), + cloud_user_store.clone(), + client.clone(), + cx, + ), cx, ); diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 3de135c5a2..a5de7f3442 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -2,11 +2,11 @@ use ai_onboarding::YoungAccountBanner; use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; -use client::{Client, ModelRequestUsage, UserStore, zed_urls}; +use client::{Client, CloudUserStore, ModelRequestUsage, UserStore, zed_urls}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CURRENT_PLAN_HEADER_NAME, CompletionBody, CompletionEvent, CompletionRequestStatus, CountTokensBody, CountTokensResponse, - EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE, + EXPIRED_LLM_TOKEN_HEADER_NAME, ListModelsResponse, MODEL_REQUESTS_RESOURCE_HEADER_VALUE, Plan, SERVER_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, SUBSCRIPTION_LIMIT_RESOURCE_HEADER_NAME, TOOL_USE_LIMIT_REACHED_HEADER_NAME, ZED_VERSION_HEADER_NAME, }; @@ -27,7 +27,6 @@ use language_model::{ LanguageModelToolChoice, LanguageModelToolSchemaFormat, LlmApiToken, ModelRequestLimitReachedError, PaymentRequiredError, RateLimiter, RefreshLlmTokenListener, }; -use proto::Plan; use release_channel::AppVersion; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -118,6 +117,7 @@ pub struct State { client: Arc, llm_api_token: LlmApiToken, user_store: Entity, + cloud_user_store: Entity, status: client::Status, accept_terms_of_service_task: Option>>, models: Vec>, @@ -133,6 +133,7 @@ impl State { fn new( client: Arc, user_store: Entity, + cloud_user_store: Entity, status: client::Status, cx: &mut Context, ) -> Self { @@ -142,6 +143,7 @@ impl State { client: client.clone(), llm_api_token: LlmApiToken::default(), user_store, + cloud_user_store, status, accept_terms_of_service_task: None, models: Vec::new(), @@ -150,12 +152,19 @@ impl State { recommended_models: Vec::new(), _fetch_models_task: cx.spawn(async move |this, cx| { maybe!(async move { - let (client, llm_api_token) = this - .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; + let (client, cloud_user_store, llm_api_token) = + this.read_with(cx, |this, _cx| { + ( + client.clone(), + this.cloud_user_store.clone(), + this.llm_api_token.clone(), + ) + })?; loop { - let status = this.read_with(cx, |this, _cx| this.status)?; - if matches!(status, client::Status::Connected { .. }) { + let is_authenticated = + cloud_user_store.read_with(cx, |this, _cx| this.is_authenticated())?; + if is_authenticated { break; } @@ -194,8 +203,8 @@ impl State { } } - fn is_signed_out(&self) -> bool { - self.status.is_signed_out() + fn is_signed_out(&self, cx: &App) -> bool { + !self.cloud_user_store.read(cx).is_authenticated() } fn authenticate(&self, cx: &mut Context) -> Task> { @@ -210,10 +219,7 @@ impl State { } fn has_accepted_terms_of_service(&self, cx: &App) -> bool { - self.user_store - .read(cx) - .current_user_has_accepted_terms() - .unwrap_or(false) + self.cloud_user_store.read(cx).has_accepted_tos() } fn accept_terms_of_service(&mut self, cx: &mut Context) { @@ -297,11 +303,24 @@ impl State { } impl CloudLanguageModelProvider { - pub fn new(user_store: Entity, client: Arc, cx: &mut App) -> Self { + pub fn new( + user_store: Entity, + cloud_user_store: Entity, + client: Arc, + cx: &mut App, + ) -> Self { let mut status_rx = client.status(); let status = *status_rx.borrow(); - let state = cx.new(|cx| State::new(client.clone(), user_store.clone(), status, cx)); + let state = cx.new(|cx| { + State::new( + client.clone(), + user_store.clone(), + cloud_user_store.clone(), + status, + cx, + ) + }); let state_ref = state.downgrade(); let maintain_client_status = cx.spawn(async move |cx| { @@ -398,7 +417,7 @@ impl LanguageModelProvider for CloudLanguageModelProvider { fn is_authenticated(&self, cx: &App) -> bool { let state = self.state.read(cx); - !state.is_signed_out() && state.has_accepted_terms_of_service(cx) + !state.is_signed_out(cx) && state.has_accepted_terms_of_service(cx) } fn authenticate(&self, _cx: &mut App) -> Task> { @@ -614,9 +633,9 @@ impl CloudLanguageModel { .and_then(|plan| cloud_llm_client::Plan::from_str(plan).ok()) { let plan = match plan { - cloud_llm_client::Plan::ZedFree => Plan::Free, - cloud_llm_client::Plan::ZedPro => Plan::ZedPro, - cloud_llm_client::Plan::ZedProTrial => Plan::ZedProTrial, + cloud_llm_client::Plan::ZedFree => proto::Plan::Free, + cloud_llm_client::Plan::ZedPro => proto::Plan::ZedPro, + cloud_llm_client::Plan::ZedProTrial => proto::Plan::ZedProTrial, }; return Err(anyhow!(ModelRequestLimitReachedError { plan })); } @@ -1118,7 +1137,7 @@ fn response_lines( #[derive(IntoElement, RegisterComponent)] struct ZedAiConfiguration { is_connected: bool, - plan: Option, + plan: Option, subscription_period: Option<(DateTime, DateTime)>, eligible_for_trial: bool, has_accepted_terms_of_service: bool, @@ -1132,15 +1151,15 @@ impl RenderOnce for ZedAiConfiguration { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { let young_account_banner = YoungAccountBanner; - let is_pro = self.plan == Some(proto::Plan::ZedPro); + let is_pro = self.plan == Some(Plan::ZedPro); let subscription_text = match (self.plan, self.subscription_period) { - (Some(proto::Plan::ZedPro), Some(_)) => { + (Some(Plan::ZedPro), Some(_)) => { "You have access to Zed's hosted models through your Pro subscription." } - (Some(proto::Plan::ZedProTrial), Some(_)) => { + (Some(Plan::ZedProTrial), Some(_)) => { "You have access to Zed's hosted models through your Pro trial." } - (Some(proto::Plan::Free), Some(_)) => { + (Some(Plan::ZedFree), Some(_)) => { "You have basic access to Zed's hosted models through the Free plan." } _ => { @@ -1262,15 +1281,15 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let state = self.state.read(cx); - let user_store = state.user_store.read(cx); + let cloud_user_store = state.cloud_user_store.read(cx); ZedAiConfiguration { - is_connected: !state.is_signed_out(), - plan: user_store.current_plan(), - subscription_period: user_store.subscription_period(), - eligible_for_trial: user_store.trial_started_at().is_none(), + is_connected: !state.is_signed_out(cx), + plan: cloud_user_store.plan(), + subscription_period: cloud_user_store.subscription_period(), + eligible_for_trial: cloud_user_store.trial_started_at().is_none(), has_accepted_terms_of_service: state.has_accepted_terms_of_service(cx), - account_too_young: user_store.account_too_young(), + account_too_young: cloud_user_store.account_too_young(), accept_terms_of_service_in_progress: state.accept_terms_of_service_task.is_some(), accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(), sign_in_callback: self.sign_in_callback.clone(), @@ -1286,7 +1305,7 @@ impl Component for ZedAiConfiguration { fn preview(_window: &mut Window, _cx: &mut App) -> Option { fn configuration( is_connected: bool, - plan: Option, + plan: Option, eligible_for_trial: bool, account_too_young: bool, has_accepted_terms_of_service: bool, @@ -1330,15 +1349,15 @@ impl Component for ZedAiConfiguration { ), single_example( "Free Plan", - configuration(true, Some(proto::Plan::Free), true, false, true), + configuration(true, Some(Plan::ZedFree), true, false, true), ), single_example( "Zed Pro Trial Plan", - configuration(true, Some(proto::Plan::ZedProTrial), true, false, true), + configuration(true, Some(Plan::ZedProTrial), true, false, true), ), single_example( "Zed Pro Plan", - configuration(true, Some(proto::Plan::ZedPro), true, false, true), + configuration(true, Some(Plan::ZedPro), true, false, true), ), ]) .into_any_element(), diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 219dc1e7ae..cc7061de1d 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -556,7 +556,12 @@ pub fn main() { ); supermaven::init(app_state.client.clone(), cx); language_model::init(app_state.client.clone(), cx); - language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_models::init( + app_state.user_store.clone(), + app_state.cloud_user_store.clone(), + app_state.client.clone(), + cx, + ); agent_settings::init(cx); agent_servers::init(cx); web_search::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c72fe39d2d..29aaf05669 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4487,7 +4487,12 @@ mod tests { ); image_viewer::init(cx); language_model::init(app_state.client.clone(), cx); - language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); + language_models::init( + app_state.user_store.clone(), + app_state.cloud_user_store.clone(), + app_state.client.clone(), + cx, + ); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx); diff --git a/crates/zed/src/zed/component_preview/preview_support/active_thread.rs b/crates/zed/src/zed/component_preview/preview_support/active_thread.rs index 825744572d..1076ee49ea 100644 --- a/crates/zed/src/zed/component_preview/preview_support/active_thread.rs +++ b/crates/zed/src/zed/component_preview/preview_support/active_thread.rs @@ -17,9 +17,10 @@ pub fn load_preview_thread_store( cx: &mut AsyncApp, ) -> Task>> { workspace - .update(cx, |_, cx| { + .update(cx, |workspace, cx| { ThreadStore::load( project.clone(), + workspace.app_state().cloud_user_store.clone(), cx.new(|_| ToolWorkingSet::default()), None, Arc::new(PromptBuilder::new(None).unwrap()), From d512ef1e567b589dadda23dc4759a1f34dbf41b8 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 22:08:21 -0400 Subject: [PATCH 33/56] ai_onboarding: Read the plan from the `CloudUserStore` (#35451) This PR updates the AI onboarding to read the plan from the `CloudUserStore` so that we don't need to connect to Collab. Release Notes: - N/A --- Cargo.lock | 1 + crates/agent_ui/src/agent_panel.rs | 1 + crates/ai_onboarding/Cargo.toml | 1 + .../src/agent_panel_onboarding_content.rs | 17 +++++++---------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ef5a8afd7..6a076edac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,7 @@ name = "ai_onboarding" version = "0.1.0" dependencies = [ "client", + "cloud_llm_client", "component", "gpui", "language_model", diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 5eabd107b9..f5d5797090 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -697,6 +697,7 @@ impl AgentPanel { let onboarding = cx.new(|cx| { AgentPanelOnboarding::new( user_store.clone(), + cloud_user_store.clone(), client, |_window, cx| { OnboardingUpsell::set_dismissed(true, cx); diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml index 9031e14e29..20fd54339e 100644 --- a/crates/ai_onboarding/Cargo.toml +++ b/crates/ai_onboarding/Cargo.toml @@ -16,6 +16,7 @@ default = [] [dependencies] client.workspace = true +cloud_llm_client.workspace = true component.workspace = true gpui.workspace = true language_model.workspace = true diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index e8a62f7ff2..237b0ae046 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -1,6 +1,7 @@ use std::sync::Arc; -use client::{Client, UserStore}; +use client::{Client, CloudUserStore, UserStore}; +use cloud_llm_client::Plan; use gpui::{Entity, IntoElement, ParentElement}; use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; use ui::prelude::*; @@ -9,6 +10,7 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding}; pub struct AgentPanelOnboarding { user_store: Entity, + cloud_user_store: Entity, client: Arc, configured_providers: Vec<(IconName, SharedString)>, continue_with_zed_ai: Arc, @@ -17,6 +19,7 @@ pub struct AgentPanelOnboarding { impl AgentPanelOnboarding { pub fn new( user_store: Entity, + cloud_user_store: Entity, client: Arc, continue_with_zed_ai: impl Fn(&mut Window, &mut App) + 'static, cx: &mut Context, @@ -36,6 +39,7 @@ impl AgentPanelOnboarding { Self { user_store, + cloud_user_store, client, configured_providers: Self::compute_available_providers(cx), continue_with_zed_ai: Arc::new(continue_with_zed_ai), @@ -56,15 +60,8 @@ impl AgentPanelOnboarding { impl Render for AgentPanelOnboarding { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let enrolled_in_trial = matches!( - self.user_store.read(cx).current_plan(), - Some(proto::Plan::ZedProTrial) - ); - - let is_pro_user = matches!( - self.user_store.read(cx).current_plan(), - Some(proto::Plan::ZedPro) - ); + let enrolled_in_trial = self.cloud_user_store.read(cx).plan() == Some(Plan::ZedProTrial); + let is_pro_user = self.cloud_user_store.read(cx).plan() == Some(Plan::ZedPro); AgentPanelOnboardingCard::new() .child( From ef4484e2ab39ca16c03a413c709d9f3882bea982 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 22:50:38 -0400 Subject: [PATCH 34/56] cloud_api_client: Add `accept_terms_of_service` method (#35452) This PR adds an `accept_terms_of_service` method to the `CloudApiClient`. Release Notes: - N/A --- .../cloud_api_client/src/cloud_api_client.rs | 68 ++++++++++++++----- crates/cloud_api_types/src/cloud_api_types.rs | 5 ++ 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 5a768810c0..6689475dae 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::{Result, anyhow}; pub use cloud_api_types::*; use futures::AsyncReadExt as _; +use http_client::http::request; use http_client::{AsyncBody, HttpClientWithUrl, Method, Request}; use parking_lot::RwLock; @@ -51,17 +52,26 @@ impl CloudApiClient { )) } + fn build_request( + &self, + req: request::Builder, + body: impl Into, + ) -> Result> { + Ok(req + .header("Content-Type", "application/json") + .header("Authorization", self.authorization_header()?) + .body(body.into())?) + } + pub async fn get_authenticated_user(&self) -> Result { - let request = Request::builder() - .method(Method::GET) - .uri( + let request = self.build_request( + Request::builder().method(Method::GET).uri( self.http_client .build_zed_cloud_url("/client/users/me", &[])? .as_ref(), - ) - .header("Content-Type", "application/json") - .header("Authorization", self.authorization_header()?) - .body(AsyncBody::default())?; + ), + AsyncBody::default(), + )?; let mut response = self.http_client.send(request).await?; @@ -81,25 +91,49 @@ impl CloudApiClient { Ok(serde_json::from_str(&body)?) } + pub async fn accept_terms_of_service(&self) -> Result { + let request = self.build_request( + Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/terms_of_service/accept", &[])? + .as_ref(), + ), + AsyncBody::default(), + )?; + + let mut response = self.http_client.send(request).await?; + + if !response.status().is_success() { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + anyhow::bail!( + "Failed to accept terms of service.\nStatus: {:?}\nBody: {body}", + response.status() + ) + } + + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + + Ok(serde_json::from_str(&body)?) + } + pub async fn create_llm_token( &self, system_id: Option, ) -> Result { - let mut request_builder = Request::builder() - .method(Method::POST) - .uri( - self.http_client - .build_zed_cloud_url("/client/llm_tokens", &[])? - .as_ref(), - ) - .header("Content-Type", "application/json") - .header("Authorization", self.authorization_header()?); + let mut request_builder = Request::builder().method(Method::POST).uri( + self.http_client + .build_zed_cloud_url("/client/llm_tokens", &[])? + .as_ref(), + ); if let Some(system_id) = system_id { request_builder = request_builder.header(ZED_SYSTEM_ID_HEADER_NAME, system_id); } - let request = request_builder.body(AsyncBody::default())?; + let request = self.build_request(request_builder, AsyncBody::default())?; let mut response = self.http_client.send(request).await?; diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index e4d4a27af5..b38b38cde1 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -41,6 +41,11 @@ pub struct SubscriptionPeriod { pub ended_at: Timestamp, } +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct AcceptTermsOfServiceResponse { + pub user: AuthenticatedUser, +} + #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct LlmToken(pub String); From 8484dca9030c14096fe80276628ec8343cd7290b Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 23:10:16 -0400 Subject: [PATCH 35/56] client: Remove unused `subscription_period` from `UserStore` (#35454) This PR removes the `subscription_period` field from the `UserStore`, as its usage has been replaced by the `CloudUserStore`. Release Notes: - N/A --- crates/client/src/user.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index b8c2e3433b..d528507025 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -20,7 +20,7 @@ use std::{ sync::{Arc, Weak}, }; use text::ReplicaId; -use util::{TryFutureExt as _, maybe}; +use util::TryFutureExt as _; pub type UserId = u64; @@ -111,7 +111,6 @@ pub struct UserStore { participant_indices: HashMap, update_contacts_tx: mpsc::UnboundedSender, current_plan: Option, - subscription_period: Option<(DateTime, DateTime)>, trial_started_at: Option>, is_usage_based_billing_enabled: Option, account_too_young: Option, @@ -188,7 +187,6 @@ impl UserStore { by_github_login: Default::default(), current_user: current_user_rx, current_plan: None, - subscription_period: None, trial_started_at: None, is_usage_based_billing_enabled: None, account_too_young: None, @@ -354,13 +352,6 @@ impl UserStore { ) -> Result<()> { this.update(&mut cx, |this, cx| { this.current_plan = Some(message.payload.plan()); - this.subscription_period = maybe!({ - let period = message.payload.subscription_period?; - let started_at = DateTime::from_timestamp(period.started_at as i64, 0)?; - let ended_at = DateTime::from_timestamp(period.ended_at as i64, 0)?; - - Some((started_at, ended_at)) - }); this.trial_started_at = message .payload .trial_started_at @@ -747,10 +738,6 @@ impl UserStore { self.current_plan } - pub fn subscription_period(&self) -> Option<(DateTime, DateTime)> { - self.subscription_period - } - pub fn trial_started_at(&self) -> Option> { self.trial_started_at } From 604fd98c1d130d29a5c4f32a7f5cb1bc161ce044 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 31 Jul 2025 23:25:23 -0400 Subject: [PATCH 36/56] inline_completion_button: Replace `UserStore` with `CloudUserStore` (#35456) This PR replaces usages of the `UserStore` in the inline completion button with the `CloudUserStore`. Release Notes: - N/A --- crates/client/src/user.rs | 8 ----- .../src/inline_completion_button.rs | 29 ++++++++++--------- crates/zed/src/zed.rs | 2 +- crates/zeta/src/zeta.rs | 12 ++++---- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index d528507025..b8219f6169 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -114,7 +114,6 @@ pub struct UserStore { trial_started_at: Option>, is_usage_based_billing_enabled: Option, account_too_young: Option, - has_overdue_invoices: Option, current_user: watch::Receiver>>, accepted_tos_at: Option>>, contacts: Vec>, @@ -190,7 +189,6 @@ impl UserStore { trial_started_at: None, is_usage_based_billing_enabled: None, account_too_young: None, - has_overdue_invoices: None, accepted_tos_at: None, contacts: Default::default(), incoming_contact_requests: Default::default(), @@ -358,7 +356,6 @@ impl UserStore { .and_then(|trial_started_at| DateTime::from_timestamp(trial_started_at as i64, 0)); this.is_usage_based_billing_enabled = message.payload.is_usage_based_billing_enabled; this.account_too_young = message.payload.account_too_young; - this.has_overdue_invoices = message.payload.has_overdue_invoices; cx.emit(Event::PlanUpdated); cx.notify(); @@ -755,11 +752,6 @@ impl UserStore { self.account_too_young.unwrap_or(false) } - /// Returns whether the current user has overdue invoices and usage should be blocked. - pub fn has_overdue_invoices(&self) -> bool { - self.has_overdue_invoices.unwrap_or(false) - } - pub fn current_user_has_accepted_terms(&self) -> Option { self.accepted_tos_at .map(|accepted_tos_at| accepted_tos_at.is_some()) diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index a9a5ae5a49..d23cd7b9f9 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use client::{UserStore, zed_urls}; +use client::{CloudUserStore, zed_urls}; use cloud_llm_client::UsageLimit; use copilot::{Copilot, Status}; use editor::{ @@ -60,7 +60,7 @@ pub struct InlineCompletionButton { file: Option>, edit_prediction_provider: Option>, fs: Arc, - user_store: Entity, + cloud_user_store: Entity, popover_menu_handle: PopoverMenuHandle, } @@ -246,13 +246,16 @@ impl Render for InlineCompletionButton { IconName::ZedPredictDisabled }; - if zeta::should_show_upsell_modal(&self.user_store, cx) { - let tooltip_meta = - match self.user_store.read(cx).current_user_has_accepted_terms() { - Some(true) => "Choose a Plan", - Some(false) => "Accept the Terms of Service", - None => "Sign In", - }; + if zeta::should_show_upsell_modal(&self.cloud_user_store, cx) { + let tooltip_meta = if self.cloud_user_store.read(cx).is_authenticated() { + if self.cloud_user_store.read(cx).has_accepted_tos() { + "Choose a Plan" + } else { + "Accept the Terms of Service" + } + } else { + "Sign In" + }; return div().child( IconButton::new("zed-predict-pending-button", zeta_icon) @@ -369,7 +372,7 @@ impl Render for InlineCompletionButton { impl InlineCompletionButton { pub fn new( fs: Arc, - user_store: Entity, + cloud_user_store: Entity, popover_menu_handle: PopoverMenuHandle, cx: &mut Context, ) -> Self { @@ -390,7 +393,7 @@ impl InlineCompletionButton { edit_prediction_provider: None, popover_menu_handle, fs, - user_store, + cloud_user_store, } } @@ -761,7 +764,7 @@ impl InlineCompletionButton { }) }) .separator(); - } else if self.user_store.read(cx).account_too_young() { + } else if self.cloud_user_store.read(cx).account_too_young() { menu = menu .custom_entry( |_window, _cx| { @@ -776,7 +779,7 @@ impl InlineCompletionButton { cx.open_url(&zed_urls::account_url(cx)) }) .separator(); - } else if self.user_store.read(cx).has_overdue_invoices() { + } else if self.cloud_user_store.read(cx).has_overdue_invoices() { menu = menu .custom_entry( |_window, _cx| { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 29aaf05669..91613049f4 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -336,7 +336,7 @@ pub fn initialize_workspace( let edit_prediction_button = cx.new(|cx| { inline_completion_button::InlineCompletionButton::new( app_state.fs.clone(), - app_state.user_store.clone(), + app_state.cloud_user_store.clone(), inline_completion_menu_handle.clone(), cx, ) diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index d295b7d17c..0ef6bef59d 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -16,7 +16,7 @@ pub use rate_completion_modal::*; use anyhow::{Context as _, Result, anyhow}; use arrayvec::ArrayVec; -use client::{Client, CloudUserStore, EditPredictionUsage, UserStore}; +use client::{Client, CloudUserStore, EditPredictionUsage}; use cloud_llm_client::{ AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsBody, PredictEditsResponse, ZED_VERSION_HEADER_NAME, @@ -120,10 +120,11 @@ impl Dismissable for ZedPredictUpsell { } } -pub fn should_show_upsell_modal(user_store: &Entity, cx: &App) -> bool { - match user_store.read(cx).current_user_has_accepted_terms() { - Some(true) => !ZedPredictUpsell::dismissed(), - Some(false) | None => true, +pub fn should_show_upsell_modal(cloud_user_store: &Entity, cx: &App) -> bool { + if cloud_user_store.read(cx).has_accepted_tos() { + !ZedPredictUpsell::dismissed() + } else { + true } } @@ -1804,6 +1805,7 @@ fn tokens_for_bytes(bytes: usize) -> usize { #[cfg(test)] mod tests { + use client::UserStore; use client::test::FakeServer; use clock::FakeSystemClock; use cloud_api_types::{ From 3dc1c884698fec386c24a85fa0295082f71fbdc3 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Fri, 1 Aug 2025 19:37:38 +0200 Subject: [PATCH 37/56] Start separating authentication from connection to collab (#35471) This pull request should be idempotent, but lays the groundwork for avoiding to connect to collab in order to interact with AI features provided by Zed. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers Co-authored-by: Richard Feldman --- Cargo.lock | 24 +- crates/agent/Cargo.toml | 1 - crates/agent/src/thread.rs | 38 ++- crates/agent/src/thread_store.rs | 18 +- crates/agent_ui/src/active_thread.rs | 7 - crates/agent_ui/src/agent_configuration.rs | 6 +- crates/agent_ui/src/agent_diff.rs | 13 - crates/agent_ui/src/agent_panel.rs | 23 +- crates/agent_ui/src/message_editor.rs | 16 +- crates/ai_onboarding/Cargo.toml | 1 - .../src/agent_panel_onboarding_content.rs | 9 +- crates/ai_onboarding/src/ai_onboarding.rs | 37 +-- crates/ai_onboarding/src/ai_upsell_card.rs | 6 +- .../assistant_tools/src/edit_agent/evals.rs | 6 +- crates/channel/src/channel_store_tests.rs | 18 +- crates/client/Cargo.toml | 1 - crates/client/src/client.rs | 203 ++++++++++------ crates/client/src/cloud.rs | 3 - crates/client/src/cloud/user_store.rs | 211 ---------------- crates/client/src/test.rs | 94 ++++++- crates/client/src/user.rs | 230 +++++++++++------- crates/collab/src/tests/integration_tests.rs | 10 +- crates/collab/src/tests/notification_tests.rs | 4 + crates/collab/src/tests/test_server.rs | 59 ++++- crates/collab_ui/src/collab_panel.rs | 2 +- crates/collab_ui/src/notification_panel.rs | 11 +- crates/eval/src/eval.rs | 13 +- crates/eval/src/instance.rs | 1 - crates/http_client/Cargo.toml | 1 + crates/http_client/src/http_client.rs | 71 ++++-- .../src/inline_completion_button.rs | 18 +- .../language_model/src/model/cloud_model.rs | 5 +- crates/language_models/Cargo.toml | 1 - crates/language_models/src/language_models.rs | 25 +- crates/language_models/src/provider/cloud.rs | 63 ++--- crates/project/src/project.rs | 5 +- crates/title_bar/src/title_bar.rs | 26 +- crates/workspace/src/workspace.rs | 14 +- crates/zed/src/main.rs | 48 +--- crates/zed/src/zed.rs | 9 +- crates/zed/src/zed/component_preview.rs | 3 +- .../preview_support/active_thread.rs | 23 +- .../zed/src/zed/inline_completion_registry.rs | 28 +-- crates/zeta/src/zeta.rs | 94 ++----- 44 files changed, 650 insertions(+), 849 deletions(-) delete mode 100644 crates/client/src/cloud.rs delete mode 100644 crates/client/src/cloud/user_store.rs diff --git a/Cargo.lock b/Cargo.lock index 6a076edac3..83703cbe18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,7 +114,6 @@ dependencies = [ "pretty_assertions", "project", "prompt_store", - "proto", "rand 0.8.5", "ref-cast", "rope", @@ -359,7 +358,6 @@ dependencies = [ "component", "gpui", "language_model", - "proto", "serde", "smallvec", "telemetry", @@ -1076,17 +1074,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "async-recursion" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d78656ba01f1b93024b7c3a0467f1608e4be67d725749fdcd7d2c7678fd7a2" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "async-recursion" version = "1.1.1" @@ -2972,7 +2959,6 @@ name = "client" version = "0.1.0" dependencies = [ "anyhow", - "async-recursion 0.3.2", "async-tungstenite", "base64 0.22.1", "chrono", @@ -7887,6 +7873,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "log", + "parking_lot", "serde", "serde_json", "url", @@ -9153,7 +9140,6 @@ dependencies = [ "open_router", "partial-json-fixer", "project", - "proto", "release_channel", "schemars", "serde", @@ -9891,7 +9877,7 @@ name = "markdown_preview" version = "0.1.0" dependencies = [ "anyhow", - "async-recursion 1.1.1", + "async-recursion", "collections", "editor", "fs", @@ -16215,7 +16201,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assistant_slash_command", - "async-recursion 1.1.1", + "async-recursion", "breadcrumbs", "client", "collections", @@ -19643,7 +19629,7 @@ version = "0.1.0" dependencies = [ "any_vec", "anyhow", - "async-recursion 1.1.1", + "async-recursion", "bincode", "call", "client", @@ -20168,7 +20154,7 @@ dependencies = [ "async-io", "async-lock", "async-process", - "async-recursion 1.1.1", + "async-recursion", "async-task", "async-trait", "blocking", diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index c89a7f3303..7bc0e82cad 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -47,7 +47,6 @@ paths.workspace = true postage.workspace = true project.workspace = true prompt_store.workspace = true -proto.workspace = true ref-cast.workspace = true rope.workspace = true schemars.workspace = true diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index ee16f83dc4..8558dd528d 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -12,8 +12,8 @@ use agent_settings::{AgentProfileId, AgentSettings, CompletionMode}; use anyhow::{Result, anyhow}; use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolWorkingSet}; use chrono::{DateTime, Utc}; -use client::{CloudUserStore, ModelRequestUsage, RequestUsage}; -use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; +use client::{ModelRequestUsage, RequestUsage}; +use cloud_llm_client::{CompletionIntent, CompletionRequestStatus, Plan, UsageLimit}; use collections::HashMap; use feature_flags::{self, FeatureFlagAppExt}; use futures::{FutureExt, StreamExt as _, future::Shared}; @@ -37,7 +37,6 @@ use project::{ git_store::{GitStore, GitStoreCheckpoint, RepositoryState}, }; use prompt_store::{ModelContext, PromptBuilder}; -use proto::Plan; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -374,7 +373,6 @@ pub struct Thread { completion_count: usize, pending_completions: Vec, project: Entity, - cloud_user_store: Entity, prompt_builder: Arc, tools: Entity, tool_use: ToolUseState, @@ -445,7 +443,6 @@ pub struct ExceededWindowError { impl Thread { pub fn new( project: Entity, - cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, system_prompt: SharedProjectContext, @@ -472,7 +469,6 @@ impl Thread { completion_count: 0, pending_completions: Vec::new(), project: project.clone(), - cloud_user_store, prompt_builder, tools: tools.clone(), last_restore_checkpoint: None, @@ -506,7 +502,6 @@ impl Thread { id: ThreadId, serialized: SerializedThread, project: Entity, - cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, project_context: SharedProjectContext, @@ -607,7 +602,6 @@ impl Thread { last_restore_checkpoint: None, pending_checkpoint: None, project: project.clone(), - cloud_user_store, prompt_builder, tools: tools.clone(), tool_use, @@ -3260,15 +3254,18 @@ impl Thread { } fn update_model_request_usage(&self, amount: u32, limit: UsageLimit, cx: &mut Context) { - self.cloud_user_store.update(cx, |cloud_user_store, cx| { - cloud_user_store.update_model_request_usage( - ModelRequestUsage(RequestUsage { - amount: amount as i32, - limit, - }), - cx, - ) - }); + self.project + .read(cx) + .user_store() + .update(cx, |user_store, cx| { + user_store.update_model_request_usage( + ModelRequestUsage(RequestUsage { + amount: amount as i32, + limit, + }), + cx, + ) + }); } pub fn deny_tool_use( @@ -3886,7 +3883,6 @@ fn main() {{ thread.id.clone(), serialized, thread.project.clone(), - thread.cloud_user_store.clone(), thread.tools.clone(), thread.prompt_builder.clone(), thread.project_context.clone(), @@ -5483,16 +5479,10 @@ fn main() {{ let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let (client, user_store) = - project.read_with(cx, |project, _cx| (project.client(), project.user_store())); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); - let thread_store = cx .update(|_, cx| { ThreadStore::load( project.clone(), - cloud_user_store, cx.new(|_| ToolWorkingSet::default()), None, Arc::new(PromptBuilder::new(None).unwrap()), diff --git a/crates/agent/src/thread_store.rs b/crates/agent/src/thread_store.rs index 6efa56f233..cc7cb50c91 100644 --- a/crates/agent/src/thread_store.rs +++ b/crates/agent/src/thread_store.rs @@ -8,7 +8,6 @@ use agent_settings::{AgentProfileId, CompletionMode}; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::{Tool, ToolId, ToolWorkingSet}; use chrono::{DateTime, Utc}; -use client::CloudUserStore; use collections::HashMap; use context_server::ContextServerId; use futures::{ @@ -105,7 +104,6 @@ pub type TextThreadStore = assistant_context::ContextStore; pub struct ThreadStore { project: Entity, - cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, prompt_store: Option>, @@ -126,7 +124,6 @@ impl EventEmitter for ThreadStore {} impl ThreadStore { pub fn load( project: Entity, - cloud_user_store: Entity, tools: Entity, prompt_store: Option>, prompt_builder: Arc, @@ -136,14 +133,8 @@ impl ThreadStore { let (thread_store, ready_rx) = cx.update(|cx| { let mut option_ready_rx = None; let thread_store = cx.new(|cx| { - let (thread_store, ready_rx) = Self::new( - project, - cloud_user_store, - tools, - prompt_builder, - prompt_store, - cx, - ); + let (thread_store, ready_rx) = + Self::new(project, tools, prompt_builder, prompt_store, cx); option_ready_rx = Some(ready_rx); thread_store }); @@ -156,7 +147,6 @@ impl ThreadStore { fn new( project: Entity, - cloud_user_store: Entity, tools: Entity, prompt_builder: Arc, prompt_store: Option>, @@ -200,7 +190,6 @@ impl ThreadStore { let this = Self { project, - cloud_user_store, tools, prompt_builder, prompt_store, @@ -418,7 +407,6 @@ impl ThreadStore { cx.new(|cx| { Thread::new( self.project.clone(), - self.cloud_user_store.clone(), self.tools.clone(), self.prompt_builder.clone(), self.project_context.clone(), @@ -437,7 +425,6 @@ impl ThreadStore { ThreadId::new(), serialized, self.project.clone(), - self.cloud_user_store.clone(), self.tools.clone(), self.prompt_builder.clone(), self.project_context.clone(), @@ -469,7 +456,6 @@ impl ThreadStore { id.clone(), thread, this.project.clone(), - this.cloud_user_store.clone(), this.tools.clone(), this.prompt_builder.clone(), this.project_context.clone(), diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index 1669c24a1b..04a093c7d0 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -3820,7 +3820,6 @@ mod tests { use super::*; use agent::{MessageSegment, context::ContextLoadResult, thread_store}; use assistant_tool::{ToolRegistry, ToolWorkingSet}; - use client::CloudUserStore; use editor::EditorSettings; use fs::FakeFs; use gpui::{AppContext, TestAppContext, VisualTestContext}; @@ -4117,16 +4116,10 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let (client, user_store) = - project.read_with(cx, |project, _cx| (project.client(), project.user_store())); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); - let thread_store = cx .update(|_, cx| { ThreadStore::load( project.clone(), - cloud_user_store, cx.new(|_| ToolWorkingSet::default()), None, Arc::new(PromptBuilder::new(None).unwrap()), diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index fae04188eb..ad19d39b53 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -7,6 +7,7 @@ use std::{sync::Arc, time::Duration}; use agent_settings::AgentSettings; use assistant_tool::{ToolSource, ToolWorkingSet}; +use cloud_llm_client::Plan; use collections::HashMap; use context_server::ContextServerId; use extension::ExtensionManifest; @@ -25,7 +26,6 @@ use project::{ context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, project_settings::{ContextServerSettings, ProjectSettings}, }; -use proto::Plan; use settings::{Settings, update_settings_file}; use ui::{ Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, @@ -180,7 +180,7 @@ impl AgentConfiguration { let current_plan = if is_zed_provider { self.workspace .upgrade() - .and_then(|workspace| workspace.read(cx).user_store().read(cx).current_plan()) + .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan()) } else { None }; @@ -502,7 +502,7 @@ impl AgentConfiguration { .blend(cx.theme().colors().text_accent.opacity(0.2)); let (plan_name, label_color, bg_color) = match plan { - Plan::Free => ("Free", Color::Default, free_chip_bg), + Plan::ZedFree => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), }; diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 5c8011cb18..ec0a11f86b 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1893,7 +1893,6 @@ mod tests { use agent::thread_store::{self, ThreadStore}; use agent_settings::AgentSettings; use assistant_tool::ToolWorkingSet; - use client::CloudUserStore; use editor::EditorSettings; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{FakeFs, Project}; @@ -1933,17 +1932,11 @@ mod tests { }) .unwrap(); - let (client, user_store) = - project.read_with(cx, |project, _cx| (project.client(), project.user_store())); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); - let prompt_store = None; let thread_store = cx .update(|cx| { ThreadStore::load( project.clone(), - cloud_user_store, cx.new(|_| ToolWorkingSet::default()), prompt_store, Arc::new(PromptBuilder::new(None).unwrap()), @@ -2105,17 +2098,11 @@ mod tests { }) .unwrap(); - let (client, user_store) = - project.read_with(cx, |project, _cx| (project.client(), project.user_store())); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store, cx)); - let prompt_store = None; let thread_store = cx .update(|cx| { ThreadStore::load( project.clone(), - cloud_user_store, cx.new(|_| ToolWorkingSet::default()), prompt_store, Arc::new(PromptBuilder::new(None).unwrap()), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f5d5797090..78c32e2af7 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -43,8 +43,8 @@ use anyhow::{Result, anyhow}; use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; -use client::{CloudUserStore, UserStore, zed_urls}; -use cloud_llm_client::{CompletionIntent, UsageLimit}; +use client::{ UserStore, zed_urls}; +use cloud_llm_client::{CompletionIntent, Plan, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use feature_flags::{self, FeatureFlagAppExt}; use fs::Fs; @@ -60,7 +60,6 @@ use language_model::{ }; use project::{DisableAiSettings, Project, ProjectPath, Worktree}; use prompt_store::{PromptBuilder, PromptStore, UserPromptId}; -use proto::Plan; use rules_library::{RulesLibrary, open_rules_library}; use search::{BufferSearchBar, buffer_search}; use settings::{Settings, update_settings_file}; @@ -427,7 +426,6 @@ impl ActiveView { pub struct AgentPanel { workspace: WeakEntity, user_store: Entity, - cloud_user_store: Entity, project: Entity, fs: Arc, language_registry: Arc, @@ -487,7 +485,6 @@ impl AgentPanel { let project = workspace.project().clone(); ThreadStore::load( project, - workspace.app_state().cloud_user_store.clone(), tools.clone(), prompt_store.clone(), prompt_builder.clone(), @@ -555,7 +552,6 @@ impl AgentPanel { let thread = thread_store.update(cx, |this, cx| this.create_thread(cx)); let fs = workspace.app_state().fs.clone(); let user_store = workspace.app_state().user_store.clone(); - let cloud_user_store = workspace.app_state().cloud_user_store.clone(); let project = workspace.project(); let language_registry = project.read(cx).languages().clone(); let client = workspace.client().clone(); @@ -582,7 +578,6 @@ impl AgentPanel { MessageEditor::new( fs.clone(), workspace.clone(), - cloud_user_store.clone(), message_editor_context_store.clone(), prompt_store.clone(), thread_store.downgrade(), @@ -697,7 +692,6 @@ impl AgentPanel { let onboarding = cx.new(|cx| { AgentPanelOnboarding::new( user_store.clone(), - cloud_user_store.clone(), client, |_window, cx| { OnboardingUpsell::set_dismissed(true, cx); @@ -710,7 +704,6 @@ impl AgentPanel { active_view, workspace, user_store, - cloud_user_store, project: project.clone(), fs: fs.clone(), language_registry, @@ -853,7 +846,6 @@ impl AgentPanel { MessageEditor::new( self.fs.clone(), self.workspace.clone(), - self.cloud_user_store.clone(), context_store.clone(), self.prompt_store.clone(), self.thread_store.downgrade(), @@ -1127,7 +1119,6 @@ impl AgentPanel { MessageEditor::new( self.fs.clone(), self.workspace.clone(), - self.cloud_user_store.clone(), context_store, self.prompt_store.clone(), self.thread_store.downgrade(), @@ -1826,8 +1817,8 @@ impl AgentPanel { } fn render_toolbar(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let cloud_user_store = self.cloud_user_store.read(cx); - let usage = cloud_user_store.model_request_usage(); + let user_store = self.user_store.read(cx); + let usage = user_store.model_request_usage(); let account_url = zed_urls::account_url(cx); @@ -2298,10 +2289,10 @@ impl AgentPanel { | ActiveView::Configuration => return false, } - let plan = self.user_store.read(cx).current_plan(); + let plan = self.user_store.read(cx).plan(); let has_previous_trial = self.user_store.read(cx).trial_started_at().is_some(); - matches!(plan, Some(Plan::Free)) && has_previous_trial + matches!(plan, Some(Plan::ZedFree)) && has_previous_trial } fn should_render_onboarding(&self, cx: &mut Context) -> bool { @@ -2916,7 +2907,7 @@ impl AgentPanel { ) -> AnyElement { let error_message = match plan { Plan::ZedPro => "Upgrade to usage-based billing for more prompts.", - Plan::ZedProTrial | Plan::Free => "Upgrade to Zed Pro for more prompts.", + Plan::ZedProTrial | Plan::ZedFree => "Upgrade to Zed Pro for more prompts.", }; let icon = Icon::new(IconName::XCircle) diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index e00a0087eb..2185885347 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -17,7 +17,6 @@ use agent::{ use agent_settings::{AgentSettings, CompletionMode}; use ai_onboarding::ApiKeysWithProviders; use buffer_diff::BufferDiff; -use client::CloudUserStore; use cloud_llm_client::CompletionIntent; use collections::{HashMap, HashSet}; use editor::actions::{MoveUp, Paste}; @@ -78,7 +77,6 @@ pub struct MessageEditor { editor: Entity, workspace: WeakEntity, project: Entity, - cloud_user_store: Entity, context_store: Entity, prompt_store: Option>, history_store: Option>, @@ -158,7 +156,6 @@ impl MessageEditor { pub fn new( fs: Arc, workspace: WeakEntity, - cloud_user_store: Entity, context_store: Entity, prompt_store: Option>, thread_store: WeakEntity, @@ -230,7 +227,6 @@ impl MessageEditor { Self { editor: editor.clone(), project: thread.read(cx).project().clone(), - cloud_user_store, thread, incompatible_tools_state: incompatible_tools.clone(), workspace, @@ -1286,16 +1282,14 @@ impl MessageEditor { return None; } - let cloud_user_store = self.cloud_user_store.read(cx); - if cloud_user_store.is_usage_based_billing_enabled() { + let user_store = self.project.read(cx).user_store().read(cx); + if user_store.is_usage_based_billing_enabled() { return None; } - let plan = cloud_user_store - .plan() - .unwrap_or(cloud_llm_client::Plan::ZedFree); + let plan = user_store.plan().unwrap_or(cloud_llm_client::Plan::ZedFree); - let usage = cloud_user_store.model_request_usage()?; + let usage = user_store.model_request_usage()?; Some( div() @@ -1758,7 +1752,6 @@ impl AgentPreview for MessageEditor { ) -> Option { if let Some(workspace) = workspace.upgrade() { let fs = workspace.read(cx).app_state().fs.clone(); - let cloud_user_store = workspace.read(cx).app_state().cloud_user_store.clone(); let project = workspace.read(cx).project().clone(); let weak_project = project.downgrade(); let context_store = cx.new(|_cx| ContextStore::new(weak_project, None)); @@ -1771,7 +1764,6 @@ impl AgentPreview for MessageEditor { MessageEditor::new( fs, workspace.downgrade(), - cloud_user_store, context_store, None, thread_store.downgrade(), diff --git a/crates/ai_onboarding/Cargo.toml b/crates/ai_onboarding/Cargo.toml index 20fd54339e..95a45b1a6f 100644 --- a/crates/ai_onboarding/Cargo.toml +++ b/crates/ai_onboarding/Cargo.toml @@ -20,7 +20,6 @@ cloud_llm_client.workspace = true component.workspace = true gpui.workspace = true language_model.workspace = true -proto.workspace = true serde.workspace = true smallvec.workspace = true telemetry.workspace = true diff --git a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs index 237b0ae046..f1629eeff8 100644 --- a/crates/ai_onboarding/src/agent_panel_onboarding_content.rs +++ b/crates/ai_onboarding/src/agent_panel_onboarding_content.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use client::{Client, CloudUserStore, UserStore}; +use client::{Client, UserStore}; use cloud_llm_client::Plan; use gpui::{Entity, IntoElement, ParentElement}; use language_model::{LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID}; @@ -10,7 +10,6 @@ use crate::{AgentPanelOnboardingCard, ApiKeysWithoutProviders, ZedAiOnboarding}; pub struct AgentPanelOnboarding { user_store: Entity, - cloud_user_store: Entity, client: Arc, configured_providers: Vec<(IconName, SharedString)>, continue_with_zed_ai: Arc, @@ -19,7 +18,6 @@ pub struct AgentPanelOnboarding { impl AgentPanelOnboarding { pub fn new( user_store: Entity, - cloud_user_store: Entity, client: Arc, continue_with_zed_ai: impl Fn(&mut Window, &mut App) + 'static, cx: &mut Context, @@ -39,7 +37,6 @@ impl AgentPanelOnboarding { Self { user_store, - cloud_user_store, client, configured_providers: Self::compute_available_providers(cx), continue_with_zed_ai: Arc::new(continue_with_zed_ai), @@ -60,8 +57,8 @@ impl AgentPanelOnboarding { impl Render for AgentPanelOnboarding { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let enrolled_in_trial = self.cloud_user_store.read(cx).plan() == Some(Plan::ZedProTrial); - let is_pro_user = self.cloud_user_store.read(cx).plan() == Some(Plan::ZedPro); + let enrolled_in_trial = self.user_store.read(cx).plan() == Some(Plan::ZedProTrial); + let is_pro_user = self.user_store.read(cx).plan() == Some(Plan::ZedPro); AgentPanelOnboardingCard::new() .child( diff --git a/crates/ai_onboarding/src/ai_onboarding.rs b/crates/ai_onboarding/src/ai_onboarding.rs index 3aec9c62cd..c252b65f20 100644 --- a/crates/ai_onboarding/src/ai_onboarding.rs +++ b/crates/ai_onboarding/src/ai_onboarding.rs @@ -9,6 +9,7 @@ pub use agent_api_keys_onboarding::{ApiKeysWithProviders, ApiKeysWithoutProvider pub use agent_panel_onboarding_card::AgentPanelOnboardingCard; pub use agent_panel_onboarding_content::AgentPanelOnboarding; pub use ai_upsell_card::AiUpsellCard; +use cloud_llm_client::Plan; pub use edit_prediction_onboarding_content::EditPredictionOnboarding; pub use young_account_banner::YoungAccountBanner; @@ -79,7 +80,7 @@ impl From for SignInStatus { pub struct ZedAiOnboarding { pub sign_in_status: SignInStatus, pub has_accepted_terms_of_service: bool, - pub plan: Option, + pub plan: Option, pub account_too_young: bool, pub continue_with_zed_ai: Arc, pub sign_in: Arc, @@ -99,8 +100,8 @@ impl ZedAiOnboarding { Self { sign_in_status: status.into(), - has_accepted_terms_of_service: store.current_user_has_accepted_terms().unwrap_or(false), - plan: store.current_plan(), + has_accepted_terms_of_service: store.has_accepted_terms_of_service(), + plan: store.plan(), account_too_young: store.account_too_young(), continue_with_zed_ai, accept_terms_of_service: Arc::new({ @@ -113,11 +114,9 @@ impl ZedAiOnboarding { sign_in: Arc::new(move |_window, cx| { cx.spawn({ let client = client.clone(); - async move |cx| { - client.authenticate_and_connect(true, cx).await; - } + async move |cx| client.sign_in_with_optional_connect(true, cx).await }) - .detach(); + .detach_and_log_err(cx); }), dismiss_onboarding: None, } @@ -411,9 +410,9 @@ impl RenderOnce for ZedAiOnboarding { if matches!(self.sign_in_status, SignInStatus::SignedIn) { if self.has_accepted_terms_of_service { match self.plan { - None | Some(proto::Plan::Free) => self.render_free_plan_state(cx), - Some(proto::Plan::ZedProTrial) => self.render_trial_state(cx), - Some(proto::Plan::ZedPro) => self.render_pro_plan_state(cx), + None | Some(Plan::ZedFree) => self.render_free_plan_state(cx), + Some(Plan::ZedProTrial) => self.render_trial_state(cx), + Some(Plan::ZedPro) => self.render_pro_plan_state(cx), } } else { self.render_accept_terms_of_service() @@ -433,7 +432,7 @@ impl Component for ZedAiOnboarding { fn onboarding( sign_in_status: SignInStatus, has_accepted_terms_of_service: bool, - plan: Option, + plan: Option, account_too_young: bool, ) -> AnyElement { ZedAiOnboarding { @@ -468,25 +467,15 @@ impl Component for ZedAiOnboarding { ), single_example( "Free Plan", - onboarding(SignInStatus::SignedIn, true, Some(proto::Plan::Free), false), + onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedFree), false), ), single_example( "Pro Trial", - onboarding( - SignInStatus::SignedIn, - true, - Some(proto::Plan::ZedProTrial), - false, - ), + onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedProTrial), false), ), single_example( "Pro Plan", - onboarding( - SignInStatus::SignedIn, - true, - Some(proto::Plan::ZedPro), - false, - ), + onboarding(SignInStatus::SignedIn, true, Some(Plan::ZedPro), false), ), ]) .into_any_element(), diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index 041e0d87ec..8bc29aa594 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -21,11 +21,9 @@ impl AiUpsellCard { sign_in: Arc::new(move |_window, cx| { cx.spawn({ let client = client.clone(); - async move |cx| { - client.authenticate_and_connect(true, cx).await; - } + async move |cx| client.sign_in_with_optional_connect(true, cx).await }) - .detach(); + .detach_and_log_err(cx); }), } } diff --git a/crates/assistant_tools/src/edit_agent/evals.rs b/crates/assistant_tools/src/edit_agent/evals.rs index 13619da25c..eda7eee0e3 100644 --- a/crates/assistant_tools/src/edit_agent/evals.rs +++ b/crates/assistant_tools/src/edit_agent/evals.rs @@ -7,7 +7,7 @@ use crate::{ }; use Role::*; use assistant_tool::ToolRegistry; -use client::{Client, CloudUserStore, UserStore}; +use client::{Client, UserStore}; use collections::HashMap; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; @@ -1470,14 +1470,12 @@ impl EditAgentTest { client::init_settings(cx); let client = Client::production(cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); settings::init(cx); Project::init_settings(cx); language::init(cx); language_model::init(client.clone(), cx); - language_models::init(user_store.clone(), cloud_user_store, client.clone(), cx); + language_models::init(user_store.clone(), client.clone(), cx); crate::init(client.http_client(), cx); }); diff --git a/crates/channel/src/channel_store_tests.rs b/crates/channel/src/channel_store_tests.rs index f8f5de3c39..c92226eeeb 100644 --- a/crates/channel/src/channel_store_tests.rs +++ b/crates/channel/src/channel_store_tests.rs @@ -259,20 +259,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) { assert_channels(&channel_store, &[(0, "the-channel".to_string())], cx); }); - let get_users = server.receive::().await.unwrap(); - assert_eq!(get_users.payload.user_ids, vec![5]); - server.respond( - get_users.receipt(), - proto::UsersResponse { - users: vec![proto::User { - id: 5, - github_login: "nathansobo".into(), - avatar_url: "http://avatar.com/nathansobo".into(), - name: None, - }], - }, - ); - // Join a channel and populate its existing messages. let channel = channel_store.update(cx, |store, cx| { let channel_id = store.ordered_channels().next().unwrap().1.id; @@ -334,7 +320,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { .map(|message| (message.sender.github_login.clone(), message.body.clone())) .collect::>(), &[ - ("nathansobo".into(), "a".into()), + ("user-5".into(), "a".into()), ("maxbrunsfeld".into(), "b".into()) ] ); @@ -437,7 +423,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) { .map(|message| (message.sender.github_login.clone(), message.body.clone())) .collect::>(), &[ - ("nathansobo".into(), "y".into()), + ("user-5".into(), "y".into()), ("maxbrunsfeld".into(), "z".into()) ] ); diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 3ff03114ea..365625b445 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -17,7 +17,6 @@ test-support = ["clock/test-support", "collections/test-support", "gpui/test-sup [dependencies] anyhow.workspace = true -async-recursion = "0.3" async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots"] } base64.workspace = true chrono = { workspace = true, features = ["serde"] } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 8aa46ecda5..2fd343903d 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,14 +1,12 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; -mod cloud; mod proxy; pub mod telemetry; pub mod user; pub mod zed_urls; use anyhow::{Context as _, Result, anyhow}; -use async_recursion::async_recursion; use async_tungstenite::tungstenite::{ client::IntoClientRequest, error::Error as WebsocketError, @@ -52,7 +50,6 @@ use tokio::net::TcpStream; use url::Url; use util::{ConnectionResult, ResultExt}; -pub use cloud::*; pub use rpc::*; pub use telemetry_events::Event; pub use user::*; @@ -163,20 +160,8 @@ pub fn init(client: &Arc, cx: &mut App) { let client = client.clone(); move |_: &SignIn, cx| { if let Some(client) = client.upgrade() { - cx.spawn( - async move |cx| match client.authenticate_and_connect(true, &cx).await { - ConnectionResult::Timeout => { - log::error!("Initial authentication timed out"); - } - ConnectionResult::ConnectionReset => { - log::error!("Initial authentication connection reset"); - } - ConnectionResult::Result(r) => { - r.log_err(); - } - }, - ) - .detach(); + cx.spawn(async move |cx| client.sign_in_with_optional_connect(true, &cx).await) + .detach_and_log_err(cx); } } }); @@ -285,6 +270,8 @@ pub enum Status { SignedOut, UpgradeRequired, Authenticating, + Authenticated, + AuthenticationError, Connecting, ConnectionError, Connected { @@ -684,7 +671,7 @@ impl Client { let mut delay = INITIAL_RECONNECTION_DELAY; loop { - match client.authenticate_and_connect(true, &cx).await { + match client.connect(true, &cx).await { ConnectionResult::Timeout => { log::error!("client connect attempt timed out") } @@ -854,17 +841,122 @@ impl Client { .is_some() } - #[async_recursion(?Send)] - pub async fn authenticate_and_connect( + pub async fn sign_in( + self: &Arc, + try_provider: bool, + cx: &AsyncApp, + ) -> Result { + if self.status().borrow().is_signed_out() { + self.set_status(Status::Authenticating, cx); + } else { + self.set_status(Status::Reauthenticating, cx); + } + + let mut credentials = None; + + let old_credentials = self.state.read().credentials.clone(); + if let Some(old_credentials) = old_credentials { + self.cloud_client.set_credentials( + old_credentials.user_id as u32, + old_credentials.access_token.clone(), + ); + + // Fetch the authenticated user with the old credentials, to ensure they are still valid. + if self.cloud_client.get_authenticated_user().await.is_ok() { + credentials = Some(old_credentials); + } + } + + if credentials.is_none() && try_provider { + if let Some(stored_credentials) = self.credentials_provider.read_credentials(cx).await { + self.cloud_client.set_credentials( + stored_credentials.user_id as u32, + stored_credentials.access_token.clone(), + ); + + // Fetch the authenticated user with the stored credentials, and + // clear them from the credentials provider if that fails. + if self.cloud_client.get_authenticated_user().await.is_ok() { + credentials = Some(stored_credentials); + } else { + self.credentials_provider + .delete_credentials(cx) + .await + .log_err(); + } + } + } + + if credentials.is_none() { + let mut status_rx = self.status(); + let _ = status_rx.next().await; + futures::select_biased! { + authenticate = self.authenticate(cx).fuse() => { + match authenticate { + Ok(creds) => { + if IMPERSONATE_LOGIN.is_none() { + self.credentials_provider + .write_credentials(creds.user_id, creds.access_token.clone(), cx) + .await + .log_err(); + } + + credentials = Some(creds); + }, + Err(err) => { + self.set_status(Status::AuthenticationError, cx); + return Err(err); + } + } + } + _ = status_rx.next().fuse() => { + return Err(anyhow!("authentication canceled")); + } + } + } + + let credentials = credentials.unwrap(); + self.set_id(credentials.user_id); + self.cloud_client + .set_credentials(credentials.user_id as u32, credentials.access_token.clone()); + self.state.write().credentials = Some(credentials.clone()); + self.set_status(Status::Authenticated, cx); + + Ok(credentials) + } + + /// Performs a sign-in and also connects to Collab. + /// + /// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls + /// to `sign_in` when we're ready to remove auto-connection to Collab. + pub async fn sign_in_with_optional_connect( + self: &Arc, + try_provider: bool, + cx: &AsyncApp, + ) -> Result<()> { + let credentials = self.sign_in(try_provider, cx).await?; + + let connect_result = match self.connect_with_credentials(credentials, cx).await { + ConnectionResult::Timeout => Err(anyhow!("connection timed out")), + ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")), + ConnectionResult::Result(result) => result.context("client auth and connect"), + }; + connect_result.log_err(); + + Ok(()) + } + + pub async fn connect( self: &Arc, try_provider: bool, cx: &AsyncApp, ) -> ConnectionResult<()> { let was_disconnected = match *self.status().borrow() { - Status::SignedOut => true, + Status::SignedOut | Status::Authenticated => true, Status::ConnectionError | Status::ConnectionLost | Status::Authenticating { .. } + | Status::AuthenticationError | Status::Reauthenticating { .. } | Status::ReconnectionError { .. } => false, Status::Connected { .. } | Status::Connecting { .. } | Status::Reconnecting { .. } => { @@ -877,41 +969,10 @@ impl Client { ); } }; - if was_disconnected { - self.set_status(Status::Authenticating, cx); - } else { - self.set_status(Status::Reauthenticating, cx) - } - - let mut read_from_provider = false; - let mut credentials = self.state.read().credentials.clone(); - if credentials.is_none() && try_provider { - credentials = self.credentials_provider.read_credentials(cx).await; - read_from_provider = credentials.is_some(); - } - - if credentials.is_none() { - let mut status_rx = self.status(); - let _ = status_rx.next().await; - futures::select_biased! { - authenticate = self.authenticate(cx).fuse() => { - match authenticate { - Ok(creds) => credentials = Some(creds), - Err(err) => { - self.set_status(Status::ConnectionError, cx); - return ConnectionResult::Result(Err(err)); - } - } - } - _ = status_rx.next().fuse() => { - return ConnectionResult::Result(Err(anyhow!("authentication canceled"))); - } - } - } - let credentials = credentials.unwrap(); - self.set_id(credentials.user_id); - self.cloud_client - .set_credentials(credentials.user_id as u32, credentials.access_token.clone()); + let credentials = match self.sign_in(try_provider, cx).await { + Ok(credentials) => credentials, + Err(err) => return ConnectionResult::Result(Err(err)), + }; if was_disconnected { self.set_status(Status::Connecting, cx); @@ -919,17 +980,20 @@ impl Client { self.set_status(Status::Reconnecting, cx); } + self.connect_with_credentials(credentials, cx).await + } + + async fn connect_with_credentials( + self: &Arc, + credentials: Credentials, + cx: &AsyncApp, + ) -> ConnectionResult<()> { let mut timeout = futures::FutureExt::fuse(cx.background_executor().timer(CONNECTION_TIMEOUT)); futures::select_biased! { connection = self.establish_connection(&credentials, cx).fuse() => { match connection { Ok(conn) => { - self.state.write().credentials = Some(credentials.clone()); - if !read_from_provider && IMPERSONATE_LOGIN.is_none() { - self.credentials_provider.write_credentials(credentials.user_id, credentials.access_token, cx).await.log_err(); - } - futures::select_biased! { result = self.set_connection(conn, cx).fuse() => { match result.context("client auth and connect") { @@ -947,15 +1011,8 @@ impl Client { } } Err(EstablishConnectionError::Unauthorized) => { - self.state.write().credentials.take(); - if read_from_provider { - self.credentials_provider.delete_credentials(cx).await.log_err(); - self.set_status(Status::SignedOut, cx); - self.authenticate_and_connect(false, cx).await - } else { - self.set_status(Status::ConnectionError, cx); - ConnectionResult::Result(Err(EstablishConnectionError::Unauthorized).context("client auth and connect")) - } + self.set_status(Status::ConnectionError, cx); + ConnectionResult::Result(Err(EstablishConnectionError::Unauthorized).context("client auth and connect")) } Err(EstablishConnectionError::UpgradeRequired) => { self.set_status(Status::UpgradeRequired, cx); @@ -1705,7 +1762,7 @@ mod tests { }); let auth_and_connect = cx.spawn({ let client = client.clone(); - |cx| async move { client.authenticate_and_connect(false, &cx).await } + |cx| async move { client.connect(false, &cx).await } }); executor.run_until_parked(); assert!(matches!(status.next().await, Some(Status::Connecting))); @@ -1782,7 +1839,7 @@ mod tests { let _authenticate = cx.spawn({ let client = client.clone(); - move |cx| async move { client.authenticate_and_connect(false, &cx).await } + move |cx| async move { client.connect(false, &cx).await } }); executor.run_until_parked(); assert_eq!(*auth_count.lock(), 1); @@ -1790,7 +1847,7 @@ mod tests { let _authenticate = cx.spawn({ let client = client.clone(); - |cx| async move { client.authenticate_and_connect(false, &cx).await } + |cx| async move { client.connect(false, &cx).await } }); executor.run_until_parked(); assert_eq!(*auth_count.lock(), 2); diff --git a/crates/client/src/cloud.rs b/crates/client/src/cloud.rs deleted file mode 100644 index 39c9d04887..0000000000 --- a/crates/client/src/cloud.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod user_store; - -pub use user_store::*; diff --git a/crates/client/src/cloud/user_store.rs b/crates/client/src/cloud/user_store.rs deleted file mode 100644 index 78444b3f95..0000000000 --- a/crates/client/src/cloud/user_store.rs +++ /dev/null @@ -1,211 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Context as _; -use chrono::{DateTime, Utc}; -use cloud_api_client::{AuthenticatedUser, CloudApiClient, GetAuthenticatedUserResponse, PlanInfo}; -use cloud_llm_client::Plan; -use gpui::{Context, Entity, Subscription, Task}; -use util::{ResultExt as _, maybe}; - -use crate::user::Event as RpcUserStoreEvent; -use crate::{EditPredictionUsage, ModelRequestUsage, RequestUsage, UserStore}; - -pub struct CloudUserStore { - cloud_client: Arc, - authenticated_user: Option>, - plan_info: Option>, - model_request_usage: Option, - edit_prediction_usage: Option, - _maintain_authenticated_user_task: Task<()>, - _rpc_plan_updated_subscription: Subscription, -} - -impl CloudUserStore { - pub fn new( - cloud_client: Arc, - rpc_user_store: Entity, - cx: &mut Context, - ) -> Self { - let rpc_plan_updated_subscription = - cx.subscribe(&rpc_user_store, Self::handle_rpc_user_store_event); - - Self { - cloud_client: cloud_client.clone(), - authenticated_user: None, - plan_info: None, - model_request_usage: None, - edit_prediction_usage: None, - _maintain_authenticated_user_task: cx.spawn(async move |this, cx| { - maybe!(async move { - loop { - let Some(this) = this.upgrade() else { - return anyhow::Ok(()); - }; - - if cloud_client.has_credentials() { - let already_fetched_authenticated_user = this - .read_with(cx, |this, _cx| this.authenticated_user().is_some()) - .unwrap_or(false); - - if already_fetched_authenticated_user { - // We already fetched the authenticated user; nothing to do. - } else { - let authenticated_user_result = cloud_client - .get_authenticated_user() - .await - .context("failed to fetch authenticated user"); - if let Some(response) = authenticated_user_result.log_err() { - this.update(cx, |this, _cx| { - this.update_authenticated_user(response); - }) - .ok(); - } - } - } else { - this.update(cx, |this, _cx| { - this.authenticated_user.take(); - this.plan_info.take(); - }) - .ok(); - } - - cx.background_executor() - .timer(Duration::from_millis(100)) - .await; - } - }) - .await - .log_err(); - }), - _rpc_plan_updated_subscription: rpc_plan_updated_subscription, - } - } - - pub fn is_authenticated(&self) -> bool { - self.authenticated_user.is_some() - } - - pub fn authenticated_user(&self) -> Option> { - self.authenticated_user.clone() - } - - pub fn plan(&self) -> Option { - self.plan_info.as_ref().map(|plan| plan.plan) - } - - pub fn subscription_period(&self) -> Option<(DateTime, DateTime)> { - self.plan_info - .as_ref() - .and_then(|plan| plan.subscription_period) - .map(|subscription_period| { - ( - subscription_period.started_at.0, - subscription_period.ended_at.0, - ) - }) - } - - pub fn trial_started_at(&self) -> Option> { - self.plan_info - .as_ref() - .and_then(|plan| plan.trial_started_at) - .map(|trial_started_at| trial_started_at.0) - } - - pub fn has_accepted_tos(&self) -> bool { - self.authenticated_user - .as_ref() - .map(|user| user.accepted_tos_at.is_some()) - .unwrap_or_default() - } - - /// Returns whether the user's account is too new to use the service. - pub fn account_too_young(&self) -> bool { - self.plan_info - .as_ref() - .map(|plan| plan.is_account_too_young) - .unwrap_or_default() - } - - /// Returns whether the current user has overdue invoices and usage should be blocked. - pub fn has_overdue_invoices(&self) -> bool { - self.plan_info - .as_ref() - .map(|plan| plan.has_overdue_invoices) - .unwrap_or_default() - } - - pub fn is_usage_based_billing_enabled(&self) -> bool { - self.plan_info - .as_ref() - .map(|plan| plan.is_usage_based_billing_enabled) - .unwrap_or_default() - } - - pub fn model_request_usage(&self) -> Option { - self.model_request_usage - } - - pub fn update_model_request_usage(&mut self, usage: ModelRequestUsage, cx: &mut Context) { - self.model_request_usage = Some(usage); - cx.notify(); - } - - pub fn edit_prediction_usage(&self) -> Option { - self.edit_prediction_usage - } - - pub fn update_edit_prediction_usage( - &mut self, - usage: EditPredictionUsage, - cx: &mut Context, - ) { - self.edit_prediction_usage = Some(usage); - cx.notify(); - } - - fn update_authenticated_user(&mut self, response: GetAuthenticatedUserResponse) { - self.authenticated_user = Some(Arc::new(response.user)); - self.model_request_usage = Some(ModelRequestUsage(RequestUsage { - limit: response.plan.usage.model_requests.limit, - amount: response.plan.usage.model_requests.used as i32, - })); - self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage { - limit: response.plan.usage.edit_predictions.limit, - amount: response.plan.usage.edit_predictions.used as i32, - })); - self.plan_info = Some(Arc::new(response.plan)); - } - - fn handle_rpc_user_store_event( - &mut self, - _: Entity, - event: &RpcUserStoreEvent, - cx: &mut Context, - ) { - match event { - RpcUserStoreEvent::PlanUpdated => { - cx.spawn(async move |this, cx| { - let cloud_client = - cx.update(|cx| this.read_with(cx, |this, _cx| this.cloud_client.clone()))??; - - let response = cloud_client - .get_authenticated_user() - .await - .context("failed to fetch authenticated user")?; - - cx.update(|cx| { - this.update(cx, |this, _cx| { - this.update_authenticated_user(response); - }) - })??; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); - } - _ => {} - } - } -} diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index 6ce79fa9c5..439fb100d2 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -1,8 +1,11 @@ use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore}; use anyhow::{Context as _, Result, anyhow}; use chrono::Duration; +use cloud_api_client::{AuthenticatedUser, GetAuthenticatedUserResponse, PlanInfo}; +use cloud_llm_client::{CurrentUsage, Plan, UsageData, UsageLimit}; use futures::{StreamExt, stream::BoxStream}; use gpui::{AppContext as _, BackgroundExecutor, Entity, TestAppContext}; +use http_client::{AsyncBody, Method, Request, http}; use parking_lot::Mutex; use rpc::{ ConnectionId, Peer, Receipt, TypedEnvelope, @@ -39,6 +42,44 @@ impl FakeServer { executor: cx.executor(), }; + client.http_client().as_fake().replace_handler({ + let state = server.state.clone(); + move |old_handler, req| { + let state = state.clone(); + let old_handler = old_handler.clone(); + async move { + match (req.method(), req.uri().path()) { + (&Method::GET, "/client/users/me") => { + let credentials = parse_authorization_header(&req); + if credentials + != Some(Credentials { + user_id: client_user_id, + access_token: state.lock().access_token.to_string(), + }) + { + return Ok(http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()); + } + + Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&make_get_authenticated_user_response( + client_user_id as i32, + format!("user-{client_user_id}"), + )) + .unwrap() + .into(), + ) + .unwrap()) + } + _ => old_handler(req).await, + } + } + } + }); client .override_authenticate({ let state = Arc::downgrade(&server.state); @@ -105,7 +146,7 @@ impl FakeServer { }); client - .authenticate_and_connect(false, &cx.to_async()) + .connect(false, &cx.to_async()) .await .into_response() .unwrap(); @@ -223,3 +264,54 @@ impl Drop for FakeServer { self.disconnect(); } } + +pub fn parse_authorization_header(req: &Request) -> Option { + let mut auth_header = req + .headers() + .get(http::header::AUTHORIZATION)? + .to_str() + .ok()? + .split_whitespace(); + let user_id = auth_header.next()?.parse().ok()?; + let access_token = auth_header.next()?; + Some(Credentials { + user_id, + access_token: access_token.to_string(), + }) +} + +pub fn make_get_authenticated_user_response( + user_id: i32, + github_login: String, +) -> GetAuthenticatedUserResponse { + GetAuthenticatedUserResponse { + user: AuthenticatedUser { + id: user_id, + metrics_id: format!("metrics-id-{user_id}"), + avatar_url: "".to_string(), + github_login, + name: None, + is_staff: false, + accepted_tos_at: None, + }, + feature_flags: vec![], + plan: PlanInfo { + plan: Plan::ZedPro, + subscription_period: None, + usage: CurrentUsage { + model_requests: UsageData { + used: 0, + limit: UsageLimit::Limited(500), + }, + edit_predictions: UsageData { + used: 250, + limit: UsageLimit::Unlimited, + }, + }, + trial_started_at: None, + is_usage_based_billing_enabled: false, + is_account_too_young: false, + has_overdue_invoices: false, + }, + } +} diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index b8219f6169..a4441bec75 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,6 +1,7 @@ use super::{Client, Status, TypedEnvelope, proto}; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; +use cloud_api_client::{GetAuthenticatedUserResponse, PlanInfo}; use cloud_llm_client::{ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, MODEL_REQUESTS_USAGE_AMOUNT_HEADER_NAME, MODEL_REQUESTS_USAGE_LIMIT_HEADER_NAME, UsageLimit, @@ -20,7 +21,7 @@ use std::{ sync::{Arc, Weak}, }; use text::ReplicaId; -use util::TryFutureExt as _; +use util::{ResultExt, TryFutureExt as _}; pub type UserId = u64; @@ -110,12 +111,11 @@ pub struct UserStore { by_github_login: HashMap, participant_indices: HashMap, update_contacts_tx: mpsc::UnboundedSender, - current_plan: Option, - trial_started_at: Option>, - is_usage_based_billing_enabled: Option, - account_too_young: Option, + model_request_usage: Option, + edit_prediction_usage: Option, + plan_info: Option, current_user: watch::Receiver>>, - accepted_tos_at: Option>>, + accepted_tos_at: Option>, contacts: Vec>, incoming_contact_requests: Vec>, outgoing_contact_requests: Vec>, @@ -185,10 +185,9 @@ impl UserStore { users: Default::default(), by_github_login: Default::default(), current_user: current_user_rx, - current_plan: None, - trial_started_at: None, - is_usage_based_billing_enabled: None, - account_too_young: None, + plan_info: None, + model_request_usage: None, + edit_prediction_usage: None, accepted_tos_at: None, contacts: Default::default(), incoming_contact_requests: Default::default(), @@ -218,53 +217,30 @@ impl UserStore { return Ok(()); }; match status { - Status::Connected { .. } => { + Status::Authenticated | Status::Connected { .. } => { if let Some(user_id) = client.user_id() { - let fetch_user = if let Ok(fetch_user) = - this.update(cx, |this, cx| this.get_user(user_id, cx).log_err()) - { - fetch_user - } else { - break; - }; - let fetch_private_user_info = - client.request(proto::GetPrivateUserInfo {}).log_err(); - let (user, info) = - futures::join!(fetch_user, fetch_private_user_info); - + let response = client.cloud_client().get_authenticated_user().await; + let mut current_user = None; cx.update(|cx| { - if let Some(info) = info { - let staff = - info.staff && !*feature_flags::ZED_DISABLE_STAFF; - cx.update_flags(staff, info.flags); - client.telemetry.set_authenticated_user_info( - Some(info.metrics_id.clone()), - staff, - ); - + if let Some(response) = response.log_err() { + let user = Arc::new(User { + id: user_id, + github_login: response.user.github_login.clone().into(), + avatar_uri: response.user.avatar_url.clone().into(), + name: response.user.name.clone(), + }); + current_user = Some(user.clone()); this.update(cx, |this, cx| { - let accepted_tos_at = { - #[cfg(debug_assertions)] - if std::env::var("ZED_IGNORE_ACCEPTED_TOS").is_ok() - { - None - } else { - info.accepted_tos_at - } - - #[cfg(not(debug_assertions))] - info.accepted_tos_at - }; - - this.set_current_user_accepted_tos_at(accepted_tos_at); - cx.emit(Event::PrivateUserInfoUpdated); + this.by_github_login + .insert(user.github_login.clone(), user_id); + this.users.insert(user_id, user); + this.update_authenticated_user(response, cx) }) } else { anyhow::Ok(()) } })??; - - current_user_tx.send(user).await.ok(); + current_user_tx.send(current_user).await.ok(); this.update(cx, |_, cx| cx.notify())?; } @@ -345,22 +321,22 @@ impl UserStore { async fn handle_update_plan( this: Entity, - message: TypedEnvelope, + _message: TypedEnvelope, mut cx: AsyncApp, ) -> Result<()> { - this.update(&mut cx, |this, cx| { - this.current_plan = Some(message.payload.plan()); - this.trial_started_at = message - .payload - .trial_started_at - .and_then(|trial_started_at| DateTime::from_timestamp(trial_started_at as i64, 0)); - this.is_usage_based_billing_enabled = message.payload.is_usage_based_billing_enabled; - this.account_too_young = message.payload.account_too_young; + let client = this + .read_with(&cx, |this, _| this.client.upgrade())? + .context("client was dropped")?; - cx.emit(Event::PlanUpdated); - cx.notify(); - })?; - Ok(()) + let response = client + .cloud_client() + .get_authenticated_user() + .await + .context("failed to fetch authenticated user")?; + + this.update(&mut cx, |this, cx| { + this.update_authenticated_user(response, cx); + }) } fn update_contacts(&mut self, message: UpdateContacts, cx: &Context) -> Task> { @@ -719,42 +695,131 @@ impl UserStore { self.current_user.borrow().clone() } - pub fn current_plan(&self) -> Option { + pub fn plan(&self) -> Option { #[cfg(debug_assertions)] 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), + "free" => Some(cloud_llm_client::Plan::ZedFree), + "trial" => Some(cloud_llm_client::Plan::ZedProTrial), + "pro" => Some(cloud_llm_client::Plan::ZedPro), _ => { panic!("ZED_SIMULATE_PLAN must be one of 'free', 'trial', or 'pro'"); } }; } - self.current_plan + self.plan_info.as_ref().map(|info| info.plan) + } + + pub fn subscription_period(&self) -> Option<(DateTime, DateTime)> { + self.plan_info + .as_ref() + .and_then(|plan| plan.subscription_period) + .map(|subscription_period| { + ( + subscription_period.started_at.0, + subscription_period.ended_at.0, + ) + }) } pub fn trial_started_at(&self) -> Option> { - self.trial_started_at + self.plan_info + .as_ref() + .and_then(|plan| plan.trial_started_at) + .map(|trial_started_at| trial_started_at.0) } - pub fn usage_based_billing_enabled(&self) -> Option { - self.is_usage_based_billing_enabled + /// Returns whether the user's account is too new to use the service. + pub fn account_too_young(&self) -> bool { + self.plan_info + .as_ref() + .map(|plan| plan.is_account_too_young) + .unwrap_or_default() + } + + /// Returns whether the current user has overdue invoices and usage should be blocked. + pub fn has_overdue_invoices(&self) -> bool { + self.plan_info + .as_ref() + .map(|plan| plan.has_overdue_invoices) + .unwrap_or_default() + } + + pub fn is_usage_based_billing_enabled(&self) -> bool { + self.plan_info + .as_ref() + .map(|plan| plan.is_usage_based_billing_enabled) + .unwrap_or_default() + } + + pub fn model_request_usage(&self) -> Option { + self.model_request_usage + } + + pub fn update_model_request_usage(&mut self, usage: ModelRequestUsage, cx: &mut Context) { + self.model_request_usage = Some(usage); + cx.notify(); + } + + pub fn edit_prediction_usage(&self) -> Option { + self.edit_prediction_usage + } + + pub fn update_edit_prediction_usage( + &mut self, + usage: EditPredictionUsage, + cx: &mut Context, + ) { + self.edit_prediction_usage = Some(usage); + cx.notify(); + } + + fn update_authenticated_user( + &mut self, + response: GetAuthenticatedUserResponse, + cx: &mut Context, + ) { + let staff = response.user.is_staff && !*feature_flags::ZED_DISABLE_STAFF; + cx.update_flags(staff, response.feature_flags); + if let Some(client) = self.client.upgrade() { + client + .telemetry + .set_authenticated_user_info(Some(response.user.metrics_id.clone()), staff); + } + + let accepted_tos_at = { + #[cfg(debug_assertions)] + if std::env::var("ZED_IGNORE_ACCEPTED_TOS").is_ok() { + None + } else { + response.user.accepted_tos_at + } + + #[cfg(not(debug_assertions))] + response.user.accepted_tos_at + }; + + self.accepted_tos_at = Some(accepted_tos_at); + self.model_request_usage = Some(ModelRequestUsage(RequestUsage { + limit: response.plan.usage.model_requests.limit, + amount: response.plan.usage.model_requests.used as i32, + })); + self.edit_prediction_usage = Some(EditPredictionUsage(RequestUsage { + limit: response.plan.usage.edit_predictions.limit, + amount: response.plan.usage.edit_predictions.used as i32, + })); + self.plan_info = Some(response.plan); + cx.emit(Event::PrivateUserInfoUpdated); } pub fn watch_current_user(&self) -> watch::Receiver>> { self.current_user.clone() } - /// Returns whether the user's account is too new to use the service. - pub fn account_too_young(&self) -> bool { - self.account_too_young.unwrap_or(false) - } - - pub fn current_user_has_accepted_terms(&self) -> Option { + pub fn has_accepted_terms_of_service(&self) -> bool { self.accepted_tos_at - .map(|accepted_tos_at| accepted_tos_at.is_some()) + .map_or(false, |accepted_tos_at| accepted_tos_at.is_some()) } pub fn accept_terms_of_service(&self, cx: &Context) -> Task> { @@ -766,23 +831,18 @@ impl UserStore { cx.spawn(async move |this, cx| -> anyhow::Result<()> { let client = client.upgrade().context("client not found")?; let response = client - .request(proto::AcceptTermsOfService {}) + .cloud_client() + .accept_terms_of_service() .await .context("error accepting tos")?; this.update(cx, |this, cx| { - this.set_current_user_accepted_tos_at(Some(response.accepted_tos_at)); + this.accepted_tos_at = Some(response.user.accepted_tos_at); cx.emit(Event::PrivateUserInfoUpdated); })?; Ok(()) }) } - fn set_current_user_accepted_tos_at(&mut self, accepted_tos_at: Option) { - self.accepted_tos_at = Some( - accepted_tos_at.and_then(|timestamp| DateTime::from_timestamp(timestamp as i64, 0)), - ); - } - fn load_users( &self, request: impl RequestMessage, diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index f1cc2bf24a..cbb8838f25 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -1286,7 +1286,7 @@ async fn test_calls_on_multiple_connections( client_b1.disconnect(&cx_b1.to_async()); executor.advance_clock(RECEIVE_TIMEOUT); client_b1 - .authenticate_and_connect(false, &cx_b1.to_async()) + .connect(false, &cx_b1.to_async()) .await .into_response() .unwrap(); @@ -1667,7 +1667,7 @@ async fn test_project_reconnect( // Client A reconnects. Their project is re-shared, and client B re-joins it. server.allow_connections(); client_a - .authenticate_and_connect(false, &cx_a.to_async()) + .connect(false, &cx_a.to_async()) .await .into_response() .unwrap(); @@ -1796,7 +1796,7 @@ async fn test_project_reconnect( // Client B reconnects. They re-join the room and the remaining shared project. server.allow_connections(); client_b - .authenticate_and_connect(false, &cx_b.to_async()) + .connect(false, &cx_b.to_async()) .await .into_response() .unwrap(); @@ -5738,7 +5738,7 @@ async fn test_contacts( server.allow_connections(); client_c - .authenticate_and_connect(false, &cx_c.to_async()) + .connect(false, &cx_c.to_async()) .await .into_response() .unwrap(); @@ -6269,7 +6269,7 @@ async fn test_contact_requests( client.disconnect(&cx.to_async()); client.clear_contacts(cx).await; client - .authenticate_and_connect(false, &cx.to_async()) + .connect(false, &cx.to_async()) .await .into_response() .unwrap(); diff --git a/crates/collab/src/tests/notification_tests.rs b/crates/collab/src/tests/notification_tests.rs index 4e64b5526b..9bf906694e 100644 --- a/crates/collab/src/tests/notification_tests.rs +++ b/crates/collab/src/tests/notification_tests.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use gpui::{BackgroundExecutor, TestAppContext}; use notifications::NotificationEvent; use parking_lot::Mutex; +use pretty_assertions::assert_eq; use rpc::{Notification, proto}; use crate::tests::TestServer; @@ -17,6 +18,9 @@ async fn test_notifications( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; + // Wait for authentication/connection to Collab to be established. + executor.run_until_parked(); + let notification_events_a = Arc::new(Mutex::new(Vec::new())); let notification_events_b = Arc::new(Mutex::new(Vec::new())); client_a.notification_store().update(cx_a, |_, cx| { diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 00d1caa7c5..e71574db49 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -8,7 +8,7 @@ use crate::{ use anyhow::anyhow; use call::ActiveCall; use channel::{ChannelBuffer, ChannelStore}; -use client::CloudUserStore; +use client::test::{make_get_authenticated_user_response, parse_authorization_header}; use client::{ self, ChannelId, Client, Connection, Credentials, EstablishConnectionError, UserStore, proto::PeerId, @@ -21,7 +21,7 @@ use fs::FakeFs; use futures::{StreamExt as _, channel::oneshot}; use git::GitHostingProviderRegistry; use gpui::{AppContext as _, BackgroundExecutor, Entity, Task, TestAppContext, VisualTestContext}; -use http_client::FakeHttpClient; +use http_client::{FakeHttpClient, Method}; use language::LanguageRegistry; use node_runtime::NodeRuntime; use notifications::NotificationStore; @@ -162,6 +162,8 @@ impl TestServer { } pub async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient { + const ACCESS_TOKEN: &str = "the-token"; + let fs = FakeFs::new(cx.executor()); cx.update(|cx| { @@ -176,7 +178,7 @@ impl TestServer { }); let clock = Arc::new(FakeSystemClock::new()); - let http = FakeHttpClient::with_404_response(); + let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await { user.id @@ -198,6 +200,47 @@ impl TestServer { .expect("creating user failed") .user_id }; + + let http = FakeHttpClient::create({ + let name = name.to_string(); + move |req| { + let name = name.clone(); + async move { + match (req.method(), req.uri().path()) { + (&Method::GET, "/client/users/me") => { + let credentials = parse_authorization_header(&req); + if credentials + != Some(Credentials { + user_id: user_id.to_proto(), + access_token: ACCESS_TOKEN.into(), + }) + { + return Ok(http_client::Response::builder() + .status(401) + .body("Unauthorized".into()) + .unwrap()); + } + + Ok(http_client::Response::builder() + .status(200) + .body( + serde_json::to_string(&make_get_authenticated_user_response( + user_id.0, name, + )) + .unwrap() + .into(), + ) + .unwrap()) + } + _ => Ok(http_client::Response::builder() + .status(404) + .body("Not Found".into()) + .unwrap()), + } + } + } + }); + let client_name = name.to_string(); let mut client = cx.update(|cx| Client::new(clock, http.clone(), cx)); let server = self.server.clone(); @@ -209,11 +252,10 @@ impl TestServer { .unwrap() .set_id(user_id.to_proto()) .override_authenticate(move |cx| { - let access_token = "the-token".to_string(); cx.spawn(async move |_| { Ok(Credentials { user_id: user_id.to_proto(), - access_token, + access_token: ACCESS_TOKEN.into(), }) }) }) @@ -222,7 +264,7 @@ impl TestServer { credentials, &Credentials { user_id: user_id.0 as u64, - access_token: "the-token".into() + access_token: ACCESS_TOKEN.into(), } ); @@ -282,15 +324,12 @@ impl TestServer { .register_hosting_provider(Arc::new(git_hosting_providers::Github::public_instance())); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); let language_registry = Arc::new(LanguageRegistry::test(cx.executor())); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); let app_state = Arc::new(workspace::AppState { client: client.clone(), user_store: user_store.clone(), - cloud_user_store, workspace_store, languages: language_registry, fs: fs.clone(), @@ -323,7 +362,7 @@ impl TestServer { }); client - .authenticate_and_connect(false, &cx.to_async()) + .connect(false, &cx.to_async()) .await .into_response() .unwrap(); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 4d5973481e..51eee5c68e 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -2331,7 +2331,7 @@ impl CollabPanel { let client = this.client.clone(); cx.spawn_in(window, async move |_, cx| { client - .authenticate_and_connect(true, &cx) + .connect(true, &cx) .await .into_response() .notify_async_err(cx); diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index fba8f66c2d..c3e834b645 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -634,13 +634,13 @@ impl Render for NotificationPanel { .child(Icon::new(IconName::Envelope)), ) .map(|this| { - if self.client.user_id().is_none() { + if !self.client.status().borrow().is_connected() { this.child( v_flex() .gap_2() .p_4() .child( - Button::new("sign_in_prompt_button", "Sign in") + Button::new("connect_prompt_button", "Connect") .icon_color(Color::Muted) .icon(IconName::Github) .icon_position(IconPosition::Start) @@ -652,10 +652,7 @@ impl Render for NotificationPanel { let client = client.clone(); window .spawn(cx, async move |cx| { - match client - .authenticate_and_connect(true, &cx) - .await - { + match client.connect(true, &cx).await { util::ConnectionResult::Timeout => { log::error!("Connection timeout"); } @@ -673,7 +670,7 @@ impl Render for NotificationPanel { ) .child( div().flex().w_full().items_center().child( - Label::new("Sign in to view notifications.") + Label::new("Connect to view notifications.") .color(Color::Muted) .size(LabelSize::Small), ), diff --git a/crates/eval/src/eval.rs b/crates/eval/src/eval.rs index 8d257a37a7..a02b4a7f0b 100644 --- a/crates/eval/src/eval.rs +++ b/crates/eval/src/eval.rs @@ -13,7 +13,7 @@ pub(crate) use tool_metrics::*; use ::fs::RealFs; use clap::Parser; -use client::{Client, CloudUserStore, ProxySettings, UserStore}; +use client::{Client, ProxySettings, UserStore}; use collections::{HashMap, HashSet}; use extension::ExtensionHostProxy; use futures::future; @@ -329,7 +329,6 @@ pub struct AgentAppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, - pub cloud_user_store: Entity, pub fs: Arc, pub node_runtime: NodeRuntime, @@ -384,8 +383,6 @@ pub fn init(cx: &mut App) -> Arc { let languages = Arc::new(languages); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); extension::init(cx); @@ -425,12 +422,7 @@ pub fn init(cx: &mut App) -> Arc { languages.clone(), ); language_model::init(client.clone(), cx); - language_models::init( - user_store.clone(), - cloud_user_store.clone(), - client.clone(), - cx, - ); + language_models::init(user_store.clone(), client.clone(), cx); languages::init(languages.clone(), node_runtime.clone(), cx); prompt_store::init(cx); terminal_view::init(cx); @@ -455,7 +447,6 @@ pub fn init(cx: &mut App) -> Arc { languages, client, user_store, - cloud_user_store, fs, node_runtime, prompt_builder, diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 54d864ea21..0f2b4c18ea 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -221,7 +221,6 @@ impl ExampleInstance { let prompt_store = None; let thread_store = ThreadStore::load( project.clone(), - app_state.cloud_user_store.clone(), tools, prompt_store, app_state.prompt_builder.clone(), diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index 2045708ff2..3f51cc5a23 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -23,6 +23,7 @@ futures.workspace = true http.workspace = true http-body.workspace = true log.workspace = true +parking_lot.workspace = true serde.workspace = true serde_json.workspace = true url.workspace = true diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 06875718d9..d33bbefc06 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -9,12 +9,10 @@ pub use http::{self, Method, Request, Response, StatusCode, Uri}; use futures::future::BoxFuture; use http::request::Builder; +use parking_lot::Mutex; #[cfg(feature = "test-support")] use std::fmt; -use std::{ - any::type_name, - sync::{Arc, Mutex}, -}; +use std::{any::type_name, sync::Arc}; pub use url::Url; #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] @@ -86,6 +84,11 @@ pub trait HttpClient: 'static + Send + Sync { } fn proxy(&self) -> Option<&Url>; + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + panic!("called as_fake on {}", type_name::()) + } } /// An [`HttpClient`] that may have a proxy. @@ -132,6 +135,11 @@ impl HttpClient for HttpClientWithProxy { fn type_name(&self) -> &'static str { self.client.type_name() } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + self.client.as_fake() + } } impl HttpClient for Arc { @@ -153,6 +161,11 @@ impl HttpClient for Arc { fn type_name(&self) -> &'static str { self.client.type_name() } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + self.client.as_fake() + } } /// An [`HttpClient`] that has a base URL. @@ -199,20 +212,13 @@ impl HttpClientWithUrl { /// Returns the base URL. pub fn base_url(&self) -> String { - self.base_url - .lock() - .map_or_else(|_| Default::default(), |url| url.clone()) + self.base_url.lock().clone() } /// Sets the base URL. pub fn set_base_url(&self, base_url: impl Into) { let base_url = base_url.into(); - self.base_url - .lock() - .map(|mut url| { - *url = base_url; - }) - .ok(); + *self.base_url.lock() = base_url; } /// Builds a URL using the given path. @@ -288,6 +294,11 @@ impl HttpClient for Arc { fn type_name(&self) -> &'static str { self.client.type_name() } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + self.client.as_fake() + } } impl HttpClient for HttpClientWithUrl { @@ -309,6 +320,11 @@ impl HttpClient for HttpClientWithUrl { fn type_name(&self) -> &'static str { self.client.type_name() } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + self.client.as_fake() + } } pub fn read_proxy_from_env() -> Option { @@ -360,10 +376,15 @@ impl HttpClient for BlockedHttpClient { fn type_name(&self) -> &'static str { type_name::() } + + #[cfg(feature = "test-support")] + fn as_fake(&self) -> &FakeHttpClient { + panic!("called as_fake on {}", type_name::()) + } } #[cfg(feature = "test-support")] -type FakeHttpHandler = Box< +type FakeHttpHandler = Arc< dyn Fn(Request) -> BoxFuture<'static, anyhow::Result>> + Send + Sync @@ -372,7 +393,7 @@ type FakeHttpHandler = Box< #[cfg(feature = "test-support")] pub struct FakeHttpClient { - handler: FakeHttpHandler, + handler: Mutex>, user_agent: HeaderValue, } @@ -387,7 +408,7 @@ impl FakeHttpClient { base_url: Mutex::new("http://test.example".into()), client: HttpClientWithProxy { client: Arc::new(Self { - handler: Box::new(move |req| Box::pin(handler(req))), + handler: Mutex::new(Some(Arc::new(move |req| Box::pin(handler(req))))), user_agent: HeaderValue::from_static(type_name::()), }), proxy: None, @@ -412,6 +433,18 @@ impl FakeHttpClient { .unwrap()) }) } + + pub fn replace_handler(&self, new_handler: F) + where + Fut: futures::Future>> + Send + 'static, + F: Fn(FakeHttpHandler, Request) -> Fut + Send + Sync + 'static, + { + let mut handler = self.handler.lock(); + let old_handler = handler.take().unwrap(); + *handler = Some(Arc::new(move |req| { + Box::pin(new_handler(old_handler.clone(), req)) + })); + } } #[cfg(feature = "test-support")] @@ -427,7 +460,7 @@ impl HttpClient for FakeHttpClient { &self, req: Request, ) -> BoxFuture<'static, anyhow::Result>> { - let future = (self.handler)(req); + let future = (self.handler.lock().as_ref().unwrap())(req); future } @@ -442,4 +475,8 @@ impl HttpClient for FakeHttpClient { fn type_name(&self) -> &'static str { type_name::() } + + fn as_fake(&self) -> &FakeHttpClient { + self + } } diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index d23cd7b9f9..79ebc573df 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use client::{CloudUserStore, zed_urls}; +use client::{UserStore, zed_urls}; use cloud_llm_client::UsageLimit; use copilot::{Copilot, Status}; use editor::{ @@ -60,7 +60,7 @@ pub struct InlineCompletionButton { file: Option>, edit_prediction_provider: Option>, fs: Arc, - cloud_user_store: Entity, + user_store: Entity, popover_menu_handle: PopoverMenuHandle, } @@ -246,9 +246,9 @@ impl Render for InlineCompletionButton { IconName::ZedPredictDisabled }; - if zeta::should_show_upsell_modal(&self.cloud_user_store, cx) { - let tooltip_meta = if self.cloud_user_store.read(cx).is_authenticated() { - if self.cloud_user_store.read(cx).has_accepted_tos() { + if zeta::should_show_upsell_modal(&self.user_store, cx) { + let tooltip_meta = if self.user_store.read(cx).current_user().is_some() { + if self.user_store.read(cx).has_accepted_terms_of_service() { "Choose a Plan" } else { "Accept the Terms of Service" @@ -372,7 +372,7 @@ impl Render for InlineCompletionButton { impl InlineCompletionButton { pub fn new( fs: Arc, - cloud_user_store: Entity, + user_store: Entity, popover_menu_handle: PopoverMenuHandle, cx: &mut Context, ) -> Self { @@ -391,9 +391,9 @@ impl InlineCompletionButton { language: None, file: None, edit_prediction_provider: None, + user_store, popover_menu_handle, fs, - cloud_user_store, } } @@ -764,7 +764,7 @@ impl InlineCompletionButton { }) }) .separator(); - } else if self.cloud_user_store.read(cx).account_too_young() { + } else if self.user_store.read(cx).account_too_young() { menu = menu .custom_entry( |_window, _cx| { @@ -779,7 +779,7 @@ impl InlineCompletionButton { cx.open_url(&zed_urls::account_url(cx)) }) .separator(); - } else if self.cloud_user_store.read(cx).has_overdue_invoices() { + } else if self.user_store.read(cx).has_overdue_invoices() { menu = menu .custom_entry( |_window, _cx| { diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index a5d2ac34f5..8ae5893410 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -3,10 +3,11 @@ use std::sync::Arc; use anyhow::Result; use client::Client; +use cloud_llm_client::Plan; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, ReadGlobal as _, }; -use proto::{Plan, TypedEnvelope}; +use proto::TypedEnvelope; use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard}; use thiserror::Error; @@ -30,7 +31,7 @@ pub struct ModelRequestLimitReachedError { impl fmt::Display for ModelRequestLimitReachedError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let message = match self.plan { - Plan::Free => "Model request limit reached. Upgrade to Zed Pro for more requests.", + Plan::ZedFree => "Model request limit reached. Upgrade to Zed Pro for more requests.", Plan::ZedPro => { "Model request limit reached. Upgrade to usage-based billing for more requests." } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 208b0d99c9..b5bfb870f6 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -44,7 +44,6 @@ ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true -proto.workspace = true release_channel.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index a88f12283a..18e6f47ed0 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; -use client::{Client, CloudUserStore, UserStore}; +use client::{Client, UserStore}; use collections::HashSet; use gpui::{App, Context, Entity}; use language_model::{LanguageModelProviderId, LanguageModelRegistry}; @@ -26,22 +26,11 @@ use crate::provider::vercel::VercelLanguageModelProvider; use crate::provider::x_ai::XAiLanguageModelProvider; pub use crate::settings::*; -pub fn init( - user_store: Entity, - cloud_user_store: Entity, - client: Arc, - cx: &mut App, -) { +pub fn init(user_store: Entity, client: Arc, cx: &mut App) { crate::settings::init_settings(cx); let registry = LanguageModelRegistry::global(cx); registry.update(cx, |registry, cx| { - register_language_model_providers( - registry, - user_store, - cloud_user_store, - client.clone(), - cx, - ); + register_language_model_providers(registry, user_store, client.clone(), cx); }); let mut openai_compatible_providers = AllLanguageModelSettings::get_global(cx) @@ -111,17 +100,11 @@ fn register_openai_compatible_providers( fn register_language_model_providers( registry: &mut LanguageModelRegistry, user_store: Entity, - cloud_user_store: Entity, client: Arc, cx: &mut Context, ) { registry.register_provider( - CloudLanguageModelProvider::new( - user_store.clone(), - cloud_user_store.clone(), - client.clone(), - cx, - ), + CloudLanguageModelProvider::new(user_store.clone(), client.clone(), cx), cx, ); diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index a5de7f3442..2108547c4f 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -2,7 +2,7 @@ use ai_onboarding::YoungAccountBanner; use anthropic::AnthropicModelMode; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; -use client::{Client, CloudUserStore, ModelRequestUsage, UserStore, zed_urls}; +use client::{Client, ModelRequestUsage, UserStore, zed_urls}; use cloud_llm_client::{ CLIENT_SUPPORTS_STATUS_MESSAGES_HEADER_NAME, CURRENT_PLAN_HEADER_NAME, CompletionBody, CompletionEvent, CompletionRequestStatus, CountTokensBody, CountTokensResponse, @@ -117,7 +117,6 @@ pub struct State { client: Arc, llm_api_token: LlmApiToken, user_store: Entity, - cloud_user_store: Entity, status: client::Status, accept_terms_of_service_task: Option>>, models: Vec>, @@ -133,17 +132,14 @@ impl State { fn new( client: Arc, user_store: Entity, - cloud_user_store: Entity, status: client::Status, cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); - Self { client: client.clone(), llm_api_token: LlmApiToken::default(), - user_store, - cloud_user_store, + user_store: user_store.clone(), status, accept_terms_of_service_task: None, models: Vec::new(), @@ -152,18 +148,12 @@ impl State { recommended_models: Vec::new(), _fetch_models_task: cx.spawn(async move |this, cx| { maybe!(async move { - let (client, cloud_user_store, llm_api_token) = - this.read_with(cx, |this, _cx| { - ( - client.clone(), - this.cloud_user_store.clone(), - this.llm_api_token.clone(), - ) - })?; + let (client, llm_api_token) = this + .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; loop { - let is_authenticated = - cloud_user_store.read_with(cx, |this, _cx| this.is_authenticated())?; + let is_authenticated = user_store + .read_with(cx, |user_store, _cx| user_store.current_user().is_some())?; if is_authenticated { break; } @@ -204,22 +194,19 @@ impl State { } fn is_signed_out(&self, cx: &App) -> bool { - !self.cloud_user_store.read(cx).is_authenticated() + self.user_store.read(cx).current_user().is_none() } fn authenticate(&self, cx: &mut Context) -> Task> { let client = self.client.clone(); cx.spawn(async move |state, cx| { - client - .authenticate_and_connect(true, &cx) - .await - .into_response()?; + client.sign_in_with_optional_connect(true, &cx).await?; state.update(cx, |_, cx| cx.notify()) }) } fn has_accepted_terms_of_service(&self, cx: &App) -> bool { - self.cloud_user_store.read(cx).has_accepted_tos() + self.user_store.read(cx).has_accepted_terms_of_service() } fn accept_terms_of_service(&mut self, cx: &mut Context) { @@ -303,24 +290,11 @@ impl State { } impl CloudLanguageModelProvider { - pub fn new( - user_store: Entity, - cloud_user_store: Entity, - client: Arc, - cx: &mut App, - ) -> Self { + pub fn new(user_store: Entity, client: Arc, cx: &mut App) -> Self { let mut status_rx = client.status(); let status = *status_rx.borrow(); - let state = cx.new(|cx| { - State::new( - client.clone(), - user_store.clone(), - cloud_user_store.clone(), - status, - cx, - ) - }); + let state = cx.new(|cx| State::new(client.clone(), user_store.clone(), status, cx)); let state_ref = state.downgrade(); let maintain_client_status = cx.spawn(async move |cx| { @@ -632,11 +606,6 @@ impl CloudLanguageModel { .and_then(|plan| plan.to_str().ok()) .and_then(|plan| cloud_llm_client::Plan::from_str(plan).ok()) { - let plan = match plan { - cloud_llm_client::Plan::ZedFree => proto::Plan::Free, - cloud_llm_client::Plan::ZedPro => proto::Plan::ZedPro, - cloud_llm_client::Plan::ZedProTrial => proto::Plan::ZedProTrial, - }; return Err(anyhow!(ModelRequestLimitReachedError { plan })); } } @@ -1281,15 +1250,15 @@ impl ConfigurationView { impl Render for ConfigurationView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let state = self.state.read(cx); - let cloud_user_store = state.cloud_user_store.read(cx); + let user_store = state.user_store.read(cx); ZedAiConfiguration { is_connected: !state.is_signed_out(cx), - plan: cloud_user_store.plan(), - subscription_period: cloud_user_store.subscription_period(), - eligible_for_trial: cloud_user_store.trial_started_at().is_none(), + plan: user_store.plan(), + subscription_period: user_store.subscription_period(), + eligible_for_trial: user_store.trial_started_at().is_none(), has_accepted_terms_of_service: state.has_accepted_terms_of_service(cx), - account_too_young: cloud_user_store.account_too_young(), + account_too_young: user_store.account_too_young(), accept_terms_of_service_in_progress: state.accept_terms_of_service_task.is_some(), accept_terms_of_service_callback: self.accept_terms_of_service_callback.clone(), sign_in_callback: self.sign_in_callback.clone(), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f567d29438..acd348f1a6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1390,10 +1390,7 @@ impl Project { fs: Arc, cx: AsyncApp, ) -> Result> { - client - .authenticate_and_connect(true, &cx) - .await - .into_response()?; + client.connect(true, &cx).await.into_response()?; let subscriptions = [ EntitySubscription::Project(client.subscribe_to_entity::(remote_id)?), diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 552ef915cb..426d87ad13 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -20,7 +20,7 @@ use crate::application_menu::{ use auto_update::AutoUpdateStatus; use call::ActiveCall; -use client::{Client, CloudUserStore, UserStore, zed_urls}; +use client::{Client, UserStore, zed_urls}; use cloud_llm_client::Plan; use gpui::{ Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, @@ -126,7 +126,6 @@ pub struct TitleBar { platform_titlebar: Entity, project: Entity, user_store: Entity, - cloud_user_store: Entity, client: Arc, workspace: WeakEntity, application_menu: Option>, @@ -180,11 +179,9 @@ impl Render for TitleBar { children.push(self.banner.clone().into_any_element()) } - let is_authenticated = self.cloud_user_store.read(cx).is_authenticated(); let status = self.client.status(); let status = &*status.borrow(); - - let show_sign_in = !is_authenticated || !matches!(status, client::Status::Connected { .. }); + let user = self.user_store.read(cx).current_user(); children.push( h_flex() @@ -194,10 +191,10 @@ impl Render for TitleBar { .children(self.render_call_controls(window, cx)) .children(self.render_connection_status(status, cx)) .when( - show_sign_in && TitleBarSettings::get_global(cx).show_sign_in, + user.is_none() && TitleBarSettings::get_global(cx).show_sign_in, |el| el.child(self.render_sign_in_button(cx)), ) - .when(is_authenticated, |parent| { + .when(user.is_some(), |parent| { parent.child(self.render_user_menu_button(cx)) }) .into_any_element(), @@ -248,7 +245,6 @@ impl TitleBar { ) -> Self { let project = workspace.project().clone(); let user_store = workspace.app_state().user_store.clone(); - let cloud_user_store = workspace.app_state().cloud_user_store.clone(); let client = workspace.app_state().client.clone(); let active_call = ActiveCall::global(cx); @@ -296,7 +292,6 @@ impl TitleBar { workspace: workspace.weak_handle(), project, user_store, - cloud_user_store, client, _subscriptions: subscriptions, banner, @@ -622,9 +617,8 @@ impl TitleBar { window .spawn(cx, async move |cx| { client - .authenticate_and_connect(true, &cx) + .sign_in_with_optional_connect(true, &cx) .await - .into_response() .notify_async_err(cx); }) .detach(); @@ -632,15 +626,15 @@ impl TitleBar { } pub fn render_user_menu_button(&mut self, cx: &mut Context) -> impl Element { - let cloud_user_store = self.cloud_user_store.read(cx); - if let Some(user) = cloud_user_store.authenticated_user() { - let has_subscription_period = cloud_user_store.subscription_period().is_some(); - let plan = cloud_user_store.plan().filter(|_| { + let user_store = self.user_store.read(cx); + if let Some(user) = user_store.current_user() { + let has_subscription_period = user_store.subscription_period().is_some(); + let plan = user_store.plan().filter(|_| { // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. has_subscription_period }); - let user_avatar = user.avatar_url.clone(); + let user_avatar = user.avatar_uri.clone(); let free_chip_bg = cx .theme() .colors() diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e9af72cd91..00f20cd2c9 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -15,7 +15,6 @@ mod toast_layer; mod toolbar; mod workspace_settings; -use client::CloudUserStore; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; use anyhow::{Context as _, Result, anyhow}; @@ -840,7 +839,6 @@ pub struct AppState { pub languages: Arc, pub client: Arc, pub user_store: Entity, - pub cloud_user_store: Entity, pub workspace_store: Entity, pub fs: Arc, pub build_window_options: fn(Option, &mut App) -> WindowOptions, @@ -913,8 +911,6 @@ impl AppState { let client = Client::new(clock, http_client.clone(), cx); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); theme::init(theme::LoadThemes::JustBase, cx); @@ -926,7 +922,6 @@ impl AppState { fs, languages, user_store, - cloud_user_store, workspace_store, node_runtime: NodeRuntime::unavailable(), build_window_options: |_, _| Default::default(), @@ -5694,16 +5689,12 @@ impl Workspace { let client = project.read(cx).client(); let user_store = project.read(cx).user_store(); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); - let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); let session = cx.new(|cx| AppSession::new(Session::test(), cx)); window.activate_window(); let app_state = Arc::new(AppState { languages: project.read(cx).languages().clone(), workspace_store, - cloud_user_store, client, user_store, fs: project.read(cx).fs().clone(), @@ -6902,10 +6893,13 @@ async fn join_channel_internal( match status { Status::Connecting | Status::Authenticating + | Status::Authenticated | Status::Reconnecting | Status::Reauthenticating => continue, Status::Connected { .. } => break 'outer, - Status::SignedOut => return Err(ErrorCode::SignedOut.into()), + Status::SignedOut | Status::AuthenticationError => { + return Err(ErrorCode::SignedOut.into()); + } Status::UpgradeRequired => return Err(ErrorCode::UpgradeRequired.into()), Status::ConnectionError | Status::ConnectionLost | Status::ReconnectionError { .. } => { return Err(ErrorCode::Disconnected.into()); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index cc7061de1d..b02c6af0b0 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -5,7 +5,7 @@ use agent_ui::AgentPanel; use anyhow::{Context as _, Result}; use clap::{Parser, command}; use cli::FORCE_CLI_MODE_ENV_VAR_NAME; -use client::{Client, CloudUserStore, ProxySettings, UserStore, parse_zed_link}; +use client::{Client, ProxySettings, UserStore, parse_zed_link}; use collab_ui::channel_view::ChannelView; use collections::HashMap; use db::kvp::{GLOBAL_KEY_VALUE_STORE, KEY_VALUE_STORE}; @@ -42,7 +42,7 @@ use theme::{ ActiveTheme, IconThemeNotFoundError, SystemAppearance, ThemeNotFoundError, ThemeRegistry, ThemeSettings, }; -use util::{ConnectionResult, ResultExt, TryFutureExt, maybe}; +use util::{ResultExt, TryFutureExt, maybe}; use uuid::Uuid; use welcome::{FIRST_OPEN, show_welcome_view}; use workspace::{ @@ -457,8 +457,6 @@ pub fn main() { language::init(cx); languages::init(languages.clone(), node_runtime.clone(), cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx)); language_extension::init( @@ -518,7 +516,6 @@ pub fn main() { languages: languages.clone(), client: client.clone(), user_store: user_store.clone(), - cloud_user_store, fs: fs.clone(), build_window_options, workspace_store, @@ -556,12 +553,7 @@ pub fn main() { ); supermaven::init(app_state.client.clone(), cx); language_model::init(app_state.client.clone(), cx); - language_models::init( - app_state.user_store.clone(), - app_state.cloud_user_store.clone(), - app_state.client.clone(), - cx, - ); + language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); agent_settings::init(cx); agent_servers::init(cx); web_search::init(cx); @@ -569,7 +561,7 @@ pub fn main() { snippet_provider::init(cx); inline_completion_registry::init( app_state.client.clone(), - app_state.cloud_user_store.clone(), + app_state.user_store.clone(), cx, ); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), stdout_is_a_pty(), cx); @@ -689,17 +681,9 @@ pub fn main() { cx.spawn({ let client = app_state.client.clone(); - async move |cx| match authenticate(client, &cx).await { - ConnectionResult::Timeout => log::error!("Timeout during initial auth"), - ConnectionResult::ConnectionReset => { - log::error!("Connection reset during initial auth") - } - ConnectionResult::Result(r) => { - r.log_err(); - } - } + async move |cx| authenticate(client, &cx).await }) - .detach(); + .detach_and_log_err(cx); let urls: Vec<_> = args .paths_or_urls @@ -849,15 +833,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut let client = app_state.client.clone(); // we continue even if authentication fails as join_channel/ open channel notes will // show a visible error message. - match authenticate(client, &cx).await { - ConnectionResult::Timeout => { - log::error!("Timeout during open request handling") - } - ConnectionResult::ConnectionReset => { - log::error!("Connection reset during open request handling") - } - ConnectionResult::Result(r) => r?, - }; + authenticate(client, &cx).await.log_err(); if let Some(channel_id) = request.join_channel { cx.update(|cx| { @@ -907,18 +883,18 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } } -async fn authenticate(client: Arc, cx: &AsyncApp) -> ConnectionResult<()> { +async fn authenticate(client: Arc, cx: &AsyncApp) -> Result<()> { if stdout_is_a_pty() { if client::IMPERSONATE_LOGIN.is_some() { - return client.authenticate_and_connect(false, cx).await; + client.sign_in_with_optional_connect(false, cx).await?; } else if client.has_credentials(cx).await { - return client.authenticate_and_connect(true, cx).await; + client.sign_in_with_optional_connect(true, cx).await?; } } else if client.has_credentials(cx).await { - return client.authenticate_and_connect(true, cx).await; + client.sign_in_with_optional_connect(true, cx).await?; } - ConnectionResult::Result(Ok(())) + Ok(()) } async fn system_id() -> Result { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 91613049f4..c72fe39d2d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -336,7 +336,7 @@ pub fn initialize_workspace( let edit_prediction_button = cx.new(|cx| { inline_completion_button::InlineCompletionButton::new( app_state.fs.clone(), - app_state.cloud_user_store.clone(), + app_state.user_store.clone(), inline_completion_menu_handle.clone(), cx, ) @@ -4487,12 +4487,7 @@ mod tests { ); image_viewer::init(cx); language_model::init(app_state.client.clone(), cx); - language_models::init( - app_state.user_store.clone(), - app_state.cloud_user_store.clone(), - app_state.client.clone(), - cx, - ); + language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx); diff --git a/crates/zed/src/zed/component_preview.rs b/crates/zed/src/zed/component_preview.rs index 2e57152c62..480505338b 100644 --- a/crates/zed/src/zed/component_preview.rs +++ b/crates/zed/src/zed/component_preview.rs @@ -139,8 +139,7 @@ impl ComponentPreview { let project_clone = project.clone(); cx.spawn_in(window, async move |entity, cx| { - let thread_store_future = - load_preview_thread_store(workspace_clone.clone(), project_clone.clone(), cx); + let thread_store_future = load_preview_thread_store(project_clone.clone(), cx); let text_thread_store_future = load_preview_text_thread_store(workspace_clone.clone(), project_clone.clone(), cx); diff --git a/crates/zed/src/zed/component_preview/preview_support/active_thread.rs b/crates/zed/src/zed/component_preview/preview_support/active_thread.rs index 1076ee49ea..de98106fae 100644 --- a/crates/zed/src/zed/component_preview/preview_support/active_thread.rs +++ b/crates/zed/src/zed/component_preview/preview_support/active_thread.rs @@ -12,22 +12,19 @@ use ui::{App, Window}; use workspace::Workspace; pub fn load_preview_thread_store( - workspace: WeakEntity, project: Entity, cx: &mut AsyncApp, ) -> Task>> { - workspace - .update(cx, |workspace, cx| { - ThreadStore::load( - project.clone(), - workspace.app_state().cloud_user_store.clone(), - cx.new(|_| ToolWorkingSet::default()), - None, - Arc::new(PromptBuilder::new(None).unwrap()), - cx, - ) - }) - .unwrap_or(Task::ready(Err(anyhow!("workspace dropped")))) + cx.update(|cx| { + ThreadStore::load( + project.clone(), + cx.new(|_| ToolWorkingSet::default()), + None, + Arc::new(PromptBuilder::new(None).unwrap()), + cx, + ) + }) + .unwrap_or(Task::ready(Err(anyhow!("workspace dropped")))) } pub fn load_preview_text_thread_store( diff --git a/crates/zed/src/zed/inline_completion_registry.rs b/crates/zed/src/zed/inline_completion_registry.rs index 89d6ff054b..378c172078 100644 --- a/crates/zed/src/zed/inline_completion_registry.rs +++ b/crates/zed/src/zed/inline_completion_registry.rs @@ -1,4 +1,4 @@ -use client::{Client, CloudUserStore}; +use client::{Client, UserStore}; use collections::HashMap; use copilot::{Copilot, CopilotCompletionProvider}; use editor::Editor; @@ -14,12 +14,12 @@ use util::ResultExt; use workspace::Workspace; use zeta::{ProviderDataCollection, ZetaInlineCompletionProvider}; -pub fn init(client: Arc, cloud_user_store: Entity, cx: &mut App) { +pub fn init(client: Arc, user_store: Entity, cx: &mut App) { let editors: Rc, AnyWindowHandle>>> = Rc::default(); cx.observe_new({ let editors = editors.clone(); let client = client.clone(); - let cloud_user_store = cloud_user_store.clone(); + let user_store = user_store.clone(); move |editor: &mut Editor, window, cx: &mut Context| { if !editor.mode().is_full() { return; @@ -49,7 +49,7 @@ pub fn init(client: Arc, cloud_user_store: Entity, cx: & editor, provider, &client, - cloud_user_store.clone(), + user_store.clone(), window, cx, ); @@ -61,7 +61,7 @@ pub fn init(client: Arc, cloud_user_store: Entity, cx: & let mut provider = all_language_settings(None, cx).edit_predictions.provider; cx.spawn({ - let cloud_user_store = cloud_user_store.clone(); + let user_store = user_store.clone(); let editors = editors.clone(); let client = client.clone(); @@ -73,7 +73,7 @@ pub fn init(client: Arc, cloud_user_store: Entity, cx: & &editors, provider, &client, - cloud_user_store.clone(), + user_store.clone(), cx, ); }) @@ -86,12 +86,12 @@ pub fn init(client: Arc, cloud_user_store: Entity, cx: & cx.observe_global::({ let editors = editors.clone(); let client = client.clone(); - let cloud_user_store = cloud_user_store.clone(); + let user_store = user_store.clone(); move |cx| { let new_provider = all_language_settings(None, cx).edit_predictions.provider; if new_provider != provider { - let tos_accepted = cloud_user_store.read(cx).has_accepted_tos(); + let tos_accepted = user_store.read(cx).has_accepted_terms_of_service(); telemetry::event!( "Edit Prediction Provider Changed", @@ -105,7 +105,7 @@ pub fn init(client: Arc, cloud_user_store: Entity, cx: & &editors, provider, &client, - cloud_user_store.clone(), + user_store.clone(), cx, ); @@ -146,7 +146,7 @@ fn assign_edit_prediction_providers( editors: &Rc, AnyWindowHandle>>>, provider: EditPredictionProvider, client: &Arc, - cloud_user_store: Entity, + user_store: Entity, cx: &mut App, ) { for (editor, window) in editors.borrow().iter() { @@ -156,7 +156,7 @@ fn assign_edit_prediction_providers( editor, provider, &client, - cloud_user_store.clone(), + user_store.clone(), window, cx, ); @@ -211,7 +211,7 @@ fn assign_edit_prediction_provider( editor: &mut Editor, provider: EditPredictionProvider, client: &Arc, - cloud_user_store: Entity, + user_store: Entity, window: &mut Window, cx: &mut Context, ) { @@ -242,7 +242,7 @@ fn assign_edit_prediction_provider( } } EditPredictionProvider::Zed => { - if cloud_user_store.read(cx).is_authenticated() { + if user_store.read(cx).current_user().is_some() { let mut worktree = None; if let Some(buffer) = &singleton_buffer { @@ -264,7 +264,7 @@ fn assign_edit_prediction_provider( .map(|workspace| workspace.downgrade()); let zeta = - zeta::Zeta::register(workspace, worktree, client.clone(), cloud_user_store, cx); + zeta::Zeta::register(workspace, worktree, client.clone(), user_store, cx); if let Some(buffer) = &singleton_buffer { if buffer.read(cx).file().is_some() { diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 0ef6bef59d..18b9217b95 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -16,7 +16,7 @@ pub use rate_completion_modal::*; use anyhow::{Context as _, Result, anyhow}; use arrayvec::ArrayVec; -use client::{Client, CloudUserStore, EditPredictionUsage}; +use client::{Client, EditPredictionUsage, UserStore}; use cloud_llm_client::{ AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsBody, PredictEditsResponse, ZED_VERSION_HEADER_NAME, @@ -120,8 +120,8 @@ impl Dismissable for ZedPredictUpsell { } } -pub fn should_show_upsell_modal(cloud_user_store: &Entity, cx: &App) -> bool { - if cloud_user_store.read(cx).has_accepted_tos() { +pub fn should_show_upsell_modal(user_store: &Entity, cx: &App) -> bool { + if user_store.read(cx).has_accepted_terms_of_service() { !ZedPredictUpsell::dismissed() } else { true @@ -229,7 +229,7 @@ pub struct Zeta { _llm_token_subscription: Subscription, /// Whether an update to a newer version of Zed is required to continue using Zeta. update_required: bool, - cloud_user_store: Entity, + user_store: Entity, license_detection_watchers: HashMap>, } @@ -242,11 +242,11 @@ impl Zeta { workspace: Option>, worktree: Option>, client: Arc, - cloud_user_store: Entity, + user_store: Entity, cx: &mut App, ) -> Entity { let this = Self::global(cx).unwrap_or_else(|| { - let entity = cx.new(|cx| Self::new(workspace, client, cloud_user_store, cx)); + let entity = cx.new(|cx| Self::new(workspace, client, user_store, cx)); cx.set_global(ZetaGlobal(entity.clone())); entity }); @@ -269,13 +269,13 @@ impl Zeta { } pub fn usage(&self, cx: &App) -> Option { - self.cloud_user_store.read(cx).edit_prediction_usage() + self.user_store.read(cx).edit_prediction_usage() } fn new( workspace: Option>, client: Arc, - cloud_user_store: Entity, + user_store: Entity, cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); @@ -306,7 +306,7 @@ impl Zeta { ), update_required: false, license_detection_watchers: HashMap::default(), - cloud_user_store, + user_store, } } @@ -535,8 +535,8 @@ impl Zeta { if let Some(usage) = usage { this.update(cx, |this, cx| { - this.cloud_user_store.update(cx, |cloud_user_store, cx| { - cloud_user_store.update_edit_prediction_usage(usage, cx); + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); }); }) .ok(); @@ -877,8 +877,8 @@ and then another if response.status().is_success() { if let Some(usage) = EditPredictionUsage::from_headers(response.headers()).ok() { this.update(cx, |this, cx| { - this.cloud_user_store.update(cx, |cloud_user_store, cx| { - cloud_user_store.update_edit_prediction_usage(usage, cx); + this.user_store.update(cx, |user_store, cx| { + user_store.update_edit_prediction_usage(usage, cx); }); })?; } @@ -1559,9 +1559,9 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider !self .zeta .read(cx) - .cloud_user_store + .user_store .read(cx) - .has_accepted_tos() + .has_accepted_terms_of_service() } fn is_refreshing(&self) -> bool { @@ -1587,7 +1587,7 @@ impl inline_completion::EditPredictionProvider for ZetaInlineCompletionProvider if self .zeta .read(cx) - .cloud_user_store + .user_store .read_with(cx, |cloud_user_store, _cx| { cloud_user_store.account_too_young() || cloud_user_store.has_overdue_invoices() }) @@ -1808,10 +1808,7 @@ mod tests { use client::UserStore; use client::test::FakeServer; use clock::FakeSystemClock; - use cloud_api_types::{ - AuthenticatedUser, CreateLlmTokenResponse, GetAuthenticatedUserResponse, LlmToken, PlanInfo, - }; - use cloud_llm_client::{CurrentUsage, Plan, UsageData, UsageLimit}; + use cloud_api_types::{CreateLlmTokenResponse, LlmToken}; use gpui::TestAppContext; use http_client::FakeHttpClient; use indoc::indoc; @@ -1820,39 +1817,6 @@ mod tests { use super::*; - fn make_get_authenticated_user_response() -> GetAuthenticatedUserResponse { - GetAuthenticatedUserResponse { - user: AuthenticatedUser { - id: 1, - metrics_id: "metrics-id-1".to_string(), - avatar_url: "".to_string(), - github_login: "".to_string(), - name: None, - is_staff: false, - accepted_tos_at: None, - }, - feature_flags: vec![], - plan: PlanInfo { - plan: Plan::ZedPro, - subscription_period: None, - usage: CurrentUsage { - model_requests: UsageData { - used: 0, - limit: UsageLimit::Limited(500), - }, - edit_predictions: UsageData { - used: 250, - limit: UsageLimit::Unlimited, - }, - }, - trial_started_at: None, - is_usage_based_billing_enabled: false, - is_account_too_young: false, - has_overdue_invoices: false, - }, - } - } - #[gpui::test] async fn test_inline_completion_basic_interpolation(cx: &mut TestAppContext) { let buffer = cx.new(|cx| Buffer::local("Lorem ipsum dolor", cx)); @@ -2054,14 +2018,6 @@ mod tests { let http_client = FakeHttpClient::create(move |req| async move { match (req.method(), req.uri().path()) { - (&Method::GET, "/client/users/me") => Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&make_get_authenticated_user_response()) - .unwrap() - .into(), - ) - .unwrap()), (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() .status(200) .body( @@ -2098,9 +2054,7 @@ mod tests { // Construct the fake server to authenticate. let _server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(None, client, cloud_user_store, cx)); + let zeta = cx.new(|cx| Zeta::new(None, client, user_store.clone(), cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0))); @@ -2128,14 +2082,6 @@ mod tests { let completion = completion_response.clone(); async move { match (req.method(), req.uri().path()) { - (&Method::GET, "/client/users/me") => Ok(http_client::Response::builder() - .status(200) - .body( - serde_json::to_string(&make_get_authenticated_user_response()) - .unwrap() - .into(), - ) - .unwrap()), (&Method::POST, "/client/llm_tokens") => Ok(http_client::Response::builder() .status(200) .body( @@ -2172,9 +2118,7 @@ mod tests { // Construct the fake server to authenticate. let _server = FakeServer::for_client(42, &client, cx).await; let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - let cloud_user_store = - cx.new(|cx| CloudUserStore::new(client.cloud_client(), user_store.clone(), cx)); - let zeta = cx.new(|cx| Zeta::new(None, client, cloud_user_store, cx)); + let zeta = cx.new(|cx| Zeta::new(None, client, user_store.clone(), cx)); let buffer = cx.new(|cx| Buffer::local(buffer_content, cx)); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); From 11b91c07eb0648740bbae53fb45ff506ba51e761 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 8 Aug 2025 10:23:58 -0400 Subject: [PATCH 38/56] Format --- crates/agent_ui/src/agent_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 78c32e2af7..f6369bb3b0 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -43,7 +43,7 @@ use anyhow::{Result, anyhow}; use assistant_context::{AssistantContext, ContextEvent, ContextSummary}; use assistant_slash_command::SlashCommandWorkingSet; use assistant_tool::ToolWorkingSet; -use client::{ UserStore, zed_urls}; +use client::{UserStore, zed_urls}; use cloud_llm_client::{CompletionIntent, Plan, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; use feature_flags::{self, FeatureFlagAppExt}; From ec8b5e2dd44f6f9b4a703d8330752d306d550bd3 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 4 Aug 2025 10:41:23 +0200 Subject: [PATCH 39/56] Don't trigger authentication flow unless credentials expired (#35570) This fixes a regression introduced in https://github.com/zed-industries/zed/pull/35471, where we treated stored credentials as invalid when failing to retrieve the authenticated user for any reason. This had the side effect of triggering the auth flow even when e.g. the client/server had temporary networking issues. This pull request changes the logic to only trigger authentication when getting a 401 from the server. Release Notes: - N/A --- crates/client/src/client.rs | 102 +++++++++++++++--- .../cloud_api_client/src/cloud_api_client.rs | 69 ++++++++---- 2 files changed, 137 insertions(+), 34 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 2fd343903d..309e4d892f 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -856,27 +856,28 @@ impl Client { let old_credentials = self.state.read().credentials.clone(); if let Some(old_credentials) = old_credentials { - self.cloud_client.set_credentials( - old_credentials.user_id as u32, - old_credentials.access_token.clone(), - ); - - // Fetch the authenticated user with the old credentials, to ensure they are still valid. - if self.cloud_client.get_authenticated_user().await.is_ok() { + if self + .cloud_client + .validate_credentials( + old_credentials.user_id as u32, + &old_credentials.access_token, + ) + .await? + { credentials = Some(old_credentials); } } if credentials.is_none() && try_provider { if let Some(stored_credentials) = self.credentials_provider.read_credentials(cx).await { - self.cloud_client.set_credentials( - stored_credentials.user_id as u32, - stored_credentials.access_token.clone(), - ); - - // Fetch the authenticated user with the stored credentials, and - // clear them from the credentials provider if that fails. - if self.cloud_client.get_authenticated_user().await.is_ok() { + if self + .cloud_client + .validate_credentials( + stored_credentials.user_id as u32, + &stored_credentials.access_token, + ) + .await? + { credentials = Some(stored_credentials); } else { self.credentials_provider @@ -1681,7 +1682,7 @@ pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> { #[cfg(test)] mod tests { use super::*; - use crate::test::FakeServer; + use crate::test::{FakeServer, parse_authorization_header}; use clock::FakeSystemClock; use gpui::{AppContext as _, BackgroundExecutor, TestAppContext}; @@ -1807,6 +1808,75 @@ mod tests { )); } + #[gpui::test(iterations = 10)] + async fn test_reauthenticate_only_if_unauthorized(cx: &mut TestAppContext) { + init_test(cx); + let auth_count = Arc::new(Mutex::new(0)); + let http_client = FakeHttpClient::create(|_request| async move { + Ok(http_client::Response::builder() + .status(200) + .body("".into()) + .unwrap()) + }); + let client = + cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client.clone(), cx)); + client.override_authenticate({ + let auth_count = auth_count.clone(); + move |cx| { + let auth_count = auth_count.clone(); + cx.background_spawn(async move { + *auth_count.lock() += 1; + Ok(Credentials { + user_id: 1, + access_token: auth_count.lock().to_string(), + }) + }) + } + }); + + let credentials = client.sign_in(false, &cx.to_async()).await.unwrap(); + assert_eq!(*auth_count.lock(), 1); + assert_eq!(credentials.access_token, "1"); + + // If credentials are still valid, signing in doesn't trigger authentication. + let credentials = client.sign_in(false, &cx.to_async()).await.unwrap(); + assert_eq!(*auth_count.lock(), 1); + assert_eq!(credentials.access_token, "1"); + + // If the server is unavailable, signing in doesn't trigger authentication. + http_client + .as_fake() + .replace_handler(|_, _request| async move { + Ok(http_client::Response::builder() + .status(503) + .body("".into()) + .unwrap()) + }); + client.sign_in(false, &cx.to_async()).await.unwrap_err(); + assert_eq!(*auth_count.lock(), 1); + + // If credentials became invalid, signing in triggers authentication. + http_client + .as_fake() + .replace_handler(|_, request| async move { + let credentials = parse_authorization_header(&request).unwrap(); + if credentials.access_token == "2" { + Ok(http_client::Response::builder() + .status(200) + .body("".into()) + .unwrap()) + } else { + Ok(http_client::Response::builder() + .status(401) + .body("".into()) + .unwrap()) + } + }); + let credentials = client.sign_in(false, &cx.to_async()).await.unwrap(); + assert_eq!(*auth_count.lock(), 2); + assert_eq!(credentials.access_token, "2"); + } + #[gpui::test(iterations = 10)] async fn test_authenticating_more_than_once( cx: &mut TestAppContext, diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index 6689475dae..edac051a0e 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -1,10 +1,10 @@ use std::sync::Arc; -use anyhow::{Result, anyhow}; +use anyhow::{Context, Result, anyhow}; pub use cloud_api_types::*; use futures::AsyncReadExt as _; use http_client::http::request; -use http_client::{AsyncBody, HttpClientWithUrl, Method, Request}; +use http_client::{AsyncBody, HttpClientWithUrl, Method, Request, StatusCode}; use parking_lot::RwLock; struct Credentials { @@ -40,27 +40,14 @@ impl CloudApiClient { *self.credentials.write() = None; } - fn authorization_header(&self) -> Result { - let guard = self.credentials.read(); - let credentials = guard - .as_ref() - .ok_or_else(|| anyhow!("No credentials provided"))?; - - Ok(format!( - "{} {}", - credentials.user_id, credentials.access_token - )) - } - fn build_request( &self, req: request::Builder, body: impl Into, ) -> Result> { - Ok(req - .header("Content-Type", "application/json") - .header("Authorization", self.authorization_header()?) - .body(body.into())?) + let credentials = self.credentials.read(); + let credentials = credentials.as_ref().context("no credentials provided")?; + build_request(req, body, credentials) } pub async fn get_authenticated_user(&self) -> Result { @@ -152,4 +139,50 @@ impl CloudApiClient { Ok(serde_json::from_str(&body)?) } + + pub async fn validate_credentials(&self, user_id: u32, access_token: &str) -> Result { + let request = build_request( + Request::builder().method(Method::GET).uri( + self.http_client + .build_zed_cloud_url("/client/users/me", &[])? + .as_ref(), + ), + AsyncBody::default(), + &Credentials { + user_id, + access_token: access_token.into(), + }, + )?; + + let mut response = self.http_client.send(request).await?; + + if response.status().is_success() { + Ok(true) + } else { + let mut body = String::new(); + response.body_mut().read_to_string(&mut body).await?; + if response.status() == StatusCode::UNAUTHORIZED { + return Ok(false); + } else { + return Err(anyhow!( + "Failed to get authenticated user.\nStatus: {:?}\nBody: {body}", + response.status() + )); + } + } + } +} + +fn build_request( + req: request::Builder, + body: impl Into, + credentials: &Credentials, +) -> Result> { + Ok(req + .header("Content-Type", "application/json") + .header( + "Authorization", + format!("{} {}", credentials.user_id, credentials.access_token), + ) + .body(body.into())?) } From 0dad4b7a41190afcbdc03cfad12d066ece7875a4 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Tue, 5 Aug 2025 11:33:33 +0200 Subject: [PATCH 40/56] Ensure client reconnects if an error occurs during authentication (#35629) In #35471, we added a new `AuthenticationError` variant to the client enum `Status`, but the reconnection logic was ignoring it when determining whether to reconnect. This pull request fixes that regression and introduces test coverage for this case. Release Notes: - N/A --- crates/client/src/client.rs | 81 +++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 309e4d892f..b4894cddcf 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -687,7 +687,10 @@ impl Client { } } - if matches!(*client.status().borrow(), Status::ConnectionError) { + if matches!( + *client.status().borrow(), + Status::AuthenticationError | Status::ConnectionError + ) { client.set_status( Status::ReconnectionError { next_reconnection: Instant::now() + delay, @@ -856,28 +859,14 @@ impl Client { let old_credentials = self.state.read().credentials.clone(); if let Some(old_credentials) = old_credentials { - if self - .cloud_client - .validate_credentials( - old_credentials.user_id as u32, - &old_credentials.access_token, - ) - .await? - { + if self.validate_credentials(&old_credentials, cx).await? { credentials = Some(old_credentials); } } if credentials.is_none() && try_provider { if let Some(stored_credentials) = self.credentials_provider.read_credentials(cx).await { - if self - .cloud_client - .validate_credentials( - stored_credentials.user_id as u32, - &stored_credentials.access_token, - ) - .await? - { + if self.validate_credentials(&stored_credentials, cx).await? { credentials = Some(stored_credentials); } else { self.credentials_provider @@ -926,6 +915,24 @@ impl Client { Ok(credentials) } + async fn validate_credentials( + self: &Arc, + credentials: &Credentials, + cx: &AsyncApp, + ) -> Result { + match self + .cloud_client + .validate_credentials(credentials.user_id as u32, &credentials.access_token) + .await + { + Ok(valid) => Ok(valid), + Err(err) => { + self.set_status(Status::AuthenticationError, cx); + Err(anyhow!("failed to validate credentials: {}", err)) + } + } + } + /// Performs a sign-in and also connects to Collab. /// /// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls @@ -1733,6 +1740,46 @@ mod tests { assert_eq!(server.auth_count(), 2); // Client re-authenticated due to an invalid token } + #[gpui::test(iterations = 10)] + async fn test_auth_failure_during_reconnection(cx: &mut TestAppContext) { + init_test(cx); + let http_client = FakeHttpClient::with_200_response(); + let client = + cx.update(|cx| Client::new(Arc::new(FakeSystemClock::new()), http_client.clone(), cx)); + let server = FakeServer::for_client(42, &client, cx).await; + let mut status = client.status(); + assert!(matches!( + status.next().await, + Some(Status::Connected { .. }) + )); + assert_eq!(server.auth_count(), 1); + + // Simulate an auth failure during reconnection. + http_client + .as_fake() + .replace_handler(|_, _request| async move { + Ok(http_client::Response::builder() + .status(503) + .body("".into()) + .unwrap()) + }); + server.disconnect(); + while !matches!(status.next().await, Some(Status::ReconnectionError { .. })) {} + + // Restore the ability to authenticate. + http_client + .as_fake() + .replace_handler(|_, _request| async move { + Ok(http_client::Response::builder() + .status(200) + .body("".into()) + .unwrap()) + }); + cx.executor().advance_clock(Duration::from_secs(10)); + while !matches!(status.next().await, Some(Status::Connected { .. })) {} + assert_eq!(server.auth_count(), 1); // Client reused the cached credentials when reconnecting + } + #[gpui::test(iterations = 10)] async fn test_connection_timeout(executor: BackgroundExecutor, cx: &mut TestAppContext) { init_test(cx); From 53a3270410fb47f588ec3e7e25c6962c8dc5ba7a Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Wed, 6 Aug 2025 12:04:07 +0200 Subject: [PATCH 41/56] Fetch models right after signing in (#35711) This uses the `current_user` watch in the `UserStore` instead of looping every 100ms in order to detect if the user had signed in. We are changing this because we noticed it was causing the deterministic executor in tests to never detect a "parking with nothing left to run" situation. This seems better in production as well, especially for users who never sign in. /cc @maxdeviant Release Notes: - N/A Co-authored-by: Ben Brandt --- crates/language_models/src/provider/cloud.rs | 21 +++++++------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 2108547c4f..60733993ba 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -136,6 +136,7 @@ impl State { cx: &mut Context, ) -> Self { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); + let mut current_user = user_store.read(cx).watch_current_user(); Self { client: client.clone(), llm_api_token: LlmApiToken::default(), @@ -151,22 +152,14 @@ impl State { let (client, llm_api_token) = this .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; - loop { - let is_authenticated = user_store - .read_with(cx, |user_store, _cx| user_store.current_user().is_some())?; - if is_authenticated { - break; - } - - cx.background_executor() - .timer(Duration::from_millis(100)) - .await; + while current_user.borrow().is_none() { + current_user.next().await; } - let response = Self::fetch_models(client, llm_api_token).await?; - this.update(cx, |this, cx| { - this.update_models(response, cx); - }) + let response = + Self::fetch_models(client.clone(), llm_api_token.clone()).await?; + this.update(cx, |this, cx| this.update_models(response, cx))?; + anyhow::Ok(()) }) .await .context("failed to fetch Zed models") From 6ac4a57fcea92c235408fd13ea5758697c199e90 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 6 Aug 2025 19:20:04 -0400 Subject: [PATCH 42/56] cloud_api_types: Add types for WebSocket protocol (#35753) This PR adds types for the Cloud WebSocket protocol to the `cloud_api_types` crate. Release Notes: - N/A --- Cargo.lock | 2 ++ Cargo.toml | 1 + crates/cloud_api_types/Cargo.toml | 2 ++ crates/cloud_api_types/src/cloud_api_types.rs | 1 + .../cloud_api_types/src/websocket_protocol.rs | 28 +++++++++++++++++++ 5 files changed, 34 insertions(+) create mode 100644 crates/cloud_api_types/src/websocket_protocol.rs diff --git a/Cargo.lock b/Cargo.lock index 83703cbe18..e941e43faa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3036,7 +3036,9 @@ dependencies = [ name = "cloud_api_types" version = "0.1.0" dependencies = [ + "anyhow", "chrono", + "ciborium", "cloud_llm_client", "pretty_assertions", "serde", diff --git a/Cargo.toml b/Cargo.toml index cf1ee5956f..ebb649ed49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -454,6 +454,7 @@ bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" chrono = { version = "0.4", features = ["serde"] } +ciborium = "0.2" circular-buffer = "1.0" clap = { version = "4.4", features = ["derive"] } cocoa = "0.26" diff --git a/crates/cloud_api_types/Cargo.toml b/crates/cloud_api_types/Cargo.toml index 868797df3b..28e0a36a44 100644 --- a/crates/cloud_api_types/Cargo.toml +++ b/crates/cloud_api_types/Cargo.toml @@ -12,7 +12,9 @@ workspace = true path = "src/cloud_api_types.rs" [dependencies] +anyhow.workspace = true chrono.workspace = true +ciborium.workspace = true cloud_llm_client.workspace = true serde.workspace = true workspace-hack.workspace = true diff --git a/crates/cloud_api_types/src/cloud_api_types.rs b/crates/cloud_api_types/src/cloud_api_types.rs index b38b38cde1..fa189cd3b5 100644 --- a/crates/cloud_api_types/src/cloud_api_types.rs +++ b/crates/cloud_api_types/src/cloud_api_types.rs @@ -1,4 +1,5 @@ mod timestamp; +pub mod websocket_protocol; use serde::{Deserialize, Serialize}; diff --git a/crates/cloud_api_types/src/websocket_protocol.rs b/crates/cloud_api_types/src/websocket_protocol.rs new file mode 100644 index 0000000000..c90d09e370 --- /dev/null +++ b/crates/cloud_api_types/src/websocket_protocol.rs @@ -0,0 +1,28 @@ +use anyhow::{Context as _, Result}; +use serde::{Deserialize, Serialize}; + +/// The version of the Cloud WebSocket protocol. +pub const PROTOCOL_VERSION: u32 = 0; + +/// The name of the header used to indicate the protocol version in use. +pub const PROTOCOL_VERSION_HEADER_NAME: &str = "x-zed-protocol-version"; + +/// A message from Cloud to the Zed client. +#[derive(Serialize, Deserialize)] +pub enum MessageToClient { + /// The user was updated and should be refreshed. + UserUpdated, +} + +impl MessageToClient { + pub fn serialize(&self) -> Result> { + let mut buffer = Vec::new(); + ciborium::into_writer(self, &mut buffer).context("failed to serialize message")?; + + Ok(buffer) + } + + pub fn deserialize(data: &[u8]) -> Result { + ciborium::from_reader(data).context("failed to deserialize message") + } +} From c1056991e3bfabaea1a3784099ea0f6d3e6b3418 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 6 Aug 2025 21:28:41 -0400 Subject: [PATCH 43/56] Establish WebSocket connection to Cloud (#35734) This PR adds a new WebSocket connection to Cloud. This connection will be used to push down notifications from the server to the client. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- Cargo.lock | 50 ++++++++++++- Cargo.toml | 3 + crates/client/src/client.rs | 35 +++++++++ crates/cloud_api_client/Cargo.toml | 3 + .../cloud_api_client/src/cloud_api_client.rs | 43 +++++++++++ crates/cloud_api_client/src/websocket.rs | 73 +++++++++++++++++++ .../cloud_api_types/src/websocket_protocol.rs | 2 +- tooling/workspace-hack/Cargo.toml | 52 +++++++++---- 8 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 crates/cloud_api_client/src/websocket.rs diff --git a/Cargo.lock b/Cargo.lock index e941e43faa..7a8be61412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1366,7 +1366,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 7.1.3", "num-rational", "v_frame", ] @@ -2740,7 +2740,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -3026,10 +3026,13 @@ dependencies = [ "anyhow", "cloud_api_types", "futures 0.3.31", + "gpui", + "gpui_tokio", "http_client", "parking_lot", "serde_json", "workspace-hack", + "yawc", ] [[package]] @@ -10477,6 +10480,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -15200,7 +15212,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" dependencies = [ - "nom", + "nom 7.1.3", "unicode_categories", ] @@ -19747,7 +19759,9 @@ dependencies = [ "heck 0.4.1", "hmac", "hyper 0.14.32", + "hyper 1.6.0", "hyper-rustls 0.27.5", + "hyper-util", "idna", "indexmap", "inout", @@ -19768,7 +19782,7 @@ dependencies = [ "mio", "naga", "nix 0.29.0", - "nom", + "nom 7.1.3", "num-bigint", "num-bigint-dig", "num-integer", @@ -20103,6 +20117,34 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yawc" +version = "0.2.4" +source = "git+https://github.com/deviant-forks/yawc?rev=1899688f3e69ace4545aceb97b2a13881cf26142#1899688f3e69ace4545aceb97b2a13881cf26142" +dependencies = [ + "base64 0.22.1", + "bytes 1.10.1", + "flate2", + "futures 0.3.31", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "js-sys", + "nom 8.0.0", + "pin-project", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.26.2", + "tokio-util", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "yazi" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index ebb649ed49..cff020b659 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -651,6 +651,9 @@ which = "6.0.0" windows-core = "0.61" wit-component = "0.221" workspace-hack = "0.1.0" +# We can switch back to the published version once https://github.com/infinitefield/yawc/pull/16 is merged and a new +# version is released. +yawc = { git = "https://github.com/deviant-forks/yawc", rev = "1899688f3e69ace4545aceb97b2a13881cf26142" } zstd = "0.11" [workspace.dependencies.async-stripe] diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index b4894cddcf..0480ed1c3e 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -14,6 +14,7 @@ use async_tungstenite::tungstenite::{ }; use clock::SystemClock; use cloud_api_client::CloudApiClient; +use cloud_api_client::websocket_protocol::MessageToClient; use credentials_provider::CredentialsProvider; use futures::{ AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt, @@ -933,6 +934,32 @@ impl Client { } } + /// Establishes a WebSocket connection with Cloud for receiving updates from the server. + async fn connect_to_cloud(self: &Arc, cx: &AsyncApp) -> Result<()> { + let connect_task = cx.update({ + let cloud_client = self.cloud_client.clone(); + move |cx| cloud_client.connect(cx) + })??; + let connection = connect_task.await?; + + let (mut messages, task) = cx.update(|cx| connection.spawn(cx))?; + task.detach(); + + cx.spawn({ + let this = self.clone(); + async move |cx| { + while let Some(message) = messages.next().await { + if let Some(message) = message.log_err() { + this.handle_message_to_client(message, cx); + } + } + } + }) + .detach(); + + Ok(()) + } + /// Performs a sign-in and also connects to Collab. /// /// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls @@ -944,6 +971,8 @@ impl Client { ) -> Result<()> { let credentials = self.sign_in(try_provider, cx).await?; + self.connect_to_cloud(cx).await.log_err(); + let connect_result = match self.connect_with_credentials(credentials, cx).await { ConnectionResult::Timeout => Err(anyhow!("connection timed out")), ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")), @@ -1622,6 +1651,12 @@ impl Client { } } + fn handle_message_to_client(self: &Arc, message: MessageToClient, _cx: &AsyncApp) { + match message { + MessageToClient::UserUpdated => {} + } + } + pub fn telemetry(&self) -> &Arc { &self.telemetry } diff --git a/crates/cloud_api_client/Cargo.toml b/crates/cloud_api_client/Cargo.toml index d56aa94c6e..8e50ccb191 100644 --- a/crates/cloud_api_client/Cargo.toml +++ b/crates/cloud_api_client/Cargo.toml @@ -15,7 +15,10 @@ path = "src/cloud_api_client.rs" anyhow.workspace = true cloud_api_types.workspace = true futures.workspace = true +gpui.workspace = true +gpui_tokio.workspace = true http_client.workspace = true parking_lot.workspace = true serde_json.workspace = true workspace-hack.workspace = true +yawc.workspace = true diff --git a/crates/cloud_api_client/src/cloud_api_client.rs b/crates/cloud_api_client/src/cloud_api_client.rs index edac051a0e..ef9a1a9a55 100644 --- a/crates/cloud_api_client/src/cloud_api_client.rs +++ b/crates/cloud_api_client/src/cloud_api_client.rs @@ -1,11 +1,19 @@ +mod websocket; + use std::sync::Arc; use anyhow::{Context, Result, anyhow}; +use cloud_api_types::websocket_protocol::{PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER_NAME}; pub use cloud_api_types::*; use futures::AsyncReadExt as _; +use gpui::{App, Task}; +use gpui_tokio::Tokio; use http_client::http::request; use http_client::{AsyncBody, HttpClientWithUrl, Method, Request, StatusCode}; use parking_lot::RwLock; +use yawc::WebSocket; + +use crate::websocket::Connection; struct Credentials { user_id: u32, @@ -78,6 +86,41 @@ impl CloudApiClient { Ok(serde_json::from_str(&body)?) } + pub fn connect(&self, cx: &App) -> Result>> { + let mut connect_url = self + .http_client + .build_zed_cloud_url("/client/users/connect", &[])?; + connect_url + .set_scheme(match connect_url.scheme() { + "https" => "wss", + "http" => "ws", + scheme => Err(anyhow!("invalid URL scheme: {scheme}"))?, + }) + .map_err(|_| anyhow!("failed to set URL scheme"))?; + + let credentials = self.credentials.read(); + let credentials = credentials.as_ref().context("no credentials provided")?; + let authorization_header = format!("{} {}", credentials.user_id, credentials.access_token); + + Ok(cx.spawn(async move |cx| { + let handle = cx + .update(|cx| Tokio::handle(cx)) + .ok() + .context("failed to get Tokio handle")?; + let _guard = handle.enter(); + + let ws = WebSocket::connect(connect_url) + .with_request( + request::Builder::new() + .header("Authorization", authorization_header) + .header(PROTOCOL_VERSION_HEADER_NAME, PROTOCOL_VERSION.to_string()), + ) + .await?; + + Ok(Connection::new(ws)) + })) + } + pub async fn accept_terms_of_service(&self) -> Result { let request = self.build_request( Request::builder().method(Method::POST).uri( diff --git a/crates/cloud_api_client/src/websocket.rs b/crates/cloud_api_client/src/websocket.rs new file mode 100644 index 0000000000..48a628db78 --- /dev/null +++ b/crates/cloud_api_client/src/websocket.rs @@ -0,0 +1,73 @@ +use std::pin::Pin; +use std::time::Duration; + +use anyhow::Result; +use cloud_api_types::websocket_protocol::MessageToClient; +use futures::channel::mpsc::unbounded; +use futures::stream::{SplitSink, SplitStream}; +use futures::{FutureExt as _, SinkExt as _, Stream, StreamExt as _, TryStreamExt as _, pin_mut}; +use gpui::{App, BackgroundExecutor, Task}; +use yawc::WebSocket; +use yawc::frame::{FrameView, OpCode}; + +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1); + +pub type MessageStream = Pin>>>; + +pub struct Connection { + tx: SplitSink, + rx: SplitStream, +} + +impl Connection { + pub fn new(ws: WebSocket) -> Self { + let (tx, rx) = ws.split(); + + Self { tx, rx } + } + + pub fn spawn(self, cx: &App) -> (MessageStream, Task<()>) { + let (mut tx, rx) = (self.tx, self.rx); + + let (message_tx, message_rx) = unbounded(); + + let handle_io = |executor: BackgroundExecutor| async move { + // Send messages on this frequency so the connection isn't closed. + let keepalive_timer = executor.timer(KEEPALIVE_INTERVAL).fuse(); + futures::pin_mut!(keepalive_timer); + + let rx = rx.fuse(); + pin_mut!(rx); + + loop { + futures::select_biased! { + _ = keepalive_timer => { + let _ = tx.send(FrameView::ping(Vec::new())).await; + + keepalive_timer.set(executor.timer(KEEPALIVE_INTERVAL).fuse()); + } + frame = rx.next() => { + let Some(frame) = frame else { + break; + }; + + match frame.opcode { + OpCode::Binary => { + let message_result = MessageToClient::deserialize(&frame.payload); + message_tx.unbounded_send(message_result).ok(); + } + OpCode::Close => { + break; + } + _ => {} + } + } + } + } + }; + + let task = cx.spawn(async move |cx| handle_io(cx.background_executor().clone()).await); + + (message_rx.into_stream().boxed(), task) + } +} diff --git a/crates/cloud_api_types/src/websocket_protocol.rs b/crates/cloud_api_types/src/websocket_protocol.rs index c90d09e370..75f6a73b43 100644 --- a/crates/cloud_api_types/src/websocket_protocol.rs +++ b/crates/cloud_api_types/src/websocket_protocol.rs @@ -8,7 +8,7 @@ pub const PROTOCOL_VERSION: u32 = 0; pub const PROTOCOL_VERSION_HEADER_NAME: &str = "x-zed-protocol-version"; /// A message from Cloud to the Zed client. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub enum MessageToClient { /// The user was updated and should be refreshed. UserUpdated, diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index e5123d5ab3..3bedba91a3 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -70,7 +70,7 @@ handlebars = { version = "4", features = ["rust-embed"] } hashbrown-3575ec1268b04181 = { package = "hashbrown", version = "0.15", features = ["serde"] } hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14", features = ["raw"] } hmac = { version = "0.12", default-features = false, features = ["reset"] } -hyper = { version = "0.14", features = ["client", "http1", "http2", "runtime", "server", "stream"] } +hyper-582f2526e08bb6a0 = { package = "hyper", version = "0.14", features = ["client", "http1", "http2", "runtime", "server", "stream"] } idna = { version = "1" } indexmap = { version = "2", features = ["serde"] } jiff = { version = "0.2" } @@ -199,7 +199,7 @@ hashbrown-3575ec1268b04181 = { package = "hashbrown", version = "0.15", features hashbrown-582f2526e08bb6a0 = { package = "hashbrown", version = "0.14", features = ["raw"] } heck = { version = "0.4", features = ["unicode"] } hmac = { version = "0.12", default-features = false, features = ["reset"] } -hyper = { version = "0.14", features = ["client", "http1", "http2", "runtime", "server", "stream"] } +hyper-582f2526e08bb6a0 = { package = "hyper", version = "0.14", features = ["client", "http1", "http2", "runtime", "server", "stream"] } idna = { version = "1" } indexmap = { version = "2", features = ["serde"] } itertools-594e8ee84c453af0 = { package = "itertools", version = "0.13" } @@ -287,7 +287,9 @@ core-foundation-sys = { version = "0.8" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } naga = { version = "25", features = ["msl-out", "wgsl-in"] } nix = { version = "0.29", features = ["fs", "pthread", "signal", "user"] } @@ -303,7 +305,7 @@ scopeguard = { version = "1" } security-framework = { version = "3", features = ["OSX_10_14"] } security-framework-sys = { version = "2", features = ["OSX_10_14"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } @@ -315,7 +317,9 @@ core-foundation-sys = { version = "0.8" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } naga = { version = "25", features = ["msl-out", "wgsl-in"] } nix = { version = "0.29", features = ["fs", "pthread", "signal", "user"] } @@ -332,7 +336,7 @@ scopeguard = { version = "1" } security-framework = { version = "3", features = ["OSX_10_14"] } security-framework-sys = { version = "2", features = ["OSX_10_14"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } @@ -344,7 +348,9 @@ core-foundation-sys = { version = "0.8" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } naga = { version = "25", features = ["msl-out", "wgsl-in"] } nix = { version = "0.29", features = ["fs", "pthread", "signal", "user"] } @@ -360,7 +366,7 @@ scopeguard = { version = "1" } security-framework = { version = "3", features = ["OSX_10_14"] } security-framework-sys = { version = "2", features = ["OSX_10_14"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } @@ -372,7 +378,9 @@ core-foundation-sys = { version = "0.8" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } naga = { version = "25", features = ["msl-out", "wgsl-in"] } nix = { version = "0.29", features = ["fs", "pthread", "signal", "user"] } @@ -389,7 +397,7 @@ scopeguard = { version = "1" } security-framework = { version = "3", features = ["OSX_10_14"] } security-framework-sys = { version = "2", features = ["OSX_10_14"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } @@ -407,7 +415,9 @@ foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } inout = { version = "0.1", default-features = false, features = ["block-padding"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } @@ -426,7 +436,7 @@ rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", scopeguard = { version = "1" } syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } @@ -447,7 +457,9 @@ foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } inout = { version = "0.1", default-features = false, features = ["block-padding"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } @@ -464,7 +476,7 @@ rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["ev rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } @@ -485,7 +497,9 @@ foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } inout = { version = "0.1", default-features = false, features = ["block-padding"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } @@ -504,7 +518,7 @@ rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", scopeguard = { version = "1" } syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } @@ -525,7 +539,9 @@ foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } inout = { version = "0.1", default-features = false, features = ["block-padding"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } @@ -542,7 +558,7 @@ rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["ev rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } @@ -556,14 +572,16 @@ flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } naga = { version = "25", features = ["spv-out", "wgsl-in"] } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } @@ -580,7 +598,9 @@ flume = { version = "0.11" } foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } naga = { version = "25", features = ["spv-out", "wgsl-in"] } proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } @@ -588,7 +608,7 @@ ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } @@ -612,7 +632,9 @@ foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } inout = { version = "0.1", default-features = false, features = ["block-padding"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } @@ -631,7 +653,7 @@ rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", scopeguard = { version = "1" } syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } @@ -652,7 +674,9 @@ foldhash = { version = "0.1", default-features = false, features = ["std"] } getrandom-468e82937335b1c9 = { package = "getrandom", version = "0.3", default-features = false, features = ["std"] } getrandom-6f8ce4dd05d13bba = { package = "getrandom", version = "0.2", default-features = false, features = ["js", "rdrand"] } gimli = { version = "0.31", default-features = false, features = ["read", "std", "write"] } +hyper-dff4ba8e3ae991db = { package = "hyper", version = "1", features = ["client", "http1", "http2"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "http2"] } inout = { version = "0.1", default-features = false, features = ["block-padding"] } itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } linux-raw-sys-274715c4dabd11b0 = { package = "linux-raw-sys", version = "0.9", default-features = false, features = ["elf", "errno", "general", "if_ether", "ioctl", "net", "netlink", "no_std", "prctl", "xdp"] } @@ -669,7 +693,7 @@ rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["ev rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } -tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring"] } tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } toml_datetime = { version = "0.6", default-features = false, features = ["serde"] } From 4529fca3de12202f6d98145981098f5b151959ec Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 7 Aug 2025 17:44:53 -0400 Subject: [PATCH 44/56] client: Re-fetch the authenticated user when receiving a `UserUpdated` message from Cloud (#35807) This PR wires up handling for the new `UserUpdated` message coming from Cloud over the WebSocket connection. When we receive this message we will refresh the authenticated user. Release Notes: - N/A Co-authored-by: Richard --- crates/client/src/client.rs | 24 ++++++++++++++++++++---- crates/client/src/user.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 0480ed1c3e..9d58692c0d 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -192,6 +192,8 @@ pub fn init(client: &Arc, cx: &mut App) { }); } +pub type MessageToClientHandler = Box; + struct GlobalClient(Arc); impl Global for GlobalClient {} @@ -205,6 +207,7 @@ pub struct Client { credentials_provider: ClientCredentialsProvider, state: RwLock, handler_set: parking_lot::Mutex, + message_to_client_handlers: parking_lot::Mutex>, #[allow(clippy::type_complexity)] #[cfg(any(test, feature = "test-support"))] @@ -554,6 +557,7 @@ impl Client { credentials_provider: ClientCredentialsProvider::new(cx), state: Default::default(), handler_set: Default::default(), + message_to_client_handlers: parking_lot::Mutex::new(Vec::new()), #[cfg(any(test, feature = "test-support"))] authenticate: Default::default(), @@ -1651,10 +1655,22 @@ impl Client { } } - fn handle_message_to_client(self: &Arc, message: MessageToClient, _cx: &AsyncApp) { - match message { - MessageToClient::UserUpdated => {} - } + pub fn add_message_to_client_handler( + self: &Arc, + handler: impl Fn(&MessageToClient, &App) + Send + Sync + 'static, + ) { + self.message_to_client_handlers + .lock() + .push(Box::new(handler)); + } + + fn handle_message_to_client(self: &Arc, message: MessageToClient, cx: &AsyncApp) { + cx.update(|cx| { + for handler in self.message_to_client_handlers.lock().iter() { + handler(&message, cx); + } + }) + .ok(); } pub fn telemetry(&self) -> &Arc { diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index a4441bec75..93069db304 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,6 +1,7 @@ use super::{Client, Status, TypedEnvelope, proto}; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Utc}; +use cloud_api_client::websocket_protocol::MessageToClient; use cloud_api_client::{GetAuthenticatedUserResponse, PlanInfo}; use cloud_llm_client::{ EDIT_PREDICTIONS_USAGE_AMOUNT_HEADER_NAME, EDIT_PREDICTIONS_USAGE_LIMIT_HEADER_NAME, @@ -181,6 +182,12 @@ impl UserStore { client.add_message_handler(cx.weak_entity(), Self::handle_update_invite_info), client.add_message_handler(cx.weak_entity(), Self::handle_show_contacts), ]; + + client.add_message_to_client_handler({ + let this = cx.weak_entity(); + move |message, cx| Self::handle_message_to_client(this.clone(), message, cx) + }); + Self { users: Default::default(), by_github_login: Default::default(), @@ -813,6 +820,32 @@ impl UserStore { cx.emit(Event::PrivateUserInfoUpdated); } + fn handle_message_to_client(this: WeakEntity, message: &MessageToClient, cx: &App) { + cx.spawn(async move |cx| { + match message { + MessageToClient::UserUpdated => { + let cloud_client = cx + .update(|cx| { + this.read_with(cx, |this, _cx| { + this.client.upgrade().map(|client| client.cloud_client()) + }) + })?? + .ok_or(anyhow::anyhow!("Failed to get Cloud client"))?; + + let response = cloud_client.get_authenticated_user().await?; + cx.update(|cx| { + this.update(cx, |this, cx| { + this.update_authenticated_user(response, cx); + }) + })??; + } + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + pub fn watch_current_user(&self) -> watch::Receiver>> { self.current_user.clone() } From cbf5dd1f23c671d5e561d71d70c69412e7bbd997 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 7 Aug 2025 18:14:25 -0400 Subject: [PATCH 45/56] client: Only connect to Collab automatically for Zed staff (#35827) This PR makes it so that only Zed staff connect to Collab automatically. Anyone else can connect to Collab manually when they want to collaborate (but this is not required for using Zed's LLM features). Release Notes: - N/A --------- Co-authored-by: Richard --- crates/client/src/client.rs | 45 ++++++++++++++++++----- crates/feature_flags/src/feature_flags.rs | 24 ++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 9d58692c0d..12ea4bcd3e 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -16,6 +16,7 @@ use clock::SystemClock; use cloud_api_client::CloudApiClient; use cloud_api_client::websocket_protocol::MessageToClient; use credentials_provider::CredentialsProvider; +use feature_flags::FeatureFlagAppExt as _; use futures::{ AsyncReadExt, FutureExt, SinkExt, Stream, StreamExt, TryFutureExt as _, TryStreamExt, channel::oneshot, future::BoxFuture, @@ -964,25 +965,51 @@ impl Client { Ok(()) } - /// Performs a sign-in and also connects to Collab. + /// Performs a sign-in and also (optionally) connects to Collab. /// - /// This is called in places where we *don't* need to connect in the future. We will replace these calls with calls - /// to `sign_in` when we're ready to remove auto-connection to Collab. + /// Only Zed staff automatically connect to Collab. pub async fn sign_in_with_optional_connect( self: &Arc, try_provider: bool, cx: &AsyncApp, ) -> Result<()> { + let (is_staff_tx, is_staff_rx) = oneshot::channel::(); + let mut is_staff_tx = Some(is_staff_tx); + cx.update(|cx| { + cx.on_flags_ready(move |state, _cx| { + if let Some(is_staff_tx) = is_staff_tx.take() { + is_staff_tx.send(state.is_staff).log_err(); + } + }) + .detach(); + }) + .log_err(); + let credentials = self.sign_in(try_provider, cx).await?; self.connect_to_cloud(cx).await.log_err(); - let connect_result = match self.connect_with_credentials(credentials, cx).await { - ConnectionResult::Timeout => Err(anyhow!("connection timed out")), - ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")), - ConnectionResult::Result(result) => result.context("client auth and connect"), - }; - connect_result.log_err(); + cx.update(move |cx| { + cx.spawn({ + let client = self.clone(); + async move |cx| { + let is_staff = is_staff_rx.await?; + if is_staff { + match client.connect_with_credentials(credentials, cx).await { + ConnectionResult::Timeout => Err(anyhow!("connection timed out")), + ConnectionResult::ConnectionReset => Err(anyhow!("connection reset")), + ConnectionResult::Result(result) => { + result.context("client auth and connect") + } + } + } else { + Ok(()) + } + } + }) + .detach_and_log_err(cx); + }) + .log_err(); Ok(()) } diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 631bafc841..ef357adf35 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -158,6 +158,11 @@ where } } +#[derive(Debug)] +pub struct OnFlagsReady { + pub is_staff: bool, +} + pub trait FeatureFlagAppExt { fn wait_for_flag(&mut self) -> WaitForFlag; @@ -169,6 +174,10 @@ pub trait FeatureFlagAppExt { fn has_flag(&self) -> bool; fn is_staff(&self) -> bool; + fn on_flags_ready(&mut self, callback: F) -> Subscription + where + F: FnMut(OnFlagsReady, &mut App) + 'static; + fn observe_flag(&mut self, callback: F) -> Subscription where F: FnMut(bool, &mut App) + 'static; @@ -198,6 +207,21 @@ impl FeatureFlagAppExt for App { .unwrap_or(false) } + fn on_flags_ready(&mut self, mut callback: F) -> Subscription + where + F: FnMut(OnFlagsReady, &mut App) + 'static, + { + self.observe_global::(move |cx| { + let feature_flags = cx.global::(); + callback( + OnFlagsReady { + is_staff: feature_flags.staff, + }, + cx, + ); + }) + } + fn observe_flag(&mut self, mut callback: F) -> Subscription where F: FnMut(bool, &mut App) + 'static, From 885355ced44bf7a86a36ed05bafa0771f0dd7d82 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 7 Aug 2025 18:29:59 -0400 Subject: [PATCH 46/56] collab_ui: Show signed-out state when not connected to Collab (#35832) This PR updates signed-out state of the Collab panel to show when not connected to Collab, as opposed to just when the user is signed-out. Release Notes: - N/A --- crates/collab_ui/src/collab_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 51eee5c68e..3aa58f0384 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -3061,7 +3061,7 @@ impl Render for CollabPanel { .on_action(cx.listener(CollabPanel::move_channel_down)) .track_focus(&self.focus_handle) .size_full() - .child(if self.user_store.read(cx).current_user().is_none() { + .child(if !self.client.status().borrow().is_connected() { self.render_signed_out(cx) } else { self.render_signed_in(window, cx) From 43e40fc7c7b4e73477117186b89329b32e99a695 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 7 Aug 2025 19:00:45 -0400 Subject: [PATCH 47/56] language_model: Refresh the LLM token upon receiving a `UserUpdated` message from Cloud (#35839) This PR makes it so we refresh the LLM token upon receiving a `UserUpdated` message from Cloud over the WebSocket connection. Release Notes: - N/A --- Cargo.lock | 1 + crates/client/src/client.rs | 4 +-- crates/language_model/Cargo.toml | 1 + .../language_model/src/model/cloud_model.rs | 34 +++++++++---------- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a8be61412..73fc4935af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9088,6 +9088,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "client", + "cloud_api_types", "cloud_llm_client", "collections", "futures 0.3.31", diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 12ea4bcd3e..f09c012a85 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -193,7 +193,7 @@ pub fn init(client: &Arc, cx: &mut App) { }); } -pub type MessageToClientHandler = Box; +pub type MessageToClientHandler = Box; struct GlobalClient(Arc); @@ -1684,7 +1684,7 @@ impl Client { pub fn add_message_to_client_handler( self: &Arc, - handler: impl Fn(&MessageToClient, &App) + Send + Sync + 'static, + handler: impl Fn(&MessageToClient, &mut App) + Send + Sync + 'static, ) { self.message_to_client_handlers .lock() diff --git a/crates/language_model/Cargo.toml b/crates/language_model/Cargo.toml index 841be60b0e..f9920623b5 100644 --- a/crates/language_model/Cargo.toml +++ b/crates/language_model/Cargo.toml @@ -20,6 +20,7 @@ anthropic = { workspace = true, features = ["schemars"] } anyhow.workspace = true base64.workspace = true client.workspace = true +cloud_api_types.workspace = true cloud_llm_client.workspace = true collections.workspace = true futures.workspace = true diff --git a/crates/language_model/src/model/cloud_model.rs b/crates/language_model/src/model/cloud_model.rs index 8ae5893410..3b4c1fa269 100644 --- a/crates/language_model/src/model/cloud_model.rs +++ b/crates/language_model/src/model/cloud_model.rs @@ -3,11 +3,9 @@ use std::sync::Arc; use anyhow::Result; use client::Client; +use cloud_api_types::websocket_protocol::MessageToClient; use cloud_llm_client::Plan; -use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Global, ReadGlobal as _, -}; -use proto::TypedEnvelope; +use gpui::{App, AppContext as _, Context, Entity, EventEmitter, Global, ReadGlobal as _}; use smol::lock::{RwLock, RwLockUpgradableReadGuard, RwLockWriteGuard}; use thiserror::Error; @@ -82,9 +80,7 @@ impl Global for GlobalRefreshLlmTokenListener {} pub struct RefreshLlmTokenEvent; -pub struct RefreshLlmTokenListener { - _llm_token_subscription: client::Subscription, -} +pub struct RefreshLlmTokenListener; impl EventEmitter for RefreshLlmTokenListener {} @@ -99,17 +95,21 @@ impl RefreshLlmTokenListener { } fn new(client: Arc, cx: &mut Context) -> Self { - Self { - _llm_token_subscription: client - .add_message_handler(cx.weak_entity(), Self::handle_refresh_llm_token), - } + client.add_message_to_client_handler({ + let this = cx.entity(); + move |message, cx| { + Self::handle_refresh_llm_token(this.clone(), message, cx); + } + }); + + Self } - async fn handle_refresh_llm_token( - this: Entity, - _: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result<()> { - this.update(&mut cx, |_this, cx| cx.emit(RefreshLlmTokenEvent)) + fn handle_refresh_llm_token(this: Entity, message: &MessageToClient, cx: &mut App) { + match message { + MessageToClient::UserUpdated => { + this.update(cx, |_this, cx| cx.emit(RefreshLlmTokenEvent)); + } + } } } From 7e30d220e1d1775727d924ec1df6731e707bd5ad Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 8 Aug 2025 10:44:01 -0400 Subject: [PATCH 48/56] Fix Clippy warning --- crates/client/src/user.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 93069db304..c360470277 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -232,7 +232,7 @@ impl UserStore { if let Some(response) = response.log_err() { let user = Arc::new(User { id: user_id, - github_login: response.user.github_login.clone().into(), + github_login: response.user.github_login.clone(), avatar_uri: response.user.avatar_url.clone().into(), name: response.user.name.clone(), }); From 75959537ba00960abfc5f8fe3b503effa6944770 Mon Sep 17 00:00:00 2001 From: Zed Bot Date: Fri, 8 Aug 2025 15:09:34 +0000 Subject: [PATCH 49/56] Bump to 0.198.4 for @maxdeviant --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73fc4935af..d2e5e2f2ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20252,7 +20252,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.198.3" +version = "0.198.4" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 8078e17c83..747b4c287f 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.198.3" +version = "0.198.4" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From bb400433629f115636bf727c1438445855a58f4f Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 8 Aug 2025 14:10:09 -0400 Subject: [PATCH 50/56] Ensure Edit Prediction provider is properly assigned on sign-in (#35885) This PR fixes an issue where Edit Predictions would not be available in buffers that were opened when the workspace loaded. The issue was that there was a race condition between fetching/setting the authenticated user state and when we assigned the Edit Prediction provider to buffers that were already opened. We now wait for the event that we emit when we have successfully loaded the user in order to assign the Edit Prediction provider, as we'll know the user has been loaded into the `UserStore` by that point. Closes https://github.com/zed-industries/zed/issues/35883 Release Notes: - Fixed an issue where Edit Predictions were not working in buffers that were open when the workspace initially loaded. Co-authored-by: Richard Feldman --- crates/client/src/user.rs | 39 +++++++++++++------ .../zed/src/zed/inline_completion_registry.rs | 29 ++++++-------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index c360470277..38f52359b7 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -226,17 +226,35 @@ impl UserStore { match status { Status::Authenticated | Status::Connected { .. } => { if let Some(user_id) = client.user_id() { - let response = client.cloud_client().get_authenticated_user().await; - let mut current_user = None; + let response = client + .cloud_client() + .get_authenticated_user() + .await + .log_err(); + + let current_user_and_response = if let Some(response) = response { + let user = Arc::new(User { + id: user_id, + github_login: response.user.github_login.clone().into(), + avatar_uri: response.user.avatar_url.clone().into(), + name: response.user.name.clone(), + }); + + Some((user, response)) + } else { + None + }; + current_user_tx + .send( + current_user_and_response + .as_ref() + .map(|(user, _)| user.clone()), + ) + .await + .ok(); + cx.update(|cx| { - if let Some(response) = response.log_err() { - let user = Arc::new(User { - id: user_id, - github_login: response.user.github_login.clone(), - avatar_uri: response.user.avatar_url.clone().into(), - name: response.user.name.clone(), - }); - current_user = Some(user.clone()); + if let Some((user, response)) = current_user_and_response { this.update(cx, |this, cx| { this.by_github_login .insert(user.github_login.clone(), user_id); @@ -247,7 +265,6 @@ impl UserStore { anyhow::Ok(()) } })??; - current_user_tx.send(current_user).await.ok(); this.update(cx, |_, cx| cx.notify())?; } diff --git a/crates/zed/src/zed/inline_completion_registry.rs b/crates/zed/src/zed/inline_completion_registry.rs index 378c172078..acd0be0291 100644 --- a/crates/zed/src/zed/inline_completion_registry.rs +++ b/crates/zed/src/zed/inline_completion_registry.rs @@ -6,11 +6,9 @@ use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, WeakEntity}; use language::language_settings::{EditPredictionProvider, all_language_settings}; use project::DisableAiSettings; use settings::{Settings as _, SettingsStore}; -use smol::stream::StreamExt; use std::{cell::RefCell, rc::Rc, sync::Arc}; use supermaven::{Supermaven, SupermavenCompletionProvider}; use ui::Window; -use util::ResultExt; use workspace::Workspace; use zeta::{ProviderDataCollection, ZetaInlineCompletionProvider}; @@ -60,25 +58,20 @@ pub fn init(client: Arc, user_store: Entity, cx: &mut App) { cx.on_action(clear_zeta_edit_history); let mut provider = all_language_settings(None, cx).edit_predictions.provider; - cx.spawn({ - let user_store = user_store.clone(); + cx.subscribe(&user_store, { let editors = editors.clone(); let client = client.clone(); - - async move |cx| { - let mut status = client.status(); - while let Some(_status) = status.next().await { - cx.update(|cx| { - assign_edit_prediction_providers( - &editors, - provider, - &client, - user_store.clone(), - cx, - ); - }) - .log_err(); + move |user_store, event, cx| match event { + client::user::Event::PrivateUserInfoUpdated => { + assign_edit_prediction_providers( + &editors, + provider, + &client, + user_store.clone(), + cx, + ); } + _ => {} } }) .detach(); From 7a0634f3bced2c35b4f3cbc8be047dc2e70f81af Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 8 Aug 2025 14:40:30 -0400 Subject: [PATCH 51/56] Fix Clippy warning --- crates/client/src/user.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 38f52359b7..01f8f9e307 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -235,7 +235,7 @@ impl UserStore { let current_user_and_response = if let Some(response) = response { let user = Arc::new(User { id: user_id, - github_login: response.user.github_login.clone().into(), + github_login: response.user.github_login.clone(), avatar_uri: response.user.avatar_url.clone().into(), name: response.user.name.clone(), }); From 9628f5a9ee8464904a21f2f13c9fea43d1c07841 Mon Sep 17 00:00:00 2001 From: Zed Bot Date: Fri, 8 Aug 2025 18:43:10 +0000 Subject: [PATCH 52/56] Bump to 0.198.5 for @maxdeviant --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2e5e2f2ff..c3bac13d63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20252,7 +20252,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.198.4" +version = "0.198.5" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 747b4c287f..f564aaf2da 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.198.4" +version = "0.198.5" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From ec0f22263d1c91c0cfd7edb66659cbef78082af5 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Fri, 8 Aug 2025 15:20:10 -0400 Subject: [PATCH 53/56] ci: Use faster Linux ARM runners (#35880) Switch our Linux aarch_64 release builds from Linux on Graviton (32 vCPU, 64GB) to Linux running on Apple M4 Pro (8vCPU, 32GB). Builds are faster (20mins vs 30mins) for the same cost (960 unit minutes; ~$0.96/ea). Screenshot 2025-08-08 at 13 14 41 Release Notes: - N/A --- .github/actionlint.yml | 3 +++ .github/workflows/ci.yml | 16 +++++++--------- .github/workflows/release_nightly.yml | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/actionlint.yml b/.github/actionlint.yml index ad09545902..0ee6af8a1d 100644 --- a/.github/actionlint.yml +++ b/.github/actionlint.yml @@ -24,6 +24,9 @@ self-hosted-runner: - namespace-profile-8x16-ubuntu-2204 - namespace-profile-16x32-ubuntu-2204 - namespace-profile-32x64-ubuntu-2204 + # Namespace Limited Preview + - namespace-profile-8x16-ubuntu-2004-arm-m4 + - namespace-profile-8x32-ubuntu-2004-arm-m4 # Self Hosted Runners - self-mini-macos - self-32vcpu-windows-2022 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45e303949d..ed1fd38090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -510,8 +510,8 @@ jobs: runs-on: - self-mini-macos if: | - startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + ( startsWith(github.ref, 'refs/tags/v') + || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) needs: [macos_tests] env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} @@ -598,8 +598,8 @@ jobs: runs-on: - namespace-profile-16x32-ubuntu-2004 # ubuntu 20.04 for minimal glibc if: | - startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') + ( startsWith(github.ref, 'refs/tags/v') + || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) needs: [linux_tests] steps: - name: Checkout repo @@ -649,7 +649,7 @@ jobs: timeout-minutes: 60 name: Linux arm64 release bundle runs-on: - - namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc + - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc if: | startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') @@ -702,10 +702,8 @@ jobs: timeout-minutes: 60 runs-on: github-8vcpu-ubuntu-2404 if: | - false && ( - startsWith(github.ref, 'refs/tags/v') - || contains(github.event.pull_request.labels.*.name, 'run-bundling') - ) + ( startsWith(github.ref, 'refs/tags/v') + || contains(github.event.pull_request.labels.*.name, 'run-bundling') ) needs: [linux_tests] name: Build Zed on FreeBSD steps: diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 32059ad19a..c104582d0a 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -167,7 +167,7 @@ jobs: name: Create a Linux *.tar.gz bundle for ARM if: github.repository_owner == 'zed-industries' runs-on: - - namespace-profile-32x64-ubuntu-2004-arm # ubuntu 20.04 for minimal glibc + - namespace-profile-8x32-ubuntu-2004-arm-m4 # ubuntu 20.04 for minimal glibc needs: tests steps: - name: Checkout repo From 3d75d7986e875d13b0e4c3244c8e61a3e2ae88ef Mon Sep 17 00:00:00 2001 From: smit Date: Thu, 7 Aug 2025 11:39:27 +0530 Subject: [PATCH 54/56] language_models: Fix high memory consumption while using Agent Panel (#35764) Closes #31108 The `num_tokens_from_messages` method we use from `tiktoken-rs` creates new BPE every time that method is called. This creation of BPE is expensive as well as has some underlying issue that keeps memory from releasing once the method is finished, specifically noticeable on Linux. This leads to a gradual increase in memory every time that method is called in my case around +50MB on each call. We call this method with debounce every time user types in Agent Panel to calculate tokens. This can add up really fast. This PR lands quick fix, while I/maintainers figure out underlying issue. See upstream discussion: https://github.com/zurawiki/tiktoken-rs/issues/39. Here on fork https://github.com/zed-industries/tiktoken-rs/pull/1, instead of creating BPE instances every time that method is called, we use singleton BPE instances instead. So, whatever memory it is holding on to, at least that is only once per model. Before: Increase of 700MB+ on extensive use On init: prev-init First message: prev-first-call Extensive use: prev-extensive-use After: Increase of 50MB+ on extensive use On init: now-init First message: now-first-call Extensive use: now-extensive-use Release Notes: - Fixed issue where Agent Panel would cause high memory consumption over prolonged use. --- Cargo.lock | 5 ++--- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3bac13d63..01d370c7a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16416,9 +16416,8 @@ dependencies = [ [[package]] name = "tiktoken-rs" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25563eeba904d770acf527e8b370fe9a5547bacd20ff84a0b6c3bc41288e5625" +version = "0.8.0" +source = "git+https://github.com/zed-industries/tiktoken-rs?rev=30c32a4522751699adeda0d5840c71c3b75ae73d#30c32a4522751699adeda0d5840c71c3b75ae73d" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index cff020b659..2d0387019a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -591,7 +591,7 @@ sysinfo = "0.31.0" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" -tiktoken-rs = "0.7.0" +tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "30c32a4522751699adeda0d5840c71c3b75ae73d" } time = { version = "0.3", features = [ "macros", "parsing", From 7b4e191629f8525dfe1561b429104828e7c003b2 Mon Sep 17 00:00:00 2001 From: smit Date: Fri, 8 Aug 2025 06:17:37 +0530 Subject: [PATCH 55/56] editor: Fix Follow Agent unexpectedly stopping during edits (#35845) Closes #34881 For horizontal scroll, we weren't keeping track of the `local` bool, so whenever the agent tries to autoscroll horizontally, it would be seen as a user scroll event resulting in unfollow. Release Notes: - Fixed an issue where the Follow Agent could unexpectedly stop following during edits. --- crates/editor/src/element.rs | 19 ++++++++++++++++--- crates/editor/src/scroll.rs | 4 ++-- crates/editor/src/scroll/autoscroll.rs | 14 +++++++------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7e77f113ac..1b88ec6446 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -8024,12 +8024,20 @@ impl Element for EditorElement { autoscroll_containing_element, needs_horizontal_autoscroll, ) = self.editor.update(cx, |editor, cx| { - let autoscroll_request = editor.autoscroll_request(); + let autoscroll_request = editor.scroll_manager.take_autoscroll_request(); + let autoscroll_containing_element = autoscroll_request.is_some() || editor.has_pending_selection(); let (needs_horizontal_autoscroll, was_scrolled) = editor - .autoscroll_vertically(bounds, line_height, max_scroll_top, window, cx); + .autoscroll_vertically( + bounds, + line_height, + max_scroll_top, + autoscroll_request, + window, + cx, + ); if was_scrolled.0 { snapshot = editor.snapshot(window, cx); } @@ -8419,7 +8427,11 @@ impl Element for EditorElement { Ok(blocks) => blocks, Err(resized_blocks) => { self.editor.update(cx, |editor, cx| { - editor.resize_blocks(resized_blocks, autoscroll_request, cx) + editor.resize_blocks( + resized_blocks, + autoscroll_request.map(|(autoscroll, _)| autoscroll), + cx, + ) }); return self.prepaint(None, _inspector_id, bounds, &mut (), window, cx); } @@ -8464,6 +8476,7 @@ impl Element for EditorElement { scroll_width, em_advance, &line_layouts, + autoscroll_request, window, cx, ) diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index ecaf7c11e4..08ff23f8f7 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -348,8 +348,8 @@ impl ScrollManager { self.show_scrollbars } - pub fn autoscroll_request(&self) -> Option { - self.autoscroll_request.map(|(autoscroll, _)| autoscroll) + pub fn take_autoscroll_request(&mut self) -> Option<(Autoscroll, bool)> { + self.autoscroll_request.take() } pub fn active_scrollbar_state(&self) -> Option<&ActiveScrollbarState> { diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index e8a1f8da73..88d3b52d76 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -102,15 +102,12 @@ impl AutoscrollStrategy { pub(crate) struct NeedsHorizontalAutoscroll(pub(crate) bool); impl Editor { - pub fn autoscroll_request(&self) -> Option { - self.scroll_manager.autoscroll_request() - } - pub(crate) fn autoscroll_vertically( &mut self, bounds: Bounds, line_height: Pixels, max_scroll_top: f32, + autoscroll_request: Option<(Autoscroll, bool)>, window: &mut Window, cx: &mut Context, ) -> (NeedsHorizontalAutoscroll, WasScrolled) { @@ -137,7 +134,7 @@ impl Editor { WasScrolled(false) }; - let Some((autoscroll, local)) = self.scroll_manager.autoscroll_request.take() else { + let Some((autoscroll, local)) = autoscroll_request else { return (NeedsHorizontalAutoscroll(false), editor_was_scrolled); }; @@ -284,9 +281,12 @@ impl Editor { scroll_width: Pixels, em_advance: Pixels, layouts: &[LineWithInvisibles], + autoscroll_request: Option<(Autoscroll, bool)>, window: &mut Window, cx: &mut Context, ) -> Option> { + let (_, local) = autoscroll_request?; + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let selections = self.selections.all::(cx); let mut scroll_position = self.scroll_manager.scroll_position(&display_map); @@ -335,10 +335,10 @@ impl Editor { let was_scrolled = if target_left < scroll_left { scroll_position.x = target_left / em_advance; - self.set_scroll_position_internal(scroll_position, true, true, window, cx) + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } else if target_right > scroll_right { scroll_position.x = (target_right - viewport_width) / em_advance; - self.set_scroll_position_internal(scroll_position, true, true, window, cx) + self.set_scroll_position_internal(scroll_position, local, true, window, cx) } else { WasScrolled(false) }; From d4bd7c03e493c03d9f30efa0a6cdf19a62dd9017 Mon Sep 17 00:00:00 2001 From: Zed Bot Date: Tue, 12 Aug 2025 06:01:51 +0000 Subject: [PATCH 56/56] Bump to 0.198.6 for @smitbarmase --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01d370c7a3..e5e1ab5c2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20251,7 +20251,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.198.5" +version = "0.198.6" dependencies = [ "activity_indicator", "agent", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index f564aaf2da..786d6a0967 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.198.5" +version = "0.198.6" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "]