From ec6e7b84b8871ca0ef43075cf524b31dfb6f4e64 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 2 Dec 2025 14:45:51 +0100 Subject: [PATCH 01/67] gpui(windows): Fix top resize edge being only 1px tall (#43995) Closes https://github.com/zed-industries/zed/issues/41693 Release Notes: - Fixed an issue on windows where the resize area on the title bar is only 1px tall --- crates/gpui/src/platform/windows/events.rs | 96 +++++++++++----------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 8ce92403e814b791cdaed8ce202cf8a9e5177b62..8a012789cf8198186c95f3e1702bc23e9b8897ee 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -51,7 +51,7 @@ impl WindowsWindowInner { WM_NCCALCSIZE => self.handle_calc_client_size(handle, wparam, lparam), WM_DPICHANGED => self.handle_dpi_changed_msg(handle, wparam, lparam), WM_DISPLAYCHANGE => self.handle_display_change_msg(handle), - WM_NCHITTEST => self.handle_hit_test_msg(handle, msg, wparam, lparam), + WM_NCHITTEST => self.handle_hit_test_msg(handle, lparam), WM_PAINT => self.handle_paint_msg(handle), WM_CLOSE => self.handle_close_msg(), WM_DESTROY => self.handle_destroy_msg(handle), @@ -834,73 +834,69 @@ impl WindowsWindowInner { Some(0) } - fn handle_hit_test_msg( - &self, - handle: HWND, - msg: u32, - wparam: WPARAM, - lparam: LPARAM, - ) -> Option { + fn handle_hit_test_msg(&self, handle: HWND, lparam: LPARAM) -> Option { if !self.is_movable || self.state.borrow().is_fullscreen() { return None; } - let mut lock = self.state.borrow_mut(); - if let Some(mut callback) = lock.callbacks.hit_test_window_control.take() { - drop(lock); + let callback = self + .state + .borrow_mut() + .callbacks + .hit_test_window_control + .take(); + let drag_area = if let Some(mut callback) = callback { let area = callback(); self.state.borrow_mut().callbacks.hit_test_window_control = Some(callback); if let Some(area) = area { - return match area { + match area { WindowControlArea::Drag => Some(HTCAPTION as _), - WindowControlArea::Close => Some(HTCLOSE as _), - WindowControlArea::Max => Some(HTMAXBUTTON as _), - WindowControlArea::Min => Some(HTMINBUTTON as _), - }; + WindowControlArea::Close => return Some(HTCLOSE as _), + WindowControlArea::Max => return Some(HTMAXBUTTON as _), + WindowControlArea::Min => return Some(HTMINBUTTON as _), + } + } else { + None } } else { - drop(lock); - } + None + }; if !self.hide_title_bar { // If the OS draws the title bar, we don't need to handle hit test messages. - return None; - } - - // default handler for resize areas - let hit = unsafe { DefWindowProcW(handle, msg, wparam, lparam) }; - if matches!( - hit.0 as u32, - HTNOWHERE - | HTRIGHT - | HTLEFT - | HTTOPLEFT - | HTTOP - | HTTOPRIGHT - | HTBOTTOMRIGHT - | HTBOTTOM - | HTBOTTOMLEFT - ) { - return Some(hit.0); - } - - if self.state.borrow().is_fullscreen() { - return Some(HTCLIENT as _); + return drag_area; } let dpi = unsafe { GetDpiForWindow(handle) }; - let frame_y = unsafe { GetSystemMetricsForDpi(SM_CYFRAME, dpi) }; - + // We do not use the OS title bar, so the default `DefWindowProcW` will only register a 1px edge for resizes + // We need to calculate the frame thickness ourselves and do the hit test manually. + let frame_y = get_frame_thicknessx(dpi); + let frame_x = get_frame_thicknessy(dpi); let mut cursor_point = POINT { x: lparam.signed_loword().into(), y: lparam.signed_hiword().into(), }; + unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; - if !self.state.borrow().is_maximized() && cursor_point.y >= 0 && cursor_point.y <= frame_y { - return Some(HTTOP as _); + if !self.state.borrow().is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y { + // x-axis actually goes from -frame_x to 0 + return Some(if cursor_point.x <= 0 { + HTTOPLEFT + } else { + let mut rect = Default::default(); + unsafe { GetWindowRect(handle, &mut rect) }.log_err(); + // right and bottom bounds of RECT are exclusive, thus `-1` + let right = rect.right - rect.left - 1; + // the bounds include the padding frames, so accomodate for both of them + if right - 2 * frame_x <= cursor_point.x { + HTTOPRIGHT + } else { + HTTOP + } + } as _); } - Some(HTCLIENT as _) + drag_area } fn handle_nc_mouse_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { @@ -1527,7 +1523,7 @@ fn get_client_area_insets( // The top inset is calculated using an empirical formula that I derived through various // tests. Without this, the top 1-2 rows of pixels in our window would be obscured. let dpi = unsafe { GetDpiForWindow(handle) }; - let frame_thickness = get_frame_thickness(dpi); + let frame_thickness = get_frame_thicknessx(dpi); let top_insets = if is_maximized { frame_thickness } else { @@ -1548,12 +1544,18 @@ fn get_client_area_insets( // borders on Windows: // - SM_CXSIZEFRAME: The resize handle. // - SM_CXPADDEDBORDER: Additional border space that isn't part of the resize handle. -fn get_frame_thickness(dpi: u32) -> i32 { +fn get_frame_thicknessx(dpi: u32) -> i32 { let resize_frame_thickness = unsafe { GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) }; let padding_thickness = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) }; resize_frame_thickness + padding_thickness } +fn get_frame_thicknessy(dpi: u32) -> i32 { + let resize_frame_thickness = unsafe { GetSystemMetricsForDpi(SM_CYSIZEFRAME, dpi) }; + let padding_thickness = unsafe { GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi) }; + resize_frame_thickness + padding_thickness +} + fn notify_frame_changed(handle: HWND) { unsafe { SetWindowPos( From 305e73ebbb2d178e0c0d09d940428c46f305f532 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Tue, 2 Dec 2025 17:02:47 +0100 Subject: [PATCH 02/67] gpui(windows): Move interior mutability down into fields (#44002) Windows applications tend to be fairly re-entrant which does not work well with our current top-level `RefCell` approach. We have worked around a bunch of panics in the past due to this, but ultimately this will re-occur (and still does according to sentry) in the future. So this PR moves all interior mutability down to the fields. Fixes ZED-1HM Fixes ZED-3SH Fixes ZED-1YV Fixes ZED-29S Fixes ZED-29X Fixes ZED-369 Fixes ZED-20W Release Notes: - Fixed panics on windows caused by unexpected re-entrancy for interior mutability --- crates/gpui/src/geometry.rs | 4 +- crates/gpui/src/platform/windows/events.rs | 353 ++++++++--------- crates/gpui/src/platform/windows/platform.rs | 112 +++--- .../src/platform/windows/system_settings.rs | 43 ++- crates/gpui/src/platform/windows/window.rs | 365 +++++++++--------- 5 files changed, 441 insertions(+), 436 deletions(-) diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index fa6f90b9ac9949ed7b5444e13045aaef6f9c0224..859ecb3d0e6c7b5c33f5765ce4c6295cef7fd566 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -748,7 +748,7 @@ impl Size { /// assert_eq!(bounds.origin, origin); /// assert_eq!(bounds.size, size); /// ``` -#[derive(Refineable, Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)] +#[derive(Refineable, Copy, Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)] #[refineable(Debug)] #[repr(C)] pub struct Bounds { @@ -1676,8 +1676,6 @@ impl Bounds { } } -impl Copy for Bounds {} - /// Represents the edges of a box in a 2D space, such as padding or margin. /// /// Each field represents the size of the edge on one side of the box: `top`, `right`, `bottom`, and `left`. diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 8a012789cf8198186c95f3e1702bc23e9b8897ee..e6fa6006eb95ec45f1634cb72ef63e2f622455a7 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -116,17 +116,16 @@ impl WindowsWindowInner { } fn handle_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); let origin = logical_point( lparam.signed_loword() as f32, lparam.signed_hiword() as f32, - lock.scale_factor, + self.state.scale_factor.get(), ); - lock.origin = origin; - let size = lock.logical_size; + self.state.origin.set(origin); + let size = self.state.logical_size.get(); let center_x = origin.x.0 + size.width.0 / 2.; let center_y = origin.y.0 + size.height.0 / 2.; - let monitor_bounds = lock.display.bounds(); + let monitor_bounds = self.state.display.get().bounds(); if center_x < monitor_bounds.left().0 || center_x > monitor_bounds.right().0 || center_y < monitor_bounds.top().0 @@ -136,42 +135,42 @@ impl WindowsWindowInner { let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONULL) }; // minimize the window can trigger this event too, in this case, // monitor is invalid, we do nothing. - if !monitor.is_invalid() && lock.display.handle != monitor { + if !monitor.is_invalid() && self.state.display.get().handle != monitor { // we will get the same monitor if we only have one - lock.display = WindowsDisplay::new_with_handle(monitor).log_err()?; + self.state + .display + .set(WindowsDisplay::new_with_handle(monitor).log_err()?); } } - if let Some(mut callback) = lock.callbacks.moved.take() { - drop(lock); + if let Some(mut callback) = self.state.callbacks.moved.take() { callback(); - self.state.borrow_mut().callbacks.moved = Some(callback); + self.state.callbacks.moved.set(Some(callback)); } Some(0) } fn handle_get_min_max_info_msg(&self, lparam: LPARAM) -> Option { - let lock = self.state.borrow(); - let min_size = lock.min_size?; - let scale_factor = lock.scale_factor; - let boarder_offset = lock.border_offset; - drop(lock); + let min_size = self.state.min_size?; + let scale_factor = self.state.scale_factor.get(); + let boarder_offset = &self.state.border_offset; + unsafe { let minmax_info = &mut *(lparam.0 as *mut MINMAXINFO); minmax_info.ptMinTrackSize.x = - min_size.width.scale(scale_factor).0 as i32 + boarder_offset.width_offset; + min_size.width.scale(scale_factor).0 as i32 + boarder_offset.width_offset.get(); minmax_info.ptMinTrackSize.y = - min_size.height.scale(scale_factor).0 as i32 + boarder_offset.height_offset; + min_size.height.scale(scale_factor).0 as i32 + boarder_offset.height_offset.get(); } Some(0) } fn handle_size_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - // Don't resize the renderer when the window is minimized, but record that it was minimized so // that on restore the swap chain can be recreated via `update_drawable_size_even_if_unchanged`. if wparam.0 == SIZE_MINIMIZED as usize { - lock.restore_from_minimized = lock.callbacks.request_frame.take(); + self.state + .restore_from_minimized + .set(self.state.callbacks.request_frame.take()); return Some(0); } @@ -179,14 +178,16 @@ impl WindowsWindowInner { let height = lparam.hiword().max(1) as i32; let new_size = size(DevicePixels(width), DevicePixels(height)); - let scale_factor = lock.scale_factor; + let scale_factor = self.state.scale_factor.get(); let mut should_resize_renderer = false; - if lock.restore_from_minimized.is_some() { - lock.callbacks.request_frame = lock.restore_from_minimized.take(); + if let Some(restore_from_minimized) = self.state.restore_from_minimized.take() { + self.state + .callbacks + .request_frame + .set(Some(restore_from_minimized)); } else { should_resize_renderer = true; } - drop(lock); self.handle_size_change(new_size, scale_factor, should_resize_renderer); Some(0) @@ -199,17 +200,19 @@ impl WindowsWindowInner { should_resize_renderer: bool, ) { let new_logical_size = device_size.to_pixels(scale_factor); - let mut lock = self.state.borrow_mut(); - lock.logical_size = new_logical_size; - if should_resize_renderer && let Err(e) = lock.renderer.resize(device_size) { + + self.state.logical_size.set(new_logical_size); + if should_resize_renderer + && let Err(e) = self.state.renderer.borrow_mut().resize(device_size) + { log::error!("Failed to resize renderer, invalidating devices: {}", e); - lock.invalidate_devices + self.state + .invalidate_devices .store(true, std::sync::atomic::Ordering::Release); } - if let Some(mut callback) = lock.callbacks.resize.take() { - drop(lock); + if let Some(mut callback) = self.state.callbacks.resize.take() { callback(new_logical_size, scale_factor); - self.state.borrow_mut().callbacks.resize = Some(callback); + self.state.callbacks.resize.set(Some(callback)); } } @@ -254,17 +257,14 @@ impl WindowsWindowInner { } fn handle_close_msg(&self) -> Option { - let mut callback = self.state.borrow_mut().callbacks.should_close.take()?; + let mut callback = self.state.callbacks.should_close.take()?; let should_close = callback(); - self.state.borrow_mut().callbacks.should_close = Some(callback); + self.state.callbacks.should_close.set(Some(callback)); if should_close { None } else { Some(0) } } fn handle_destroy_msg(&self, handle: HWND) -> Option { - let callback = { - let mut lock = self.state.borrow_mut(); - lock.callbacks.close.take() - }; + let callback = { self.state.callbacks.close.take() }; if let Some(callback) = callback { callback(); } @@ -283,12 +283,10 @@ impl WindowsWindowInner { fn handle_mouse_move_msg(&self, handle: HWND, lparam: LPARAM, wparam: WPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE); - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - let scale_factor = lock.scale_factor; - drop(lock); + let scale_factor = self.state.scale_factor.get(); let pressed_button = match MODIFIERKEYS_FLAGS(wparam.loword() as u32) { flags if flags.contains(MK_LBUTTON) => Some(MouseButton::Left), @@ -310,32 +308,32 @@ impl WindowsWindowInner { modifiers: current_modifiers(), }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } fn handle_mouse_leave_msg(&self) -> Option { - let mut lock = self.state.borrow_mut(); - lock.hovered = false; - if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { - drop(lock); + self.state.hovered.set(false); + if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(false); - self.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + self.state + .callbacks + .hovered_status_change + .set(Some(callback)); } Some(0) } fn handle_syskeyup_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - let input = handle_key_event(wparam, lparam, &mut lock, |keystroke, _| { + let input = handle_key_event(wparam, lparam, &self.state, |keystroke, _| { PlatformInput::KeyUp(KeyUpEvent { keystroke }) })?; - let mut func = lock.callbacks.input.take()?; - drop(lock); + let mut func = self.state.callbacks.input.take()?; + func(input); - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); // Always return 0 to indicate that the message was handled, so we could properly handle `ModifiersChanged` event. Some(0) @@ -344,11 +342,10 @@ impl WindowsWindowInner { // It's a known bug that you can't trigger `ctrl-shift-0`. See: // https://superuser.com/questions/1455762/ctrl-shift-number-key-combination-has-stopped-working-for-a-few-numbers fn handle_keydown_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); let Some(input) = handle_key_event( wparam, lparam, - &mut lock, + &self.state, |keystroke, prefer_character_input| { PlatformInput::KeyDown(KeyDownEvent { keystroke, @@ -359,34 +356,31 @@ impl WindowsWindowInner { ) else { return Some(1); }; - drop(lock); - let Some(mut func) = self.state.borrow_mut().callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } fn handle_keyup_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); - let Some(input) = handle_key_event(wparam, lparam, &mut lock, |keystroke, _| { + let Some(input) = handle_key_event(wparam, lparam, &self.state, |keystroke, _| { PlatformInput::KeyUp(KeyUpEvent { keystroke }) }) else { return Some(1); }; - let Some(mut func) = lock.callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - drop(lock); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -407,16 +401,15 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { unsafe { SetCapture(handle) }; - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; let x = lparam.signed_loword(); let y = lparam.signed_hiword(); let physical_point = point(DevicePixels(x as i32), DevicePixels(y as i32)); - let click_count = lock.click_state.update(button, physical_point); - let scale_factor = lock.scale_factor; - drop(lock); + let click_count = self.state.click_state.update(button, physical_point); + let scale_factor = self.state.scale_factor.get(); let input = PlatformInput::MouseDown(MouseDownEvent { button, @@ -426,7 +419,7 @@ impl WindowsWindowInner { first_mouse: false, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -438,15 +431,14 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { unsafe { ReleaseCapture().log_err() }; - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; let x = lparam.signed_loword() as f32; let y = lparam.signed_hiword() as f32; - let click_count = lock.click_state.current_count; - let scale_factor = lock.scale_factor; - drop(lock); + let click_count = self.state.click_state.current_count.get(); + let scale_factor = self.state.scale_factor.get(); let input = PlatformInput::MouseUp(MouseUpEvent { button, @@ -455,7 +447,7 @@ impl WindowsWindowInner { click_count, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -482,24 +474,23 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { let modifiers = current_modifiers(); - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - let scale_factor = lock.scale_factor; + let scale_factor = self.state.scale_factor.get(); let wheel_scroll_amount = match modifiers.shift { - true => { - self.system_settings() - .mouse_wheel_settings - .wheel_scroll_chars - } - false => { - self.system_settings() - .mouse_wheel_settings - .wheel_scroll_lines - } + true => self + .system_settings() + .mouse_wheel_settings + .wheel_scroll_chars + .get(), + false => self + .system_settings() + .mouse_wheel_settings + .wheel_scroll_lines + .get(), }; - drop(lock); let wheel_distance = (wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_amount as f32; @@ -524,7 +515,7 @@ impl WindowsWindowInner { touch_phase: TouchPhase::Moved, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -535,16 +526,15 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - let mut lock = self.state.borrow_mut(); - let Some(mut func) = lock.callbacks.input.take() else { + let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); }; - let scale_factor = lock.scale_factor; + let scale_factor = self.state.scale_factor.get(); let wheel_scroll_chars = self .system_settings() .mouse_wheel_settings - .wheel_scroll_chars; - drop(lock); + .wheel_scroll_chars + .get(); let wheel_distance = (-wparam.signed_hiword() as f32 / WHEEL_DELTA as f32) * wheel_scroll_chars as f32; @@ -563,7 +553,7 @@ impl WindowsWindowInner { touch_phase: TouchPhase::Moved, }); let handled = !func(event).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { Some(1) } } @@ -657,11 +647,11 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - if !self.hide_title_bar || self.state.borrow().is_fullscreen() || wparam.0 == 0 { + if !self.hide_title_bar || self.state.is_fullscreen() || wparam.0 == 0 { return None; } - let is_maximized = self.state.borrow().is_maximized(); + let is_maximized = self.state.is_maximized(); let insets = get_client_area_insets(handle, is_maximized, self.windows_version); // wparam is TRUE so lparam points to an NCCALCSIZE_PARAMS structure let mut params = lparam.0 as *mut NCCALCSIZE_PARAMS; @@ -676,7 +666,7 @@ impl WindowsWindowInner { // used by Chrome. However, it may result in one row of pixels being obscured // in our client area. But as Chrome says, "there seems to be no better solution." if is_maximized - && let Some(ref taskbar_position) = self.system_settings().auto_hide_taskbar_position + && let Some(taskbar_position) = self.system_settings().auto_hide_taskbar_position.get() { // For the auto-hide taskbar, adjust in by 1 pixel on taskbar edge, // so the window isn't treated as a "fullscreen app", which would cause @@ -705,11 +695,9 @@ impl WindowsWindowInner { let this = self.clone(); self.executor .spawn(async move { - let mut lock = this.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.active_status_change.take() { - drop(lock); + if let Some(mut func) = this.state.callbacks.active_status_change.take() { func(activated); - this.state.borrow_mut().callbacks.active_status_change = Some(func); + this.state.callbacks.active_status_change.set(Some(func)); } }) .detach(); @@ -733,12 +721,11 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { let new_dpi = wparam.loword() as f32; - let mut lock = self.state.borrow_mut(); - let is_maximized = lock.is_maximized(); + + let is_maximized = self.state.is_maximized(); let new_scale_factor = new_dpi / USER_DEFAULT_SCREEN_DPI as f32; - lock.scale_factor = new_scale_factor; - lock.border_offset.update(handle).log_err(); - drop(lock); + self.state.scale_factor.set(new_scale_factor); + self.state.border_offset.update(handle).log_err(); if is_maximized { // Get the monitor and its work area at the new DPI @@ -812,7 +799,7 @@ impl WindowsWindowInner { // Because WM_DPICHANGED, WM_MOVE, WM_SIZE will come first, window reposition and resize // are handled there. // So we only care about if monitor is disconnected. - let previous_monitor = self.state.borrow().display; + let previous_monitor = self.state.display.get(); if WindowsDisplay::is_connected(previous_monitor.handle) { // we are fine, other display changed return None; @@ -830,24 +817,22 @@ impl WindowsWindowInner { return None; } let new_display = WindowsDisplay::new_with_handle(new_monitor).log_err()?; - self.state.borrow_mut().display = new_display; + self.state.display.set(new_display); Some(0) } fn handle_hit_test_msg(&self, handle: HWND, lparam: LPARAM) -> Option { - if !self.is_movable || self.state.borrow().is_fullscreen() { + if !self.is_movable || self.state.is_fullscreen() { return None; } - let callback = self - .state - .borrow_mut() - .callbacks - .hit_test_window_control - .take(); + let callback = self.state.callbacks.hit_test_window_control.take(); let drag_area = if let Some(mut callback) = callback { let area = callback(); - self.state.borrow_mut().callbacks.hit_test_window_control = Some(callback); + self.state + .callbacks + .hit_test_window_control + .set(Some(callback)); if let Some(area) = area { match area { WindowControlArea::Drag => Some(HTCAPTION as _), @@ -878,7 +863,7 @@ impl WindowsWindowInner { }; unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; - if !self.state.borrow().is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y { + if !self.state.is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y { // x-axis actually goes from -frame_x to 0 return Some(if cursor_point.x <= 0 { HTTOPLEFT @@ -902,10 +887,8 @@ impl WindowsWindowInner { fn handle_nc_mouse_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE | TME_NONCLIENT); - let mut lock = self.state.borrow_mut(); - let mut func = lock.callbacks.input.take()?; - let scale_factor = lock.scale_factor; - drop(lock); + let mut func = self.state.callbacks.input.take()?; + let scale_factor = self.state.scale_factor.get(); let mut cursor_point = POINT { x: lparam.signed_loword().into(), @@ -918,7 +901,7 @@ impl WindowsWindowInner { modifiers: current_modifiers(), }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { Some(0) } else { None } } @@ -930,17 +913,15 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - let mut lock = self.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.input.take() { - let scale_factor = lock.scale_factor; + if let Some(mut func) = self.state.callbacks.input.take() { + let scale_factor = self.state.scale_factor.get(); let mut cursor_point = POINT { x: lparam.signed_loword().into(), y: lparam.signed_hiword().into(), }; unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; let physical_point = point(DevicePixels(cursor_point.x), DevicePixels(cursor_point.y)); - let click_count = lock.click_state.update(button, physical_point); - drop(lock); + let click_count = self.state.click_state.update(button, physical_point); let input = PlatformInput::MouseDown(MouseDownEvent { button, @@ -951,21 +932,20 @@ impl WindowsWindowInner { }); let result = func(input); let handled = !result.propagate || result.default_prevented; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { return Some(0); } } else { - drop(lock); }; // Since these are handled in handle_nc_mouse_up_msg we must prevent the default window proc if button == MouseButton::Left { match wparam.0 as u32 { - HTMINBUTTON => self.state.borrow_mut().nc_button_pressed = Some(HTMINBUTTON), - HTMAXBUTTON => self.state.borrow_mut().nc_button_pressed = Some(HTMAXBUTTON), - HTCLOSE => self.state.borrow_mut().nc_button_pressed = Some(HTCLOSE), + HTMINBUTTON => self.state.nc_button_pressed.set(Some(HTMINBUTTON)), + HTMAXBUTTON => self.state.nc_button_pressed.set(Some(HTMAXBUTTON)), + HTCLOSE => self.state.nc_button_pressed.set(Some(HTCLOSE)), _ => return None, }; Some(0) @@ -981,10 +961,8 @@ impl WindowsWindowInner { wparam: WPARAM, lparam: LPARAM, ) -> Option { - let mut lock = self.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.input.take() { - let scale_factor = lock.scale_factor; - drop(lock); + if let Some(mut func) = self.state.callbacks.input.take() { + let scale_factor = self.state.scale_factor.get(); let mut cursor_point = POINT { x: lparam.signed_loword().into(), @@ -998,16 +976,15 @@ impl WindowsWindowInner { click_count: 1, }); let handled = !func(input).propagate; - self.state.borrow_mut().callbacks.input = Some(func); + self.state.callbacks.input.set(Some(func)); if handled { return Some(0); } } else { - drop(lock); } - let last_pressed = self.state.borrow_mut().nc_button_pressed.take(); + let last_pressed = self.state.nc_button_pressed.take(); if button == MouseButton::Left && let Some(last_pressed) = last_pressed { @@ -1017,7 +994,7 @@ impl WindowsWindowInner { true } (HTMAXBUTTON, HTMAXBUTTON) => { - if self.state.borrow().is_maximized() { + if self.state.is_maximized() { unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() }; } else { unsafe { ShowWindowAsync(handle, SW_MAXIMIZE).ok().log_err() }; @@ -1042,17 +1019,16 @@ impl WindowsWindowInner { } fn handle_cursor_changed(&self, lparam: LPARAM) -> Option { - let mut state = self.state.borrow_mut(); - let had_cursor = state.current_cursor.is_some(); + let had_cursor = self.state.current_cursor.get().is_some(); - state.current_cursor = if lparam.0 == 0 { + self.state.current_cursor.set(if lparam.0 == 0 { None } else { Some(HCURSOR(lparam.0 as _)) - }; + }); - if had_cursor != state.current_cursor.is_some() { - unsafe { SetCursor(state.current_cursor) }; + if had_cursor != self.state.current_cursor.get().is_some() { + unsafe { SetCursor(self.state.current_cursor.get()) }; } Some(0) @@ -1075,7 +1051,7 @@ impl WindowsWindowInner { return None; } unsafe { - SetCursor(self.state.borrow().current_cursor); + SetCursor(self.state.current_cursor.get()); }; Some(0) } @@ -1087,13 +1063,12 @@ impl WindowsWindowInner { lparam: LPARAM, ) -> Option { if wparam.0 != 0 { - let mut lock = self.state.borrow_mut(); - let display = lock.display; - lock.click_state.system_update(wparam.0); - lock.border_offset.update(handle).log_err(); - // system settings may emit a window message which wants to take the refcell lock, so drop it - drop(lock); - self.system_settings_mut().update(display, wparam.0); + let display = self.state.display.get(); + self.state.click_state.system_update(wparam.0); + self.state.border_offset.update(handle).log_err(); + // system settings may emit a window message which wants to take the refcell self.state, so drop it + + self.system_settings().update(display, wparam.0); } else { self.handle_system_theme_changed(handle, lparam)?; }; @@ -1116,13 +1091,13 @@ impl WindowsWindowInner { let new_appearance = system_appearance() .context("unable to get system appearance when handling ImmersiveColorSet") .log_err()?; - let mut lock = self.state.borrow_mut(); - if new_appearance != lock.appearance { - lock.appearance = new_appearance; - let mut callback = lock.callbacks.appearance_changed.take()?; - drop(lock); + + if new_appearance != self.state.appearance.get() { + self.state.appearance.set(new_appearance); + let mut callback = self.state.callbacks.appearance_changed.take()?; + callback(); - self.state.borrow_mut().callbacks.appearance_changed = Some(callback); + self.state.callbacks.appearance_changed.set(Some(callback)); configure_dwm_dark_mode(handle, new_appearance); } } @@ -1151,10 +1126,14 @@ impl WindowsWindowInner { } fn handle_device_lost(&self, lparam: LPARAM) -> Option { - let mut lock = self.state.borrow_mut(); let devices = lparam.0 as *const DirectXDevices; let devices = unsafe { &*devices }; - if let Err(err) = lock.renderer.handle_device_lost(&devices) { + if let Err(err) = self + .state + .renderer + .borrow_mut() + .handle_device_lost(&devices) + { panic!("Device lost: {err}"); } Some(0) @@ -1162,18 +1141,18 @@ impl WindowsWindowInner { #[inline] fn draw_window(&self, handle: HWND, force_render: bool) -> Option { - let mut request_frame = self.state.borrow_mut().callbacks.request_frame.take()?; + let mut request_frame = self.state.callbacks.request_frame.take()?; // we are instructing gpui to force render a frame, this will // re-populate all the gpu textures for us so we can resume drawing in // case we disabled drawing earlier due to a device loss - self.state.borrow_mut().renderer.mark_drawable(); + self.state.renderer.borrow_mut().mark_drawable(); request_frame(RequestFrameOptions { require_presentation: false, force_render, }); - self.state.borrow_mut().callbacks.request_frame = Some(request_frame); + self.state.callbacks.request_frame.set(Some(request_frame)); unsafe { ValidateRect(Some(handle), None).ok().log_err() }; Some(0) @@ -1182,16 +1161,16 @@ impl WindowsWindowInner { #[inline] fn parse_char_message(&self, wparam: WPARAM) -> Option { let code_point = wparam.loword(); - let mut lock = self.state.borrow_mut(); + // https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G2630 match code_point { 0xD800..=0xDBFF => { // High surrogate, wait for low surrogate - lock.pending_surrogate = Some(code_point); + self.state.pending_surrogate.set(Some(code_point)); None } 0xDC00..=0xDFFF => { - if let Some(high_surrogate) = lock.pending_surrogate.take() { + if let Some(high_surrogate) = self.state.pending_surrogate.take() { // Low surrogate, combine with pending high surrogate String::from_utf16(&[high_surrogate, code_point]).ok() } else { @@ -1203,7 +1182,7 @@ impl WindowsWindowInner { } } _ => { - lock.pending_surrogate = None; + self.state.pending_surrogate.set(None); char::from_u32(code_point as u32) .filter(|c| !c.is_control()) .map(|c| c.to_string()) @@ -1212,9 +1191,8 @@ impl WindowsWindowInner { } fn start_tracking_mouse(&self, handle: HWND, flags: TRACKMOUSEEVENT_FLAGS) { - let mut lock = self.state.borrow_mut(); - if !lock.hovered { - lock.hovered = true; + if !self.state.hovered.get() { + self.state.hovered.set(true); unsafe { TrackMouseEvent(&mut TRACKMOUSEEVENT { cbSize: std::mem::size_of::() as u32, @@ -1224,10 +1202,12 @@ impl WindowsWindowInner { }) .log_err() }; - if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { - drop(lock); + if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(true); - self.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + self.state + .callbacks + .hovered_status_change + .set(Some(callback)); } } } @@ -1236,9 +1216,9 @@ impl WindowsWindowInner { where F: FnOnce(&mut PlatformInputHandler) -> R, { - let mut input_handler = self.state.borrow_mut().input_handler.take()?; + let mut input_handler = self.state.input_handler.take()?; let result = f(&mut input_handler); - self.state.borrow_mut().input_handler = Some(input_handler); + self.state.input_handler.set(Some(input_handler)); Some(result) } @@ -1246,12 +1226,11 @@ impl WindowsWindowInner { where F: FnOnce(&mut PlatformInputHandler, f32) -> Option, { - let mut lock = self.state.borrow_mut(); - let mut input_handler = lock.input_handler.take()?; - let scale_factor = lock.scale_factor; - drop(lock); + let mut input_handler = self.state.input_handler.take()?; + let scale_factor = self.state.scale_factor.get(); + let result = f(&mut input_handler, scale_factor); - self.state.borrow_mut().input_handler = Some(input_handler); + self.state.input_handler.set(Some(input_handler)); result } } @@ -1259,7 +1238,7 @@ impl WindowsWindowInner { fn handle_key_event( wparam: WPARAM, lparam: LPARAM, - state: &mut WindowsWindowState, + state: &WindowsWindowState, f: F, ) -> Option where @@ -1272,11 +1251,12 @@ where VK_SHIFT | VK_CONTROL | VK_MENU | VK_LMENU | VK_RMENU | VK_LWIN | VK_RWIN => { if state .last_reported_modifiers + .get() .is_some_and(|prev_modifiers| prev_modifiers == modifiers) { return None; } - state.last_reported_modifiers = Some(modifiers); + state.last_reported_modifiers.set(Some(modifiers)); Some(PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers, capslock: current_capslock(), @@ -1287,11 +1267,12 @@ where let capslock = current_capslock(); if state .last_reported_capslock + .get() .is_some_and(|prev_capslock| prev_capslock == capslock) { return None; } - state.last_reported_capslock = Some(capslock); + state.last_reported_capslock.set(Some(capslock)); Some(PlatformInput::ModifiersChanged(ModifiersChangedEvent { modifiers, capslock, diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 396708fb347380a168b56d7ff7e0a9129bdd3af3..af0cb89ecc94da70cc42c8d4c397aeb2a811d6fb 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -1,5 +1,5 @@ use std::{ - cell::RefCell, + cell::{Cell, RefCell}, ffi::OsStr, path::{Path, PathBuf}, rc::{Rc, Weak}, @@ -47,7 +47,7 @@ pub(crate) struct WindowsPlatform { } struct WindowsPlatformInner { - state: RefCell, + state: WindowsPlatformState, raw_window_handles: std::sync::Weak>>, // The below members will never change throughout the entire lifecycle of the app. validation_number: usize, @@ -57,22 +57,22 @@ struct WindowsPlatformInner { pub(crate) struct WindowsPlatformState { callbacks: PlatformCallbacks, - menus: Vec, - jump_list: JumpList, + menus: RefCell>, + jump_list: RefCell, // NOTE: standard cursor handles don't need to close. - pub(crate) current_cursor: Option, - directx_devices: Option, + pub(crate) current_cursor: Cell>, + directx_devices: RefCell>, } #[derive(Default)] struct PlatformCallbacks { - open_urls: Option)>>, - quit: Option>, - reopen: Option>, - app_menu_action: Option>, - will_open_app_menu: Option>, - validate_app_menu_command: Option bool>>, - keyboard_layout_change: Option>, + open_urls: Cell)>>>, + quit: Cell>>, + reopen: Cell>>, + app_menu_action: Cell>>, + will_open_app_menu: Cell>>, + validate_app_menu_command: Cell bool>>>, + keyboard_layout_change: Cell>>, } impl WindowsPlatformState { @@ -84,10 +84,10 @@ impl WindowsPlatformState { Self { callbacks, - jump_list, - current_cursor, - directx_devices, - menus: Vec::new(), + jump_list: RefCell::new(jump_list), + current_cursor: Cell::new(current_cursor), + directx_devices: RefCell::new(directx_devices), + menus: RefCell::new(Vec::new()), } } } @@ -194,14 +194,14 @@ impl WindowsPlatform { WindowCreationInfo { icon: self.icon, executor: self.foreground_executor.clone(), - current_cursor: self.inner.state.borrow().current_cursor, + current_cursor: self.inner.state.current_cursor.get(), windows_version: self.windows_version, drop_target_helper: self.drop_target_helper.clone(), validation_number: self.inner.validation_number, main_receiver: self.inner.main_receiver.clone(), platform_window_handle: self.handle, disable_direct_composition: self.disable_direct_composition, - directx_devices: self.inner.state.borrow().directx_devices.clone().unwrap(), + directx_devices: self.inner.state.directx_devices.borrow().clone().unwrap(), invalidate_devices: self.invalidate_devices.clone(), } } @@ -213,9 +213,8 @@ impl WindowsPlatform { actions.push(dock_menu); } }); - let mut lock = self.inner.state.borrow_mut(); - lock.jump_list.dock_menus = actions; - update_jump_list(&lock.jump_list).log_err(); + self.inner.state.jump_list.borrow_mut().dock_menus = actions; + update_jump_list(&self.inner.state.jump_list.borrow()).log_err(); } fn update_jump_list( @@ -229,12 +228,10 @@ impl WindowsPlatform { actions.push(dock_menu); } }); - let mut lock = self.inner.state.borrow_mut(); - lock.jump_list.dock_menus = actions; - lock.jump_list.recent_workspaces = entries; - update_jump_list(&lock.jump_list) - .log_err() - .unwrap_or_default() + let mut jump_list = self.inner.state.jump_list.borrow_mut(); + jump_list.dock_menus = actions; + jump_list.recent_workspaces = entries; + update_jump_list(&jump_list).log_err().unwrap_or_default() } fn find_current_active_window(&self) -> Option { @@ -250,7 +247,7 @@ impl WindowsPlatform { } fn begin_vsync_thread(&self) { - let mut directx_device = self.inner.state.borrow().directx_devices.clone().unwrap(); + let mut directx_device = self.inner.state.directx_devices.borrow().clone().unwrap(); let platform_window: SafeHwnd = self.handle.into(); let validation_number = self.inner.validation_number; let all_windows = Arc::downgrade(&self.raw_window_handles); @@ -334,9 +331,9 @@ impl Platform for WindowsPlatform { fn on_keyboard_layout_change(&self, callback: Box) { self.inner .state - .borrow_mut() .callbacks - .keyboard_layout_change = Some(callback); + .keyboard_layout_change + .set(Some(callback)); } fn run(&self, on_finish_launching: Box) { @@ -354,7 +351,7 @@ impl Platform for WindowsPlatform { } self.inner - .with_callback(|callbacks| &mut callbacks.quit, |callback| callback()); + .with_callback(|callbacks| &callbacks.quit, |callback| callback()); } fn quit(&self) { @@ -473,7 +470,7 @@ impl Platform for WindowsPlatform { } fn on_open_urls(&self, callback: Box)>) { - self.inner.state.borrow_mut().callbacks.open_urls = Some(callback); + self.inner.state.callbacks.open_urls.set(Some(callback)); } fn prompt_for_paths( @@ -543,19 +540,19 @@ impl Platform for WindowsPlatform { } fn on_quit(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.quit = Some(callback); + self.inner.state.callbacks.quit.set(Some(callback)); } fn on_reopen(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.reopen = Some(callback); + self.inner.state.callbacks.reopen.set(Some(callback)); } fn set_menus(&self, menus: Vec, _keymap: &Keymap) { - self.inner.state.borrow_mut().menus = menus.into_iter().map(|menu| menu.owned()).collect(); + *self.inner.state.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect(); } fn get_menus(&self) -> Option> { - Some(self.inner.state.borrow().menus.clone()) + Some(self.inner.state.menus.borrow().clone()) } fn set_dock_menu(&self, menus: Vec, _keymap: &Keymap) { @@ -563,19 +560,27 @@ impl Platform for WindowsPlatform { } fn on_app_menu_action(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.app_menu_action = Some(callback); + self.inner + .state + .callbacks + .app_menu_action + .set(Some(callback)); } fn on_will_open_app_menu(&self, callback: Box) { - self.inner.state.borrow_mut().callbacks.will_open_app_menu = Some(callback); + self.inner + .state + .callbacks + .will_open_app_menu + .set(Some(callback)); } fn on_validate_app_menu_command(&self, callback: Box bool>) { self.inner .state - .borrow_mut() .callbacks - .validate_app_menu_command = Some(callback); + .validate_app_menu_command + .set(Some(callback)); } fn app_path(&self) -> Result { @@ -589,13 +594,13 @@ impl Platform for WindowsPlatform { fn set_cursor_style(&self, style: CursorStyle) { let hcursor = load_cursor(style); - if self.inner.state.borrow_mut().current_cursor.map(|c| c.0) != hcursor.map(|c| c.0) { + if self.inner.state.current_cursor.get().map(|c| c.0) != hcursor.map(|c| c.0) { self.post_message( WM_GPUI_CURSOR_STYLE_CHANGED, WPARAM(0), LPARAM(hcursor.map_or(0, |c| c.0 as isize)), ); - self.inner.state.borrow_mut().current_cursor = hcursor; + self.inner.state.current_cursor.set(hcursor); } } @@ -721,12 +726,12 @@ impl Platform for WindowsPlatform { impl WindowsPlatformInner { fn new(context: &mut PlatformWindowCreateContext) -> Result> { - let state = RefCell::new(WindowsPlatformState::new( + let state = WindowsPlatformState::new( context .directx_devices .take() .context("missing directx devices")?, - )); + ); Ok(Rc::new(Self { state, raw_window_handles: context.raw_window_handles.clone(), @@ -746,13 +751,13 @@ impl WindowsPlatformInner { /// Calls `project` to project to the corresponding callback field, removes it from callbacks, calls `f` with the callback and then puts the callback back. fn with_callback( &self, - project: impl Fn(&mut PlatformCallbacks) -> &mut Option, + project: impl Fn(&PlatformCallbacks) -> &Cell>, f: impl FnOnce(&mut T), ) { - let callback = project(&mut self.state.borrow_mut().callbacks).take(); + let callback = project(&self.state.callbacks).take(); if let Some(mut callback) = callback { f(&mut callback); - *project(&mut self.state.borrow_mut().callbacks) = Some(callback) + project(&self.state.callbacks).set(Some(callback)); } } @@ -877,8 +882,8 @@ impl WindowsPlatformInner { fn handle_dock_action_event(&self, action_idx: usize) -> Option { let Some(action) = self .state - .borrow_mut() .jump_list + .borrow() .dock_menus .get(action_idx) .map(|dock_menu| dock_menu.action.boxed_clone()) @@ -887,7 +892,7 @@ impl WindowsPlatformInner { return Some(1); }; self.with_callback( - |callbacks| &mut callbacks.app_menu_action, + |callbacks| &callbacks.app_menu_action, |callback| callback(&*action), ); Some(0) @@ -895,7 +900,7 @@ impl WindowsPlatformInner { fn handle_keyboard_layout_change(&self) -> Option { self.with_callback( - |callbacks| &mut callbacks.keyboard_layout_change, + |callbacks| &callbacks.keyboard_layout_change, |callback| callback(), ); Some(0) @@ -904,9 +909,8 @@ impl WindowsPlatformInner { fn handle_device_lost(&self, lparam: LPARAM) -> Option { let directx_devices = lparam.0 as *const DirectXDevices; let directx_devices = unsafe { &*directx_devices }; - let mut lock = self.state.borrow_mut(); - lock.directx_devices.take(); - lock.directx_devices = Some(directx_devices.clone()); + self.state.directx_devices.borrow_mut().take(); + *self.state.directx_devices.borrow_mut() = Some(directx_devices.clone()); Some(0) } diff --git a/crates/gpui/src/platform/windows/system_settings.rs b/crates/gpui/src/platform/windows/system_settings.rs index b2bd289cd00979541f0176a4ccea6a52143b9ddd..f5ef5ce31ec23b69d1f009792c693e248d404b8e 100644 --- a/crates/gpui/src/platform/windows/system_settings.rs +++ b/crates/gpui/src/platform/windows/system_settings.rs @@ -1,4 +1,7 @@ -use std::ffi::{c_uint, c_void}; +use std::{ + cell::Cell, + ffi::{c_uint, c_void}, +}; use ::util::ResultExt; use windows::Win32::UI::{ @@ -15,18 +18,18 @@ use super::WindowsDisplay; /// Windows settings pulled from SystemParametersInfo /// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-systemparametersinfow -#[derive(Default, Debug, Clone, Copy)] +#[derive(Default, Debug, Clone)] pub(crate) struct WindowsSystemSettings { pub(crate) mouse_wheel_settings: MouseWheelSettings, - pub(crate) auto_hide_taskbar_position: Option, + pub(crate) auto_hide_taskbar_position: Cell>, } -#[derive(Default, Debug, Clone, Copy)] +#[derive(Default, Debug, Clone)] pub(crate) struct MouseWheelSettings { /// SEE: SPI_GETWHEELSCROLLCHARS - pub(crate) wheel_scroll_chars: u32, + pub(crate) wheel_scroll_chars: Cell, /// SEE: SPI_GETWHEELSCROLLLINES - pub(crate) wheel_scroll_lines: u32, + pub(crate) wheel_scroll_lines: Cell, } impl WindowsSystemSettings { @@ -36,12 +39,13 @@ impl WindowsSystemSettings { settings } - fn init(&mut self, display: WindowsDisplay) { + fn init(&self, display: WindowsDisplay) { self.mouse_wheel_settings.update(); - self.auto_hide_taskbar_position = AutoHideTaskbarPosition::new(display).log_err().flatten(); + self.auto_hide_taskbar_position + .set(AutoHideTaskbarPosition::new(display).log_err().flatten()); } - pub(crate) fn update(&mut self, display: WindowsDisplay, wparam: usize) { + pub(crate) fn update(&self, display: WindowsDisplay, wparam: usize) { match wparam { // SPI_SETWORKAREA 47 => self.update_taskbar_position(display), @@ -51,22 +55,23 @@ impl WindowsSystemSettings { } } - fn update_mouse_wheel_settings(&mut self) { + fn update_mouse_wheel_settings(&self) { self.mouse_wheel_settings.update(); } - fn update_taskbar_position(&mut self, display: WindowsDisplay) { - self.auto_hide_taskbar_position = AutoHideTaskbarPosition::new(display).log_err().flatten(); + fn update_taskbar_position(&self, display: WindowsDisplay) { + self.auto_hide_taskbar_position + .set(AutoHideTaskbarPosition::new(display).log_err().flatten()); } } impl MouseWheelSettings { - fn update(&mut self) { + fn update(&self) { self.update_wheel_scroll_chars(); self.update_wheel_scroll_lines(); } - fn update_wheel_scroll_chars(&mut self) { + fn update_wheel_scroll_chars(&self) { let mut value = c_uint::default(); let result = unsafe { SystemParametersInfoW( @@ -77,12 +82,12 @@ impl MouseWheelSettings { ) }; - if result.log_err() != None && self.wheel_scroll_chars != value { - self.wheel_scroll_chars = value; + if result.log_err() != None && self.wheel_scroll_chars.get() != value { + self.wheel_scroll_chars.set(value); } } - fn update_wheel_scroll_lines(&mut self) { + fn update_wheel_scroll_lines(&self) { let mut value = c_uint::default(); let result = unsafe { SystemParametersInfoW( @@ -93,8 +98,8 @@ impl MouseWheelSettings { ) }; - if result.log_err() != None && self.wheel_scroll_lines != value { - self.wheel_scroll_lines = value; + if result.log_err() != None && self.wheel_scroll_lines.get() != value { + self.wheel_scroll_lines.set(value); } } } diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 334f0519f15a608a8b36b3610c88fb456a4a8f5b..7ef92b4150e69424b68e9417dda377aa7f2e9cc0 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -1,7 +1,7 @@ #![deny(unsafe_op_in_unsafe_fn)] use std::{ - cell::RefCell, + cell::{Cell, RefCell}, num::NonZeroIsize, path::PathBuf, rc::{Rc, Weak}, @@ -30,43 +30,51 @@ use crate::*; pub(crate) struct WindowsWindow(pub Rc); +impl std::ops::Deref for WindowsWindow { + type Target = WindowsWindowInner; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + pub struct WindowsWindowState { - pub origin: Point, - pub logical_size: Size, + pub origin: Cell>, + pub logical_size: Cell>, pub min_size: Option>, - pub fullscreen_restore_bounds: Bounds, + pub fullscreen_restore_bounds: Cell>, pub border_offset: WindowBorderOffset, - pub appearance: WindowAppearance, - pub scale_factor: f32, - pub restore_from_minimized: Option>, + pub appearance: Cell, + pub scale_factor: Cell, + pub restore_from_minimized: Cell>>, pub callbacks: Callbacks, - pub input_handler: Option, - pub pending_surrogate: Option, - pub last_reported_modifiers: Option, - pub last_reported_capslock: Option, - pub hovered: bool, + pub input_handler: Cell>, + pub pending_surrogate: Cell>, + pub last_reported_modifiers: Cell>, + pub last_reported_capslock: Cell>, + pub hovered: Cell, - pub renderer: DirectXRenderer, + pub renderer: RefCell, pub click_state: ClickState, - pub current_cursor: Option, - pub nc_button_pressed: Option, + pub current_cursor: Cell>, + pub nc_button_pressed: Cell>, - pub display: WindowsDisplay, + pub display: Cell, /// Flag to instruct the `VSyncProvider` thread to invalidate the directx devices /// as resizing them has failed, causing us to have lost at least the render target. pub invalidate_devices: Arc, - fullscreen: Option, - initial_placement: Option, + fullscreen: Cell>, + initial_placement: Cell>, hwnd: HWND, } pub(crate) struct WindowsWindowInner { hwnd: HWND, drop_target_helper: IDropTargetHelper, - pub(crate) state: RefCell, - system_settings: RefCell, + pub(crate) state: WindowsWindowState, + system_settings: WindowsSystemSettings, pub(crate) handle: AnyWindowHandle, pub(crate) hide_title_bar: bool, pub(crate) is_movable: bool, @@ -121,27 +129,27 @@ impl WindowsWindowState { let initial_placement = None; Ok(Self { - origin, - logical_size, - fullscreen_restore_bounds, + origin: Cell::new(origin), + logical_size: Cell::new(logical_size), + fullscreen_restore_bounds: Cell::new(fullscreen_restore_bounds), border_offset, - appearance, - scale_factor, - restore_from_minimized, + appearance: Cell::new(appearance), + scale_factor: Cell::new(scale_factor), + restore_from_minimized: Cell::new(restore_from_minimized), min_size, callbacks, - input_handler, - pending_surrogate, - last_reported_modifiers, - last_reported_capslock, - hovered, - renderer, + input_handler: Cell::new(input_handler), + pending_surrogate: Cell::new(pending_surrogate), + last_reported_modifiers: Cell::new(last_reported_modifiers), + last_reported_capslock: Cell::new(last_reported_capslock), + hovered: Cell::new(hovered), + renderer: RefCell::new(renderer), click_state, - current_cursor, - nc_button_pressed, - display, - fullscreen, - initial_placement, + current_cursor: Cell::new(current_cursor), + nc_button_pressed: Cell::new(nc_button_pressed), + display: Cell::new(display), + fullscreen: Cell::new(fullscreen), + initial_placement: Cell::new(initial_placement), hwnd, invalidate_devices, }) @@ -149,7 +157,7 @@ impl WindowsWindowState { #[inline] pub(crate) fn is_fullscreen(&self) -> bool { - self.fullscreen.is_some() + self.fullscreen.get().is_some() } pub(crate) fn is_maximized(&self) -> bool { @@ -158,8 +166,8 @@ impl WindowsWindowState { fn bounds(&self) -> Bounds { Bounds { - origin: self.origin, - size: self.logical_size, + origin: self.origin.get(), + size: self.logical_size.get(), } } @@ -178,8 +186,8 @@ impl WindowsWindowState { ( calculate_client_rect( placement.rcNormalPosition, - self.border_offset, - self.scale_factor, + &self.border_offset, + self.scale_factor.get(), ), placement.showCmd == SW_SHOWMAXIMIZED.0 as u32, ) @@ -189,7 +197,7 @@ impl WindowsWindowState { let (bounds, maximized) = self.calculate_window_bounds(); if self.is_fullscreen() { - WindowBounds::Fullscreen(self.fullscreen_restore_bounds) + WindowBounds::Fullscreen(self.fullscreen_restore_bounds.get()) } else if maximized { WindowBounds::Maximized(bounds) } else { @@ -202,13 +210,13 @@ impl WindowsWindowState { /// Currently, GPUI uses the logical size of the app to handle mouse interactions (such as /// whether the mouse collides with other elements of GPUI). fn content_size(&self) -> Size { - self.logical_size + self.logical_size.get() } } impl WindowsWindowInner { fn new(context: &mut WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result> { - let state = RefCell::new(WindowsWindowState::new( + let state = WindowsWindowState::new( hwnd, &context.directx_devices, cs, @@ -218,7 +226,7 @@ impl WindowsWindowInner { context.appearance, context.disable_direct_composition, context.invalidate_devices.clone(), - )?); + )?; Ok(Rc::new(Self { hwnd, @@ -232,7 +240,7 @@ impl WindowsWindowInner { validation_number: context.validation_number, main_receiver: context.main_receiver.clone(), platform_window_handle: context.platform_window_handle, - system_settings: RefCell::new(WindowsSystemSettings::new(context.display)), + system_settings: WindowsSystemSettings::new(context.display), })) } @@ -240,19 +248,17 @@ impl WindowsWindowInner { let this = self.clone(); self.executor .spawn(async move { - let mut lock = this.state.borrow_mut(); let StyleAndBounds { style, x, y, cx, cy, - } = match lock.fullscreen.take() { + } = match this.state.fullscreen.take() { Some(state) => state, None => { - let (window_bounds, _) = lock.calculate_window_bounds(); - lock.fullscreen_restore_bounds = window_bounds; - drop(lock); + let (window_bounds, _) = this.state.calculate_window_bounds(); + this.state.fullscreen_restore_bounds.set(window_bounds); let style = WINDOW_STYLE(unsafe { get_window_long(this.hwnd, GWL_STYLE) } as _); @@ -260,22 +266,20 @@ impl WindowsWindowInner { unsafe { GetWindowRect(this.hwnd, &mut rc) } .context("failed to get window rect") .log_err(); - - lock = this.state.borrow_mut(); - let _ = lock.fullscreen.insert(StyleAndBounds { + let _ = this.state.fullscreen.set(Some(StyleAndBounds { style, x: rc.left, y: rc.top, cx: rc.right - rc.left, cy: rc.bottom - rc.top, - }); + })); let style = style & !(WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_CAPTION); - let physical_bounds = lock.display.physical_bounds(); + let physical_bounds = this.state.display.get().physical_bounds(); StyleAndBounds { style, x: physical_bounds.left().0, @@ -285,7 +289,6 @@ impl WindowsWindowInner { } } }; - drop(lock); unsafe { set_window_long(this.hwnd, GWL_STYLE, style.0 as isize) }; unsafe { SetWindowPos( @@ -304,7 +307,7 @@ impl WindowsWindowInner { } fn set_window_placement(self: &Rc) -> Result<()> { - let Some(open_status) = self.state.borrow_mut().initial_placement.take() else { + let Some(open_status) = self.state.initial_placement.take() else { return Ok(()); }; match open_status.state { @@ -328,27 +331,23 @@ impl WindowsWindowInner { Ok(()) } - pub(crate) fn system_settings(&self) -> std::cell::Ref<'_, WindowsSystemSettings> { - self.system_settings.borrow() - } - - pub(crate) fn system_settings_mut(&self) -> std::cell::RefMut<'_, WindowsSystemSettings> { - self.system_settings.borrow_mut() + pub(crate) fn system_settings(&self) -> &WindowsSystemSettings { + &self.system_settings } } #[derive(Default)] pub(crate) struct Callbacks { - pub(crate) request_frame: Option>, - pub(crate) input: Option DispatchEventResult>>, - pub(crate) active_status_change: Option>, - pub(crate) hovered_status_change: Option>, - pub(crate) resize: Option, f32)>>, - pub(crate) moved: Option>, - pub(crate) should_close: Option bool>>, - pub(crate) close: Option>, - pub(crate) hit_test_window_control: Option Option>>, - pub(crate) appearance_changed: Option>, + pub(crate) request_frame: Cell>>, + pub(crate) input: Cell DispatchEventResult>>>, + pub(crate) active_status_change: Cell>>, + pub(crate) hovered_status_change: Cell>>, + pub(crate) resize: Cell, f32)>>>, + pub(crate) moved: Cell>>, + pub(crate) should_close: Cell bool>>>, + pub(crate) close: Cell>>, + pub(crate) hit_test_window_control: Cell Option>>>, + pub(crate) appearance_changed: Cell>>, } struct WindowCreateContext { @@ -476,21 +475,21 @@ impl WindowsWindow { register_drag_drop(&this)?; configure_dwm_dark_mode(hwnd, appearance); - this.state.borrow_mut().border_offset.update(hwnd)?; + this.state.border_offset.update(hwnd)?; let placement = retrieve_window_placement( hwnd, display, params.bounds, - this.state.borrow().scale_factor, - this.state.borrow().border_offset, + this.state.scale_factor.get(), + &this.state.border_offset, )?; if params.show { unsafe { SetWindowPlacement(hwnd, &placement)? }; } else { - this.state.borrow_mut().initial_placement = Some(WindowOpenStatus { + this.state.initial_placement.set(Some(WindowOpenStatus { placement, state: WindowOpenState::Windowed, - }); + })); } Ok(Self(this)) @@ -533,15 +532,15 @@ impl Drop for WindowsWindow { impl PlatformWindow for WindowsWindow { fn bounds(&self) -> Bounds { - self.0.state.borrow().bounds() + self.state.bounds() } fn is_maximized(&self) -> bool { - self.0.state.borrow().is_maximized() + self.state.is_maximized() } fn window_bounds(&self) -> WindowBounds { - self.0.state.borrow().window_bounds() + self.state.window_bounds() } /// get the logical size of the app's drawable area. @@ -549,14 +548,14 @@ impl PlatformWindow for WindowsWindow { /// Currently, GPUI uses the logical size of the app to handle mouse interactions (such as /// whether the mouse collides with other elements of GPUI). fn content_size(&self) -> Size { - self.0.state.borrow().content_size() + self.state.content_size() } fn resize(&mut self, size: Size) { let hwnd = self.0.hwnd; let bounds = crate::bounds(self.bounds().origin, size).to_device_pixels(self.scale_factor()); - let rect = calculate_window_rect(bounds, self.0.state.borrow().border_offset); + let rect = calculate_window_rect(bounds, &self.state.border_offset); self.0 .executor @@ -579,15 +578,15 @@ impl PlatformWindow for WindowsWindow { } fn scale_factor(&self) -> f32 { - self.0.state.borrow().scale_factor + self.state.scale_factor.get() } fn appearance(&self) -> WindowAppearance { - self.0.state.borrow().appearance + self.state.appearance.get() } fn display(&self) -> Option> { - Some(Rc::new(self.0.state.borrow().display)) + Some(Rc::new(self.state.display.get())) } fn mouse_position(&self) -> Point { @@ -612,11 +611,11 @@ impl PlatformWindow for WindowsWindow { } fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { - self.0.state.borrow_mut().input_handler = Some(input_handler); + self.state.input_handler.set(Some(input_handler)); } fn take_input_handler(&mut self) -> Option { - self.0.state.borrow_mut().input_handler.take() + self.state.input_handler.take() } fn prompt( @@ -762,7 +761,7 @@ impl PlatformWindow for WindowsWindow { } fn is_hovered(&self) -> bool { - self.0.state.borrow().hovered + self.state.hovered.get() } fn set_title(&mut self, title: &str) { @@ -805,8 +804,9 @@ impl PlatformWindow for WindowsWindow { unsafe { if IsWindowVisible(self.0.hwnd).as_bool() { ShowWindowAsync(self.0.hwnd, SW_MAXIMIZE).ok().log_err(); - } else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() { + } else if let Some(mut status) = self.state.initial_placement.take() { status.state = WindowOpenState::Maximized; + self.state.initial_placement.set(Some(status)); } } } @@ -814,61 +814,78 @@ impl PlatformWindow for WindowsWindow { fn toggle_fullscreen(&self) { if unsafe { IsWindowVisible(self.0.hwnd).as_bool() } { self.0.toggle_fullscreen(); - } else if let Some(status) = self.0.state.borrow_mut().initial_placement.as_mut() { + } else if let Some(mut status) = self.state.initial_placement.take() { status.state = WindowOpenState::Fullscreen; + self.state.initial_placement.set(Some(status)); } } fn is_fullscreen(&self) -> bool { - self.0.state.borrow().is_fullscreen() + self.state.is_fullscreen() } fn on_request_frame(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.request_frame = Some(callback); + self.state.callbacks.request_frame.set(Some(callback)); } fn on_input(&self, callback: Box DispatchEventResult>) { - self.0.state.borrow_mut().callbacks.input = Some(callback); + self.state.callbacks.input.set(Some(callback)); } fn on_active_status_change(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.active_status_change = Some(callback); + self.0 + .state + .callbacks + .active_status_change + .set(Some(callback)); } fn on_hover_status_change(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + self.0 + .state + .callbacks + .hovered_status_change + .set(Some(callback)); } fn on_resize(&self, callback: Box, f32)>) { - self.0.state.borrow_mut().callbacks.resize = Some(callback); + self.state.callbacks.resize.set(Some(callback)); } fn on_moved(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.moved = Some(callback); + self.state.callbacks.moved.set(Some(callback)); } fn on_should_close(&self, callback: Box bool>) { - self.0.state.borrow_mut().callbacks.should_close = Some(callback); + self.state.callbacks.should_close.set(Some(callback)); } fn on_close(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.close = Some(callback); + self.state.callbacks.close.set(Some(callback)); } fn on_hit_test_window_control(&self, callback: Box Option>) { - self.0.state.borrow_mut().callbacks.hit_test_window_control = Some(callback); + self.0 + .state + .callbacks + .hit_test_window_control + .set(Some(callback)); } fn on_appearance_changed(&self, callback: Box) { - self.0.state.borrow_mut().callbacks.appearance_changed = Some(callback); + self.0 + .state + .callbacks + .appearance_changed + .set(Some(callback)); } fn draw(&self, scene: &Scene) { - self.0.state.borrow_mut().renderer.draw(scene).log_err(); + self.state.renderer.borrow_mut().draw(scene).log_err(); } fn sprite_atlas(&self) -> Arc { - self.0.state.borrow().renderer.sprite_atlas() + self.state.renderer.borrow().sprite_atlas() } fn get_raw_handle(&self) -> HWND { @@ -876,7 +893,7 @@ impl PlatformWindow for WindowsWindow { } fn gpu_specs(&self) -> Option { - self.0.state.borrow().renderer.gpu_specs().log_err() + self.state.renderer.borrow().gpu_specs().log_err() } fn update_ime_position(&self, _bounds: Bounds) { @@ -889,11 +906,9 @@ struct WindowsDragDropHandler(pub Rc); impl WindowsDragDropHandler { fn handle_drag_drop(&self, input: PlatformInput) { - let mut lock = self.0.state.borrow_mut(); - if let Some(mut func) = lock.callbacks.input.take() { - drop(lock); + if let Some(mut func) = self.0.state.callbacks.input.take() { func(input); - self.0.state.borrow_mut().callbacks.input = Some(func); + self.0.state.callbacks.input.set(Some(func)); } } } @@ -937,7 +952,7 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { ScreenToClient(self.0.hwnd, &mut cursor_position) .ok() .log_err(); - let scale_factor = self.0.state.borrow().scale_factor; + let scale_factor = self.0.state.scale_factor.get(); let input = PlatformInput::FileDrop(FileDropEvent::Entered { position: logical_point( cursor_position.x as f32, @@ -975,7 +990,7 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { .ok() .log_err(); } - let scale_factor = self.0.state.borrow().scale_factor; + let scale_factor = self.0.state.scale_factor.get(); let input = PlatformInput::FileDrop(FileDropEvent::Pending { position: logical_point( cursor_position.x as f32, @@ -1017,7 +1032,7 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { .ok() .log_err(); } - let scale_factor = self.0.state.borrow().scale_factor; + let scale_factor = self.0.state.scale_factor.get(); let input = PlatformInput::FileDrop(FileDropEvent::Submit { position: logical_point( cursor_position.x as f32, @@ -1031,15 +1046,15 @@ impl IDropTarget_Impl for WindowsDragDropHandler_Impl { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct ClickState { - button: MouseButton, - last_click: Instant, - last_position: Point, - double_click_spatial_tolerance_width: i32, - double_click_spatial_tolerance_height: i32, - double_click_interval: Duration, - pub(crate) current_count: usize, + button: Cell, + last_click: Cell, + last_position: Cell>, + double_click_spatial_tolerance_width: Cell, + double_click_spatial_tolerance_height: Cell, + double_click_interval: Cell, + pub(crate) current_count: Cell, } impl ClickState { @@ -1049,61 +1064,59 @@ impl ClickState { let double_click_interval = Duration::from_millis(unsafe { GetDoubleClickTime() } as u64); ClickState { - button: MouseButton::Left, - last_click: Instant::now(), - last_position: Point::default(), - double_click_spatial_tolerance_width, - double_click_spatial_tolerance_height, - double_click_interval, - current_count: 0, + button: Cell::new(MouseButton::Left), + last_click: Cell::new(Instant::now()), + last_position: Cell::new(Point::default()), + double_click_spatial_tolerance_width: Cell::new(double_click_spatial_tolerance_width), + double_click_spatial_tolerance_height: Cell::new(double_click_spatial_tolerance_height), + double_click_interval: Cell::new(double_click_interval), + current_count: Cell::new(0), } } /// update self and return the needed click count - pub fn update(&mut self, button: MouseButton, new_position: Point) -> usize { - if self.button == button && self.is_double_click(new_position) { - self.current_count += 1; + pub fn update(&self, button: MouseButton, new_position: Point) -> usize { + if self.button.get() == button && self.is_double_click(new_position) { + self.current_count.update(|it| it + 1); } else { - self.current_count = 1; + self.current_count.set(1); } - self.last_click = Instant::now(); - self.last_position = new_position; - self.button = button; + self.last_click.set(Instant::now()); + self.last_position.set(new_position); + self.button.set(button); - self.current_count + self.current_count.get() } - pub fn system_update(&mut self, wparam: usize) { + pub fn system_update(&self, wparam: usize) { match wparam { // SPI_SETDOUBLECLKWIDTH - 29 => { - self.double_click_spatial_tolerance_width = - unsafe { GetSystemMetrics(SM_CXDOUBLECLK) } - } + 29 => self + .double_click_spatial_tolerance_width + .set(unsafe { GetSystemMetrics(SM_CXDOUBLECLK) }), // SPI_SETDOUBLECLKHEIGHT - 30 => { - self.double_click_spatial_tolerance_height = - unsafe { GetSystemMetrics(SM_CYDOUBLECLK) } - } + 30 => self + .double_click_spatial_tolerance_height + .set(unsafe { GetSystemMetrics(SM_CYDOUBLECLK) }), // SPI_SETDOUBLECLICKTIME - 32 => { - self.double_click_interval = - Duration::from_millis(unsafe { GetDoubleClickTime() } as u64) - } + 32 => self + .double_click_interval + .set(Duration::from_millis(unsafe { GetDoubleClickTime() } as u64)), _ => {} } } #[inline] fn is_double_click(&self, new_position: Point) -> bool { - let diff = self.last_position - new_position; + let diff = self.last_position.get() - new_position; - self.last_click.elapsed() < self.double_click_interval - && diff.x.0.abs() <= self.double_click_spatial_tolerance_width - && diff.y.0.abs() <= self.double_click_spatial_tolerance_height + self.last_click.get().elapsed() < self.double_click_interval.get() + && diff.x.0.abs() <= self.double_click_spatial_tolerance_width.get() + && diff.y.0.abs() <= self.double_click_spatial_tolerance_height.get() } } +#[derive(Copy, Clone)] struct StyleAndBounds { style: WINDOW_STYLE, x: i32, @@ -1129,14 +1142,14 @@ struct AccentPolicy { type Color = (u8, u8, u8, u8); -#[derive(Debug, Default, Clone, Copy)] +#[derive(Debug, Default, Clone)] pub(crate) struct WindowBorderOffset { - pub(crate) width_offset: i32, - pub(crate) height_offset: i32, + pub(crate) width_offset: Cell, + pub(crate) height_offset: Cell, } impl WindowBorderOffset { - pub(crate) fn update(&mut self, hwnd: HWND) -> anyhow::Result<()> { + pub(crate) fn update(&self, hwnd: HWND) -> anyhow::Result<()> { let window_rect = unsafe { let mut rect = std::mem::zeroed(); GetWindowRect(hwnd, &mut rect)?; @@ -1147,19 +1160,21 @@ impl WindowBorderOffset { GetClientRect(hwnd, &mut rect)?; rect }; - self.width_offset = - (window_rect.right - window_rect.left) - (client_rect.right - client_rect.left); - self.height_offset = - (window_rect.bottom - window_rect.top) - (client_rect.bottom - client_rect.top); + self.width_offset + .set((window_rect.right - window_rect.left) - (client_rect.right - client_rect.left)); + self.height_offset + .set((window_rect.bottom - window_rect.top) - (client_rect.bottom - client_rect.top)); Ok(()) } } +#[derive(Clone)] struct WindowOpenStatus { placement: WINDOWPLACEMENT, state: WindowOpenState, } +#[derive(Clone, Copy)] enum WindowOpenState { Maximized, Fullscreen, @@ -1269,7 +1284,7 @@ fn register_drag_drop(window: &Rc) -> Result<()> { Ok(()) } -fn calculate_window_rect(bounds: Bounds, border_offset: WindowBorderOffset) -> RECT { +fn calculate_window_rect(bounds: Bounds, border_offset: &WindowBorderOffset) -> RECT { // NOTE: // The reason we're not using `AdjustWindowRectEx()` here is // that the size reported by this function is incorrect. @@ -1283,10 +1298,10 @@ fn calculate_window_rect(bounds: Bounds, border_offset: WindowBord right: bounds.right().0, bottom: bounds.bottom().0, }; - let left_offset = border_offset.width_offset / 2; - let top_offset = border_offset.height_offset / 2; - let right_offset = border_offset.width_offset - left_offset; - let bottom_offset = border_offset.height_offset - top_offset; + let left_offset = border_offset.width_offset.get() / 2; + let top_offset = border_offset.height_offset.get() / 2; + let right_offset = border_offset.width_offset.get() - left_offset; + let bottom_offset = border_offset.height_offset.get() - top_offset; rect.left -= left_offset; rect.top -= top_offset; rect.right += right_offset; @@ -1296,13 +1311,13 @@ fn calculate_window_rect(bounds: Bounds, border_offset: WindowBord fn calculate_client_rect( rect: RECT, - border_offset: WindowBorderOffset, + border_offset: &WindowBorderOffset, scale_factor: f32, ) -> Bounds { - let left_offset = border_offset.width_offset / 2; - let top_offset = border_offset.height_offset / 2; - let right_offset = border_offset.width_offset - left_offset; - let bottom_offset = border_offset.height_offset - top_offset; + let left_offset = border_offset.width_offset.get() / 2; + let top_offset = border_offset.height_offset.get() / 2; + let right_offset = border_offset.width_offset.get() - left_offset; + let bottom_offset = border_offset.height_offset.get() - top_offset; let left = rect.left + left_offset; let top = rect.top + top_offset; let right = rect.right - right_offset; @@ -1319,7 +1334,7 @@ fn retrieve_window_placement( display: WindowsDisplay, initial_bounds: Bounds, scale_factor: f32, - border_offset: WindowBorderOffset, + border_offset: &WindowBorderOffset, ) -> Result { let mut placement = WINDOWPLACEMENT { length: std::mem::size_of::() as u32, @@ -1429,7 +1444,9 @@ mod tests { state.update(MouseButton::Left, point(DevicePixels(0), DevicePixels(0))), 2 ); - state.last_click -= Duration::from_millis(700); + state + .last_click + .update(|it| it - Duration::from_millis(700)); assert_eq!( state.update(MouseButton::Left, point(DevicePixels(0), DevicePixels(0))), 1 From 2db237aa52ff4aaa0b55b95167f6bc5a04272ad3 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 2 Dec 2025 13:22:16 -0300 Subject: [PATCH 03/67] Limit edit prediction reject batches to max (#43965) We currently attempt to flush all rejected predictions at once even if we have accumulated more than `MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST`. Instead, we will now flush as many as possible, and then keep the rest for the next batch. Release Notes: - N/A --- .../cloud_llm_client/src/cloud_llm_client.rs | 7 +- crates/zeta/src/provider.rs | 11 +- crates/zeta/src/zeta.rs | 306 ++++++++++++------ 3 files changed, 221 insertions(+), 103 deletions(-) diff --git a/crates/cloud_llm_client/src/cloud_llm_client.rs b/crates/cloud_llm_client/src/cloud_llm_client.rs index 35916bd6801485c8c2bfde9330a47da19025f2c3..917929a985c85610b907e682792e132cb84d8403 100644 --- a/crates/cloud_llm_client/src/cloud_llm_client.rs +++ b/crates/cloud_llm_client/src/cloud_llm_client.rs @@ -206,11 +206,16 @@ pub struct AcceptEditPredictionBody { pub request_id: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RejectEditPredictionsBody { pub rejections: Vec, } +#[derive(Debug, Clone, Serialize)] +pub struct RejectEditPredictionsBodyRef<'a> { + pub rejections: &'a [EditPredictionRejection], +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct EditPredictionRejection { pub request_id: String, diff --git a/crates/zeta/src/provider.rs b/crates/zeta/src/provider.rs index 5a2117397b7dd94d1fd61c4fb9880ebe447dbc1f..019d780e579c079f745f56136bdbd3a4add76b50 100644 --- a/crates/zeta/src/provider.rs +++ b/crates/zeta/src/provider.rs @@ -132,12 +132,8 @@ impl EditPredictionProvider for ZetaEditPredictionProvider { } fn discard(&mut self, cx: &mut Context) { - self.zeta.update(cx, |zeta, cx| { - zeta.reject_current_prediction( - EditPredictionRejectReason::Discarded, - &self.project, - cx, - ); + self.zeta.update(cx, |zeta, _cx| { + zeta.reject_current_prediction(EditPredictionRejectReason::Discarded, &self.project); }); } @@ -173,11 +169,10 @@ impl EditPredictionProvider for ZetaEditPredictionProvider { let snapshot = buffer.snapshot(); let Some(edits) = prediction.interpolate(&snapshot) else { - self.zeta.update(cx, |zeta, cx| { + self.zeta.update(cx, |zeta, _cx| { zeta.reject_current_prediction( EditPredictionRejectReason::InterpolatedEmpty, &self.project, - cx, ); }); return None; diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 909f21200cc7c055adb80b1e510e6f13e7fc9784..dba90abbc839566781d18308e53c4b0faa96e1d7 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -5,7 +5,7 @@ use cloud_llm_client::predict_edits_v3::{self, Event, PromptFormat, Signature}; use cloud_llm_client::{ AcceptEditPredictionBody, EXPIRED_LLM_TOKEN_HEADER_NAME, EditPredictionRejectReason, EditPredictionRejection, MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST, - MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBody, + MINIMUM_REQUIRED_VERSION_HEADER_NAME, PredictEditsRequestTrigger, RejectEditPredictionsBodyRef, ZED_VERSION_HEADER_NAME, }; use cloud_zeta2_prompt::retrieval_prompt::{SearchToolInput, SearchToolQuery}; @@ -19,8 +19,10 @@ use edit_prediction_context::{ SyntaxIndex, SyntaxIndexState, }; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PredictEditsRateCompletionsFeatureFlag}; +use futures::channel::mpsc::UnboundedReceiver; use futures::channel::{mpsc, oneshot}; -use futures::{AsyncReadExt as _, FutureExt as _, StreamExt as _}; +use futures::{AsyncReadExt as _, FutureExt as _, StreamExt as _, select_biased}; +use gpui::BackgroundExecutor; use gpui::{ App, AsyncApp, Entity, EntityId, Global, SharedString, Subscription, Task, WeakEntity, actions, http_client::{self, AsyncBody, Method}, @@ -100,6 +102,7 @@ actions!( const EVENT_COUNT_MAX: usize = 6; const CHANGE_GROUPING_LINE_SPAN: u32 = 8; const ZED_PREDICT_DATA_COLLECTION_CHOICE: &str = "zed_predict_data_collection_choice"; +const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); pub struct SweepFeatureFlag; @@ -195,9 +198,7 @@ pub struct Zeta { edit_prediction_model: ZetaEditPredictionModel, pub sweep_ai: SweepAi, data_collection_choice: DataCollectionChoice, - rejected_predictions: Vec, - reject_predictions_tx: mpsc::UnboundedSender<()>, - reject_predictions_debounce_task: Option>, + reject_predictions_tx: mpsc::UnboundedSender, shown_predictions: VecDeque, rated_predictions: HashSet, } @@ -325,13 +326,8 @@ impl ZetaProject { return; }; - this.update(cx, |this, cx| { - this.reject_prediction( - prediction_id, - EditPredictionRejectReason::Canceled, - false, - cx, - ); + this.update(cx, |this, _cx| { + this.reject_prediction(prediction_id, EditPredictionRejectReason::Canceled, false); }) .ok(); }) @@ -504,14 +500,24 @@ impl Zeta { let refresh_llm_token_listener = RefreshLlmTokenListener::global(cx); let data_collection_choice = Self::load_data_collection_choice(); - let (reject_tx, mut reject_rx) = mpsc::unbounded(); - cx.spawn(async move |this, cx| { - while let Some(()) = reject_rx.next().await { - this.update(cx, |this, cx| this.flush_rejected_predictions(cx))? - .await - .log_err(); + let llm_token = LlmApiToken::default(); + + let (reject_tx, reject_rx) = mpsc::unbounded(); + cx.background_spawn({ + let client = client.clone(); + let llm_token = llm_token.clone(); + let app_version = AppVersion::global(cx); + let background_executor = cx.background_executor().clone(); + async move { + Self::handle_rejected_predictions( + reject_rx, + client, + llm_token, + app_version, + background_executor, + ) + .await } - anyhow::Ok(()) }) .detach(); @@ -520,7 +526,7 @@ impl Zeta { client, user_store, options: DEFAULT_OPTIONS, - llm_token: LlmApiToken::default(), + llm_token, _llm_token_subscription: cx.subscribe( &refresh_llm_token_listener, |this, _listener, _event, cx| { @@ -540,8 +546,6 @@ impl Zeta { edit_prediction_model: ZetaEditPredictionModel::Zeta2, sweep_ai: SweepAi::new(cx), data_collection_choice, - rejected_predictions: Vec::new(), - reject_predictions_debounce_task: None, reject_predictions_tx: reject_tx, rated_predictions: Default::default(), shown_predictions: Default::default(), @@ -901,64 +905,73 @@ impl Zeta { .detach_and_log_err(cx); } - fn flush_rejected_predictions(&mut self, cx: &mut Context) -> Task> { - match self.edit_prediction_model { - ZetaEditPredictionModel::Zeta1 | ZetaEditPredictionModel::Zeta2 => {} - ZetaEditPredictionModel::Sweep => return Task::ready(anyhow::Ok(())), - } + async fn handle_rejected_predictions( + rx: UnboundedReceiver, + client: Arc, + llm_token: LlmApiToken, + app_version: Version, + background_executor: BackgroundExecutor, + ) { + let mut rx = std::pin::pin!(rx.peekable()); + let mut batched = Vec::new(); - let client = self.client.clone(); - let llm_token = self.llm_token.clone(); - let app_version = AppVersion::global(cx); - let last_rejection = self.rejected_predictions.last().cloned(); - let Some(last_rejection) = last_rejection else { - return Task::ready(anyhow::Ok(())); - }; + while let Some(rejection) = rx.next().await { + batched.push(rejection); - let body = serde_json::to_string(&RejectEditPredictionsBody { - rejections: self.rejected_predictions.clone(), - }) - .ok(); + if batched.len() < MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST / 2 { + select_biased! { + next = rx.as_mut().peek().fuse() => { + if next.is_some() { + continue; + } + } + () = background_executor.timer(REJECT_REQUEST_DEBOUNCE).fuse() => {}, + } + } - cx.spawn(async move |this, cx| { let url = client .http_client() - .build_zed_llm_url("/predict_edits/reject", &[])?; + .build_zed_llm_url("/predict_edits/reject", &[]) + .unwrap(); - cx.background_spawn(Self::send_api_request::<()>( - move |builder| { - let req = builder.uri(url.as_ref()).body(body.clone().into()); - Ok(req?) + let flush_count = batched + .len() + // in case items have accumulated after failure + .min(MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST); + let start = batched.len() - flush_count; + + let body = RejectEditPredictionsBodyRef { + rejections: &batched[start..], + }; + + let result = Self::send_api_request::<()>( + |builder| { + let req = builder + .uri(url.as_ref()) + .body(serde_json::to_string(&body)?.into()); + anyhow::Ok(req?) }, - client, - llm_token, - app_version, - )) - .await - .context("Failed to reject edit predictions")?; + client.clone(), + llm_token.clone(), + app_version.clone(), + ) + .await; - this.update(cx, |this, _| { - if let Some(ix) = this - .rejected_predictions - .iter() - .position(|rejection| rejection.request_id == last_rejection.request_id) - { - this.rejected_predictions.drain(..ix + 1); - } - }) - }) + if result.log_err().is_some() { + batched.drain(start..); + } + } } fn reject_current_prediction( &mut self, reason: EditPredictionRejectReason, project: &Entity, - cx: &mut Context, ) { if let Some(project_state) = self.projects.get_mut(&project.entity_id()) { project_state.pending_predictions.clear(); if let Some(prediction) = project_state.current_prediction.take() { - self.reject_prediction(prediction.prediction.id, reason, prediction.was_shown, cx); + self.reject_prediction(prediction.prediction.id, reason, prediction.was_shown); } }; } @@ -984,26 +997,14 @@ impl Zeta { prediction_id: EditPredictionId, reason: EditPredictionRejectReason, was_shown: bool, - cx: &mut Context, ) { - self.rejected_predictions.push(EditPredictionRejection { - request_id: prediction_id.to_string(), - reason, - was_shown, - }); - - let reached_request_limit = - self.rejected_predictions.len() >= MAX_EDIT_PREDICTION_REJECTIONS_PER_REQUEST / 2; - let reject_tx = self.reject_predictions_tx.clone(); - self.reject_predictions_debounce_task = Some(cx.spawn(async move |_this, cx| { - const REJECT_REQUEST_DEBOUNCE: Duration = Duration::from_secs(15); - if !reached_request_limit { - cx.background_executor() - .timer(REJECT_REQUEST_DEBOUNCE) - .await; - } - reject_tx.unbounded_send(()).log_err(); - })); + self.reject_predictions_tx + .unbounded_send(EditPredictionRejection { + request_id: prediction_id.to_string(), + reason, + was_shown, + }) + .log_err(); } fn is_refreshing(&self, project: &Entity) -> bool { @@ -1211,7 +1212,6 @@ impl Zeta { this.reject_current_prediction( EditPredictionRejectReason::Replaced, &project, - cx, ); Some(new_prediction) @@ -1220,7 +1220,6 @@ impl Zeta { new_prediction.prediction.id, EditPredictionRejectReason::CurrentPreferred, false, - cx, ); None } @@ -1229,7 +1228,7 @@ impl Zeta { } } Err(reject_reason) => { - this.reject_prediction(prediction_result.id, reject_reason, false, cx); + this.reject_prediction(prediction_result.id, reject_reason, false); None } } @@ -2906,7 +2905,7 @@ fn feature_gate_predict_edits_actions(cx: &mut App) { #[cfg(test)] mod tests { - use std::{path::Path, sync::Arc}; + use std::{path::Path, sync::Arc, time::Duration}; use client::UserStore; use clock::FakeSystemClock; @@ -2933,7 +2932,7 @@ mod tests { use util::path; use uuid::Uuid; - use crate::{BufferEditPrediction, Zeta}; + use crate::{BufferEditPrediction, EditPredictionId, REJECT_REQUEST_DEBOUNCE, Zeta}; #[gpui::test] async fn test_current_state(cx: &mut TestAppContext) { @@ -3035,8 +3034,8 @@ mod tests { .unwrap(); refresh_task.await.unwrap(); - zeta.update(cx, |zeta, cx| { - zeta.reject_current_prediction(EditPredictionRejectReason::Discarded, &project, cx); + zeta.update(cx, |zeta, _cx| { + zeta.reject_current_prediction(EditPredictionRejectReason::Discarded, &project); }); // Prediction for another file @@ -3545,14 +3544,17 @@ mod tests { let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); let position = snapshot.anchor_before(language::Point::new(1, 3)); + // start two refresh tasks zeta.update(cx, |zeta, cx| { - // start two refresh tasks zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + let (_, respond_first) = requests.predict.next().await.unwrap(); + + zeta.update(cx, |zeta, cx| { zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); - let (_, respond_first) = requests.predict.next().await.unwrap(); let (_, respond_second) = requests.predict.next().await.unwrap(); // wait for throttle @@ -3631,18 +3633,22 @@ mod tests { let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot()); let position = snapshot.anchor_before(language::Point::new(1, 3)); + // start two refresh tasks zeta.update(cx, |zeta, cx| { - // start two refresh tasks zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); + }); + + let (_, respond_first) = requests.predict.next().await.unwrap(); + + zeta.update(cx, |zeta, cx| { zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); }); + let (_, respond_second) = requests.predict.next().await.unwrap(); + // wait for throttle, so requests are sent cx.run_until_parked(); - let (_, respond_first) = requests.predict.next().await.unwrap(); - let (_, respond_second) = requests.predict.next().await.unwrap(); - zeta.update(cx, |zeta, cx| { // start a third request zeta.refresh_prediction_from_buffer(project.clone(), buffer.clone(), position, cx); @@ -3736,6 +3742,118 @@ mod tests { ); } + #[gpui::test] + async fn test_rejections_flushing(cx: &mut TestAppContext) { + let (zeta, mut requests) = init_test(cx); + + zeta.update(cx, |zeta, _cx| { + zeta.reject_prediction( + EditPredictionId("test-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + zeta.reject_prediction( + EditPredictionId("test-2".into()), + EditPredictionRejectReason::Canceled, + true, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + // batched + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!( + reject_request.rejections[0], + EditPredictionRejection { + request_id: "test-1".to_string(), + reason: EditPredictionRejectReason::Discarded, + was_shown: false + } + ); + assert_eq!( + reject_request.rejections[1], + EditPredictionRejection { + request_id: "test-2".to_string(), + reason: EditPredictionRejectReason::Canceled, + was_shown: true + } + ); + + // Reaching batch size limit sends without debounce + zeta.update(cx, |zeta, _cx| { + for i in 0..70 { + zeta.reject_prediction( + EditPredictionId(format!("batch-{}", i).into()), + EditPredictionRejectReason::Discarded, + false, + ); + } + }); + + // First MAX/2 items are sent immediately + cx.run_until_parked(); + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 50); + assert_eq!(reject_request.rejections[0].request_id, "batch-0"); + assert_eq!(reject_request.rejections[49].request_id, "batch-49"); + + // Remaining items are debounced with the next batch + cx.executor().advance_clock(Duration::from_secs(15)); + cx.run_until_parked(); + + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 20); + assert_eq!(reject_request.rejections[0].request_id, "batch-50"); + assert_eq!(reject_request.rejections[19].request_id, "batch-69"); + + // Request failure + zeta.update(cx, |zeta, _cx| { + zeta.reject_prediction( + EditPredictionId("retry-1".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + let (reject_request, _respond_tx) = requests.reject.next().await.unwrap(); + assert_eq!(reject_request.rejections.len(), 1); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + // Simulate failure + drop(_respond_tx); + + // Add another rejection + zeta.update(cx, |zeta, _cx| { + zeta.reject_prediction( + EditPredictionId("retry-2".into()), + EditPredictionRejectReason::Discarded, + false, + ); + }); + + cx.executor().advance_clock(REJECT_REQUEST_DEBOUNCE); + cx.run_until_parked(); + + // Retry should include both the failed item and the new one + let (reject_request, respond_tx) = requests.reject.next().await.unwrap(); + respond_tx.send(()).unwrap(); + + assert_eq!(reject_request.rejections.len(), 2); + assert_eq!(reject_request.rejections[0].request_id, "retry-1"); + assert_eq!(reject_request.rejections[1].request_id, "retry-2"); + } + // Skipped until we start including diagnostics in prompt // #[gpui::test] // async fn test_request_diagnostics(cx: &mut TestAppContext) { From 3d5873854813cef502bc96e6b24b7d1f5dc8f900 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 2 Dec 2025 13:27:36 -0300 Subject: [PATCH 04/67] collab: Add action to copy room id (#44004) Adds a `collab: copy room id` action which copies the live kit room name and session ID to the clipboard. Release Notes: - N/A --- crates/call/src/call_impl/room.rs | 10 ++++++++ crates/collab_ui/src/collab_panel.rs | 28 ++++++++++++++++++++- crates/livekit_client/src/livekit_client.rs | 8 ++++++ crates/livekit_client/src/test.rs | 8 ++++++ crates/workspace/src/workspace.rs | 4 ++- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/call/src/call_impl/room.rs b/crates/call/src/call_impl/room.rs index 2a540619d4576ec7fcf711b288ecc12bf89fd20c..fc15b4e4395ae7aa3100a165d942a6906cf1976d 100644 --- a/crates/call/src/call_impl/room.rs +++ b/crates/call/src/call_impl/room.rs @@ -524,6 +524,16 @@ impl Room { self.id } + pub fn room_id(&self) -> impl Future> + 'static { + let room = self.live_kit.as_ref().map(|lk| lk.room.clone()); + async move { + let room = room?; + let sid = room.sid().await; + let name = room.name(); + Some(format!("{} (sid: {sid})", name)) + } + } + pub fn status(&self) -> RoomStatus { self.status } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 618348307f1270e180faf4b1d061b9a942e39fa5..7137af21d315391383d3007c148807a7604a1155 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -37,7 +37,7 @@ use ui::{ }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ - Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, + CopyRoomId, Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt}, }; @@ -128,6 +128,32 @@ pub fn init(cx: &mut App) { workspace.register_action(|_, _: &LeaveCall, window, cx| { CollabPanel::leave_call(window, cx); }); + workspace.register_action(|workspace, _: &CopyRoomId, window, cx| { + use workspace::notifications::{NotificationId, NotifyTaskExt as _}; + + struct RoomIdCopiedToast; + + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + let romo_id_fut = room.read(cx).room_id(); + cx.spawn(async move |workspace, cx| { + let room_id = romo_id_fut.await.context("Failed to get livekit room")?; + workspace.update(cx, |workspace, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(room_id)); + workspace.show_toast( + workspace::Toast::new( + NotificationId::unique::(), + "Room ID copied to clipboard", + ) + .autohide(), + cx, + ); + }) + }) + .detach_and_notify_err(window, cx); + } else { + workspace.show_error(&"There’s no active call; join one first.", cx); + } + }); workspace.register_action(|workspace, _: &ShareProject, window, cx| { let project = workspace.project().clone(); println!("{project:?}"); diff --git a/crates/livekit_client/src/livekit_client.rs b/crates/livekit_client/src/livekit_client.rs index 30a13bd910d52d82a394804e25371f41685437bf..5d31f802c81678478fdb907c479e5cb63cba0487 100644 --- a/crates/livekit_client/src/livekit_client.rs +++ b/crates/livekit_client/src/livekit_client.rs @@ -98,6 +98,14 @@ impl Room { self.room.connection_state() } + pub fn name(&self) -> String { + self.room.name() + } + + pub async fn sid(&self) -> String { + self.room.sid().await.to_string() + } + pub async fn publish_local_microphone_track( &self, user_name: String, diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs index fd3163598203ac26443cae1b733372b6c3bdf1d1..a8222b9a18b719f59ccaebdff6e08b7ee4edef67 100644 --- a/crates/livekit_client/src/test.rs +++ b/crates/livekit_client/src/test.rs @@ -714,6 +714,14 @@ impl Room { self.0.lock().token.clone() } + pub fn name(&self) -> String { + "test_room".to_string() + } + + pub async fn sid(&self) -> String { + "RM_test_session".to_string() + } + pub fn play_remote_audio_track( &self, _track: &RemoteAudioTrack, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index aa100550749142b94df034195385c31dd54dbba3..d5a1c3a291c8e337695b30c1e6e1f3b3b76a3a62 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7236,7 +7236,9 @@ actions!( /// Shares the current project with collaborators. ShareProject, /// Shares your screen with collaborators. - ScreenShare + ScreenShare, + /// Copies the current room name and session id for debugging purposes. + CopyRoomId, ] ); actions!( From 7c95834b7bf194d0ee0851ee23d6257317020cb2 Mon Sep 17 00:00:00 2001 From: Novus Nota <68142933+novusnota@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:30:09 +0100 Subject: [PATCH 05/67] languages: Add injections to highlight script blocks of `actions/github-script` as JavaScript (#43771) Hello, this is my first time contributing here! The issue creation button noted that "feature request"-esque items should go to discussions, so with this PR, I'm closing that discussion and not an issue. I hope that's ok :) --- Closes https://github.com/zed-industries/zed/discussions/43769 Preview: image

Release Notes: - Added JavaScript highlighting via YAML `injections.scm` for script blocks of `actions/github-script` --- crates/languages/src/yaml/injections.scm | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/languages/src/yaml/injections.scm b/crates/languages/src/yaml/injections.scm index 9117c713b98fdd2896b13e4949a77c6489b9ee36..c9de25a18f8afb7d8e0c6874401798edede9bce1 100644 --- a/crates/languages/src/yaml/injections.scm +++ b/crates/languages/src/yaml/injections.scm @@ -1,3 +1,25 @@ ((comment) @injection.content (#set! injection.language "comment") ) + +; GitHub actions: JavaScript for workflow scripting (inline and block) +(block_mapping + (block_mapping_pair + key: (flow_node) @_uses (#eq? @_uses "uses") + value: (flow_node) @_actions_ghs (#match? @_actions_ghs "^actions/github-script")) + (block_mapping_pair + key: (flow_node) @_with (#eq? @_with "with") + value: (block_node + (block_mapping + (block_mapping_pair + key: (flow_node) @_run (#eq? @_run "script") + value: [ + (flow_node (plain_scalar (string_scalar) @injection.content)) + (block_node (block_scalar) @injection.content) + ] + (#set! injection.language "javascript") + ) + ) + ) + ) +) From 7bea1ba5556de14fd53d42ba5e0895c33185349d Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 2 Dec 2025 19:38:01 +0200 Subject: [PATCH 06/67] Run commands if completion items require so (#44008) Abide the LSP better and actually run commands if completion items request those: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#completionItem ``` /** * An optional command that is executed *after* inserting this completion. * *Note* that additional modifications to the current document should be * described with the additionalTextEdits-property. */ command?: [Command](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#command); ``` Release Notes: - N/A --- .../src/session/running/console.rs | 15 +- crates/editor/src/editor.rs | 63 ++++++- crates/editor/src/editor_tests.rs | 174 ++++++++++++++++++ 3 files changed, 235 insertions(+), 17 deletions(-) diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 2939079f256d4c2742e514f002a4c9fe5e58b49a..717169ff5ad1d0f479075ca996c550a774a4307a 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -18,14 +18,14 @@ use gpui::{ use language::{Anchor, Buffer, CharScopeContext, CodeLabel, TextBufferSnapshot, ToOffset}; use menu::{Confirm, SelectNext, SelectPrevious}; use project::{ - Completion, CompletionDisplayOptions, CompletionResponse, + CompletionDisplayOptions, CompletionResponse, debugger::session::{CompletionsQuery, OutputToken, Session}, lsp_store::CompletionDocumentation, search_history::{SearchHistory, SearchHistoryCursor}, }; use settings::Settings; use std::fmt::Write; -use std::{cell::RefCell, ops::Range, rc::Rc, usize}; +use std::{ops::Range, rc::Rc, usize}; use theme::{Theme, ThemeSettings}; use ui::{ContextMenu, Divider, PopoverMenu, SplitButton, Tooltip, prelude::*}; use util::ResultExt; @@ -553,17 +553,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { } } - fn apply_additional_edits_for_completion( - &self, - _buffer: Entity, - _completions: Rc>>, - _completion_index: usize, - _push_to_history: bool, - _cx: &mut Context, - ) -> gpui::Task>> { - Task::ready(Ok(None)) - } - fn is_completion_trigger( &self, buffer: &Entity, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3ceb9d8d699a5aa7f743e3c30042b47c486f17b4..114dbac23e80814c64471d7123ef73a29ccfc115 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -146,8 +146,8 @@ use persistence::DB; use project::{ BreakpointWithPosition, CodeAction, Completion, CompletionDisplayOptions, CompletionIntent, CompletionResponse, CompletionSource, DisableAiSettings, DocumentHighlight, InlayHint, InlayId, - InvalidationStrategy, Location, LocationLink, PrepareRenameResponse, Project, ProjectItem, - ProjectPath, ProjectTransaction, TaskSourceKind, + InvalidationStrategy, Location, LocationLink, LspAction, PrepareRenameResponse, Project, + ProjectItem, ProjectPath, ProjectTransaction, TaskSourceKind, debugger::{ breakpoint_store::{ Breakpoint, BreakpointEditAction, BreakpointSessionState, BreakpointState, @@ -6151,9 +6151,43 @@ impl Editor { } let provider = self.completion_provider.as_ref()?; + + let lsp_store = self.project().map(|project| project.read(cx).lsp_store()); + let command = lsp_store.as_ref().and_then(|lsp_store| { + let CompletionSource::Lsp { + lsp_completion, + server_id, + .. + } = &completion.source + else { + return None; + }; + let lsp_command = lsp_completion.command.as_ref()?; + let available_commands = lsp_store + .read(cx) + .lsp_server_capabilities + .get(server_id) + .and_then(|server_capabilities| { + server_capabilities + .execute_command_provider + .as_ref() + .map(|options| options.commands.as_slice()) + })?; + if available_commands.contains(&lsp_command.command) { + Some(CodeAction { + server_id: *server_id, + range: language::Anchor::MIN..language::Anchor::MIN, + lsp_action: LspAction::Command(lsp_command.clone()), + resolved: false, + }) + } else { + None + } + }); + drop(completion); let apply_edits = provider.apply_additional_edits_for_completion( - buffer_handle, + buffer_handle.clone(), completions_menu.completions.clone(), candidate_id, true, @@ -6167,8 +6201,29 @@ impl Editor { self.show_signature_help(&ShowSignatureHelp, window, cx); } - Some(cx.foreground_executor().spawn(async move { + Some(cx.spawn_in(window, async move |editor, cx| { apply_edits.await?; + + if let Some((lsp_store, command)) = lsp_store.zip(command) { + let title = command.lsp_action.title().to_owned(); + let project_transaction = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.apply_code_action(buffer_handle, command, false, cx) + })? + .await + .context("applying post-completion command")?; + if let Some(workspace) = editor.read_with(cx, |editor, _| editor.workspace())? { + Self::open_project_transaction( + &editor, + workspace.downgrade(), + project_transaction, + title, + cx, + ) + .await?; + } + } + Ok(()) })) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 0bcfad7b881f4d90a2ffe0aa5c1d330d89470e98..61d316e3915a740cb35b24a3afa445a34a608336 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -14755,6 +14755,180 @@ async fn test_completion(cx: &mut TestAppContext) { apply_additional_edits.await.unwrap(); } +#[gpui::test] +async fn test_completion_can_run_commands(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": "", + }), + ) + .await; + + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let command_calls = Arc::new(AtomicUsize::new(0)); + let registered_command = "_the/command"; + + let closure_command_calls = command_calls.clone(); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..lsp::CompletionOptions::default() + }), + execute_command_provider: Some(lsp::ExecuteCommandOptions { + commands: vec![registered_command.to_owned()], + ..lsp::ExecuteCommandOptions::default() + }), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(move |fake_server| { + fake_server.set_request_handler::( + move |params, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "registered_command".to_owned(), + text_edit: gen_text_edit(¶ms, ""), + command: Some(lsp::Command { + title: registered_command.to_owned(), + command: "_the/command".to_owned(), + arguments: Some(vec![serde_json::Value::Bool(true)]), + }), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "unregistered_command".to_owned(), + text_edit: gen_text_edit(¶ms, ""), + command: Some(lsp::Command { + title: "????????????".to_owned(), + command: "????????????".to_owned(), + arguments: Some(vec![serde_json::Value::Null]), + }), + ..lsp::CompletionItem::default() + }, + ]))) + }, + ); + fake_server.set_request_handler::({ + let command_calls = closure_command_calls.clone(); + move |params, _| { + assert_eq!(params.command, registered_command); + let command_calls = command_calls.clone(); + async move { + command_calls.fetch_add(1, atomic::Ordering::Release); + Ok(Some(json!(null))) + } + } + }); + })), + ..FakeLspAdapter::default() + }, + ); + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let editor = workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/a/main.rs")), + OpenOptions::default(), + window, + cx, + ) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + let _fake_server = fake_servers.next().await.unwrap(); + + editor.update_in(cx, |editor, window, cx| { + cx.focus_self(window); + editor.move_to_end(&MoveToEnd, window, cx); + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, _| { + assert!(editor.context_menu_visible()); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + let completion_labels = menu + .completions + .borrow() + .iter() + .map(|c| c.label.text.clone()) + .collect::>(); + assert_eq!( + completion_labels, + &["registered_command", "unregistered_command",], + ); + } else { + panic!("expected completion menu to be open"); + } + }); + + editor + .update_in(cx, |editor, window, cx| { + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + command_calls.load(atomic::Ordering::Acquire), + 1, + "For completion with a registered command, Zed should send a command execution request", + ); + + editor.update_in(cx, |editor, window, cx| { + cx.focus_self(window); + editor.handle_input(".", window, cx); + }); + cx.run_until_parked(); + editor.update(cx, |editor, _| { + assert!(editor.context_menu_visible()); + if let Some(CodeContextMenu::Completions(menu)) = editor.context_menu.borrow_mut().as_ref() + { + let completion_labels = menu + .completions + .borrow() + .iter() + .map(|c| c.label.text.clone()) + .collect::>(); + assert_eq!( + completion_labels, + &["registered_command", "unregistered_command",], + ); + } else { + panic!("expected completion menu to be open"); + } + }); + editor + .update_in(cx, |editor, window, cx| { + editor.context_menu_next(&Default::default(), window, cx); + editor + .confirm_completion(&ConfirmCompletion::default(), window, cx) + .unwrap() + }) + .await + .unwrap(); + cx.run_until_parked(); + assert_eq!( + command_calls.load(atomic::Ordering::Acquire), + 1, + "For completion with an unregistered command, Zed should not send a command execution request", + ); +} + #[gpui::test] async fn test_completion_reuse(cx: &mut TestAppContext) { init_test(cx, |_| {}); From efa98a12fd1d2289f216d3daaa81df219b3f06fb Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 2 Dec 2025 14:54:08 -0300 Subject: [PATCH 07/67] Pin cargo-about to 0.8.2 (#44012) cargo-about@0.8.3 doesn't seem to like our license identifiers. Pinning to 0.8.2 for now. Release Notes: - N/A --- script/generate-licenses | 8 ++++---- script/generate-licenses-csv | 8 ++++---- script/generate-licenses.ps1 | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/script/generate-licenses b/script/generate-licenses index 5deed400e4e173b4ce4bb88e4b36469cd176a9ad..6a833acd2095868b82c317e65ee37a516e41596a 100755 --- a/script/generate-licenses +++ b/script/generate-licenses @@ -2,7 +2,7 @@ set -euo pipefail -CARGO_ABOUT_VERSION="0.8" +CARGO_ABOUT_VERSION="0.8.2" OUTPUT_FILE="${1:-$(pwd)/assets/licenses.md}" TEMPLATE_FILE="script/licenses/template.md.hbs" @@ -28,10 +28,10 @@ echo -n "" >"$OUTPUT_FILE" } >>"$OUTPUT_FILE" if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" &>/dev/null; then - echo "Installing cargo-about@^$CARGO_ABOUT_VERSION..." - cargo install "cargo-about@^$CARGO_ABOUT_VERSION" + echo "Installing cargo-about@$CARGO_ABOUT_VERSION..." + cargo install "cargo-about@$CARGO_ABOUT_VERSION" else - echo "cargo-about@^$CARGO_ABOUT_VERSION is already installed." + echo "cargo-about@$CARGO_ABOUT_VERSION is already installed." fi echo "Generating cargo licenses" diff --git a/script/generate-licenses-csv b/script/generate-licenses-csv index 0e40c69d47a0d769e25fe1d45df18bdc439720d8..dd86f872d0f42d54d7387d9a43eb660849da0f14 100755 --- a/script/generate-licenses-csv +++ b/script/generate-licenses-csv @@ -2,15 +2,15 @@ set -euo pipefail -CARGO_ABOUT_VERSION="0.8" +CARGO_ABOUT_VERSION="0.8.2" OUTPUT_FILE="${1:-$(pwd)/assets/licenses.csv}" TEMPLATE_FILE="script/licenses/template.csv.hbs" if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" 2>&1 > /dev/null; then - echo "Installing cargo-about@^$CARGO_ABOUT_VERSION..." - cargo install "cargo-about@^$CARGO_ABOUT_VERSION" + echo "Installing cargo-about@$CARGO_ABOUT_VERSION..." + cargo install "cargo-about@$CARGO_ABOUT_VERSION" else - echo "cargo-about@^$CARGO_ABOUT_VERSION is already installed." + echo "cargo-about@$CARGO_ABOUT_VERSION is already installed." fi echo "Generating cargo licenses" diff --git a/script/generate-licenses.ps1 b/script/generate-licenses.ps1 index ab7df73e5696e7fbbceb236766ae35c3360a3afe..80cd249a46582e51e86870d347f922854a76a069 100644 --- a/script/generate-licenses.ps1 +++ b/script/generate-licenses.ps1 @@ -1,4 +1,4 @@ -$CARGO_ABOUT_VERSION="0.8" +$CARGO_ABOUT_VERSION="0.8.2" $outputFile=$args[0] ? $args[0] : "$(Get-Location)/assets/licenses.md" $templateFile="script/licenses/template.md.hbs" @@ -14,10 +14,10 @@ New-Item -Path "$outputFile" -ItemType File -Value "" -Force $versionOutput = cargo about --version if (-not ($versionOutput -match "cargo-about $CARGO_ABOUT_VERSION")) { - Write-Host "Installing cargo-about@^$CARGO_ABOUT_VERSION..." - cargo install "cargo-about@^$CARGO_ABOUT_VERSION" + Write-Host "Installing cargo-about@$CARGO_ABOUT_VERSION..." + cargo install "cargo-about@$CARGO_ABOUT_VERSION" } else { - Write-Host "cargo-about@^$CARGO_ABOUT_VERSION" is already installed + Write-Host "cargo-about@$CARGO_ABOUT_VERSION" is already installed } Write-Host "Generating cargo licenses" From 59b5de55328b9c9ceabdf041df1621308b00bf1b Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 2 Dec 2025 15:47:25 -0300 Subject: [PATCH 08/67] Update typos to 1.40.0 (#43739) Noticed we had a few typos that weren't caught by the version we were using Release Notes: - N/A --- .github/workflows/run_tests.yml | 2 +- crates/gpui/src/window.rs | 4 ++-- crates/project/src/project_tests.rs | 2 +- crates/settings/src/merge_from.rs | 2 +- tooling/xtask/src/tasks/workflows/run_tests.rs | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index bd92a17f402b4fc15b488ec6abbec2ba9ca66ac9..ad228103e33bd17dbe180d1c267c5141f5433080 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -84,7 +84,7 @@ jobs: run: ./script/check-keymaps shell: bash -euxo pipefail {0} - name: run_tests::check_style::check_for_typos - uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 + uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 with: config: ./typos.toml - name: steps::cargo_fmt diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 1b317e1ee30daa8e9dfa71a0efe0eba526f9cbbf..dabf7cf2b42cf57becb996e1f9360aaba0b6eead 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -596,7 +596,7 @@ pub enum HitboxBehavior { /// ``` /// /// This has effects beyond event handling - any use of hitbox checking, such as hover - /// styles and tooltops. These other behaviors are the main point of this mechanism. An + /// styles and tooltips. These other behaviors are the main point of this mechanism. An /// alternative might be to not affect mouse event handling - but this would allow /// inconsistent UI where clicks and moves interact with elements that are not considered to /// be hovered. @@ -624,7 +624,7 @@ pub enum HitboxBehavior { /// desired, then a `cx.stop_propagation()` handler like the one above can be used. /// /// This has effects beyond event handling - this affects any use of `is_hovered`, such as - /// hover styles and tooltops. These other behaviors are the main point of this mechanism. + /// hover styles and tooltips. These other behaviors are the main point of this mechanism. /// An alternative might be to not affect mouse event handling - but this would allow /// inconsistent UI where clicks and moves interact with elements that are not considered to /// be hovered. diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 209c08bbb9350cdfa0b8f22c47ab475fb60a82b8..3117c0f5944d05a08524608a82587226a735550e 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -9733,7 +9733,7 @@ async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) { ("project/target/debug/deps".to_string(), PathChange::Added), ("project/target/debug/deps".to_string(), PathChange::Removed), ], - "Due to `debug` directory being tracket, it should get updates for entries inside it. + "Due to `debug` directory being tracked, it should get updates for entries inside it. No updates for more nested directories should happen as those are ignored", ); } diff --git a/crates/settings/src/merge_from.rs b/crates/settings/src/merge_from.rs index 6347f231f8d00f0dbe10b98d1172c4b14b105e98..30ad0d3671a76cc614773030a00efda231657b4e 100644 --- a/crates/settings/src/merge_from.rs +++ b/crates/settings/src/merge_from.rs @@ -1,6 +1,6 @@ /// Trait for recursively merging settings structures. /// -/// When Zed starts it loads settinsg from `default.json` to initialize +/// When Zed starts it loads settings from `default.json` to initialize /// everything. These may be further refined by loading the user's settings, /// and any settings profiles; and then further refined by loading any /// local project settings. diff --git a/tooling/xtask/src/tasks/workflows/run_tests.rs b/tooling/xtask/src/tasks/workflows/run_tests.rs index 06a2ba734a381f4d077a522bb76713b26b47e72e..e4443ad91313fd4511765fb7be6a8bb092757e9d 100644 --- a/tooling/xtask/src/tasks/workflows/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/run_tests.rs @@ -226,8 +226,8 @@ fn check_style() -> NamedJob { named::uses( "crate-ci", "typos", - "80c8a4945eec0f6d464eaf9e65ed98ef085283d1", - ) // v1.38.1 + "2d0ce569feab1f8752f1dde43cc2f2aa53236e06", + ) // v1.40.0 .with(("config", "./typos.toml")) } named::job( From b1af02ca710094452521965d02b1eb051c1911b9 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:38:18 -0300 Subject: [PATCH 09/67] debugger: Improve step icons (#44017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/38475 Screenshot 2025-12-02 at 5  24@2x Release Notes: - N/A --- assets/icons/debug_step_back.svg | 1 - assets/icons/debug_step_into.svg | 6 ++- assets/icons/debug_step_out.svg | 6 ++- assets/icons/debug_step_over.svg | 6 ++- crates/debugger_ui/src/debugger_panel.rs | 47 +++++++++++------------- crates/icons/src/icons.rs | 1 - 6 files changed, 37 insertions(+), 30 deletions(-) delete mode 100644 assets/icons/debug_step_back.svg diff --git a/assets/icons/debug_step_back.svg b/assets/icons/debug_step_back.svg deleted file mode 100644 index 61d45866f61cbabbd9a7ae9975809d342cb76ed5..0000000000000000000000000000000000000000 --- a/assets/icons/debug_step_back.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/assets/icons/debug_step_into.svg b/assets/icons/debug_step_into.svg index 9a517fc7ca0762b17446a75cd90f39a91e1b51cf..0a5882354380b659425fecca2b4c6000516e422f 100644 --- a/assets/icons/debug_step_into.svg +++ b/assets/icons/debug_step_into.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_out.svg b/assets/icons/debug_step_out.svg index 147a44f930f34f6c3ddce94693a178a932129cb5..c128f56111f2b68d7229f9d2f61b6b2496f99bba 100644 --- a/assets/icons/debug_step_out.svg +++ b/assets/icons/debug_step_out.svg @@ -1 +1,5 @@ - + + + + + diff --git a/assets/icons/debug_step_over.svg b/assets/icons/debug_step_over.svg index 336abc11deb866a128e8418dab47af01b6e4d3f6..5d8ccd5b7a20b2f8a108ab4c2e03694db4f6f8a8 100644 --- a/assets/icons/debug_step_over.svg +++ b/assets/icons/debug_step_over.svg @@ -1 +1,5 @@ - + + + + + diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index 3890fa6326329d0d72aa6f81c6b94e7c2f364d34..ffdd4a22e3d092eb5d3d6626dcfe8b167ae03936 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -740,7 +740,7 @@ impl DebugPanel { } }) .child( - IconButton::new("debug-step-over", IconName::ArrowRight) + IconButton::new("step-over", IconName::DebugStepOver) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, @@ -762,32 +762,29 @@ impl DebugPanel { }), ) .child( - IconButton::new( - "debug-step-into", - IconName::ArrowDownRight, - ) - .icon_size(IconSize::Small) - .on_click(window.listener_for( - running_state, - |this, _, _window, cx| { - this.step_in(cx); - }, - )) - .disabled(thread_status != ThreadStatus::Stopped) - .tooltip({ - let focus_handle = focus_handle.clone(); - move |_window, cx| { - Tooltip::for_action_in( - "Step In", - &StepInto, - &focus_handle, - cx, - ) - } - }), + IconButton::new("step-into", IconName::DebugStepInto) + .icon_size(IconSize::Small) + .on_click(window.listener_for( + running_state, + |this, _, _window, cx| { + this.step_in(cx); + }, + )) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Step In", + &StepInto, + &focus_handle, + cx, + ) + } + }), ) .child( - IconButton::new("debug-step-out", IconName::ArrowUpRight) + IconButton::new("step-out", IconName::DebugStepOut) .icon_size(IconSize::Small) .on_click(window.listener_for( running_state, diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index da3b298751d9c1921d14722490e3cbc680292099..d28e2c1030c3c2378aa7997f4799c503cee97105 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -86,7 +86,6 @@ pub enum IconName { DebugIgnoreBreakpoints, DebugLogBreakpoint, DebugPause, - DebugStepBack, DebugStepInto, DebugStepOut, DebugStepOver, From d283338885d232c281907809c4b255eaad90a3af Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 2 Dec 2025 15:39:29 -0500 Subject: [PATCH 10/67] Add "File History" option to Git Panel entry context menu (#44016) Sublime Merge: image Fork: image Zed: image Release Notes: - Added a "File History" option to Git Panel entry context menu --- crates/git_ui/src/git_panel.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 8579cafa58a22ea6d182f23b753d3b1a365f37fa..092768c2cd97fa82079979301704ee66c969196e 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -6,7 +6,8 @@ use crate::project_diff::{self, Diff, ProjectDiff}; use crate::remote_output::{self, RemoteAction, SuccessMessage}; use crate::{branch_picker, picker_prompt, render_remote_button}; use crate::{ - git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector, + file_history_view::FileHistoryView, git_panel_settings::GitPanelSettings, git_status_icon, + repository_selector::RepositorySelector, }; use agent_settings::AgentSettings; use anyhow::Context as _; @@ -842,6 +843,26 @@ impl GitPanel { }); } + fn file_history(&mut self, _: &git::FileHistory, window: &mut Window, cx: &mut Context) { + maybe!({ + let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + let active_repo = self.active_repository.as_ref()?; + let repo_path = entry.repo_path.clone(); + let git_store = self.project.read(cx).git_store(); + + FileHistoryView::open( + repo_path, + git_store.downgrade(), + active_repo.downgrade(), + self.workspace.clone(), + window, + cx, + ); + + Some(()) + }); + } + fn open_file( &mut self, _: &menu::SecondaryConfirm, @@ -3997,6 +4018,8 @@ impl GitPanel { .separator() .action("Open Diff", Confirm.boxed_clone()) .action("Open File", SecondaryConfirm.boxed_clone()) + .separator() + .action("File History", Box::new(git::FileHistory)) }); self.selected_entry = Some(ix); self.set_context_menu(context_menu, position, window, cx); @@ -4499,6 +4522,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::close_panel)) .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_file)) + .on_action(cx.listener(Self::file_history)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) From 4e043cd56b8ee15c5d40ad4a94a28ca7ee449f68 Mon Sep 17 00:00:00 2001 From: David Kleingeld Date: Tue, 2 Dec 2025 21:40:26 +0100 Subject: [PATCH 11/67] Add git team to git in REVIEWERS.conl (#41841) Release Notes: - N/A --- REVIEWERS.conl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/REVIEWERS.conl b/REVIEWERS.conl index 86658189d0ebc3861648c1dd410a3b0b8c199706..45155ba3468f29062b58aa9094defc7f86110885 100644 --- a/REVIEWERS.conl +++ b/REVIEWERS.conl @@ -53,6 +53,10 @@ extension git = @cole-miller = @danilo-leal + = @dvdsk + = @kubkon + = @Anthony-Eid + = @cameron1024 gpui = @Anthony-Eid From 23e5477a4cc5921d1a6b35ef3ebbd6bf413b35aa Mon Sep 17 00:00:00 2001 From: Pranav Joglekar Date: Wed, 3 Dec 2025 02:28:20 +0530 Subject: [PATCH 12/67] vim: Move to opening html tag from / in closing tag (#42513) Closes #41582 Release Notes: - Improves the '%' vim motion for html by moving the cursor to the opening tag when its positioned on the `/` ( slash ) of the closing tag --- crates/vim/src/motion.rs | 31 +++++++++++++++++--- crates/vim/test_data/test_matching_tags.json | 5 ++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index dc108b0957d993b2229e8c04fed5923e9de250d4..6ba28a1c236ada7c08eeabac9d9189991434a807 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -2388,10 +2388,16 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint .or_else(|| snapshot.innermost_enclosing_bracket_ranges(offset..offset, None)); if let Some((opening_range, closing_range)) = bracket_ranges { - if opening_range.contains(&offset) { - return closing_range.start.to_display_point(map); - } else if closing_range.contains(&offset) { - return opening_range.start.to_display_point(map); + let mut chars = map.buffer_snapshot().chars_at(offset); + match chars.next() { + Some('/') => {} + _ => { + if opening_range.contains(&offset) { + return closing_range.start.to_display_point(map); + } else if closing_range.contains(&offset) { + return opening_range.start.to_display_point(map); + } + } } } @@ -3443,6 +3449,23 @@ mod test { test = "test" /> "#}); + + // test nested closing tag + cx.set_shared_state(indoc! {r#" + + + "#}) + .await; + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r#" + + <ˇ/body> + "#}); + cx.simulate_shared_keystrokes("%").await; + cx.shared_state().await.assert_eq(indoc! {r#" + <ˇbody> + + "#}); } #[gpui::test] diff --git a/crates/vim/test_data/test_matching_tags.json b/crates/vim/test_data/test_matching_tags.json index bb4f5fd450dee78319a23e8026b2cb1c4d224b19..b401033a941f201ddcf9c3a4128659ae27d787b4 100644 --- a/crates/vim/test_data/test_matching_tags.json +++ b/crates/vim/test_data/test_matching_tags.json @@ -13,3 +13,8 @@ {"Put":{"state":"\n \n"}} {"Key":"%"} {"Get":{"state":"\n ˇ\n","mode":"Normal"}} +{"Put":{"state":"\n \n \n"}} +{"Key":"%"} +{"Get":{"state":"\n \n <ˇ/body>\n","mode":"Normal"}} +{"Key":"%"} +{"Get":{"state":"\n <ˇbody>\n \n","mode":"Normal"}} From a2ddb0f1cb96f3711da4cf85d51282103820831c Mon Sep 17 00:00:00 2001 From: John Tur Date: Tue, 2 Dec 2025 16:15:18 -0500 Subject: [PATCH 13/67] Fix "busy" cursor appearing on startup (#44019) Closes https://github.com/zed-industries/zed/issues/43910 Release Notes: - N/A --- Cargo.lock | 1 + crates/crashes/Cargo.toml | 3 ++ crates/crashes/src/crashes.rs | 58 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6e558cbf395866ce6b75ff5764ba98a5ec81607a..6f584fbc7fba2182b95343e24704662c53221b12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4184,6 +4184,7 @@ dependencies = [ "serde_json", "smol", "system_specs", + "windows 0.61.3", "zstd 0.11.2+zstd.1.5.2", ] diff --git a/crates/crashes/Cargo.toml b/crates/crashes/Cargo.toml index 3f85039e9ea3bce8e702991461adec4a931d3e4a..bd1c1121848e34349b5cd58c0fa033d380fa791b 100644 --- a/crates/crashes/Cargo.toml +++ b/crates/crashes/Cargo.toml @@ -23,6 +23,9 @@ zstd.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mach2.workspace = true +[target.'cfg(target_os = "windows")'.dependencies] +windows.workspace = true + [lints] workspace = true diff --git a/crates/crashes/src/crashes.rs b/crates/crashes/src/crashes.rs index baf0bcde3b0769c4fc6cf958c86e181cda615683..4c601c393004beca1d5e550e1eeae7f126751448 100644 --- a/crates/crashes/src/crashes.rs +++ b/crates/crashes/src/crashes.rs @@ -3,6 +3,8 @@ use log::info; use minidumper::{Client, LoopAction, MinidumpBinary}; use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; use serde::{Deserialize, Serialize}; + +#[cfg(not(target_os = "windows"))] use smol::process::Command; #[cfg(target_os = "macos")] @@ -70,11 +72,16 @@ pub async fn init(crash_init: InitCrashHandler) { // used by the crash handler isn't destroyed correctly which causes it to stay on the file // system and block further attempts to initialize crash handlers with that socket path. let socket_name = paths::temp_dir().join(format!("zed-crash-handler-{zed_pid}")); + #[cfg(not(target_os = "windows"))] let _crash_handler = Command::new(exe) .arg("--crash-handler") .arg(&socket_name) .spawn() .expect("unable to spawn server process"); + + #[cfg(target_os = "windows")] + spawn_crash_handler_windows(&exe, &socket_name); + #[cfg(target_os = "linux")] let server_pid = _crash_handler.id(); info!("spawning crash handler process"); @@ -342,6 +349,57 @@ pub fn panic_hook(info: &PanicHookInfo) { } } +#[cfg(target_os = "windows")] +fn spawn_crash_handler_windows(exe: &Path, socket_name: &Path) { + use std::ffi::OsStr; + use std::iter::once; + use std::os::windows::ffi::OsStrExt; + use windows::Win32::System::Threading::{ + CreateProcessW, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_FORCEOFFFEEDBACK, + STARTUPINFOW, + }; + use windows::core::PWSTR; + + let mut command_line: Vec = OsStr::new(&format!( + "\"{}\" --crash-handler \"{}\"", + exe.display(), + socket_name.display() + )) + .encode_wide() + .chain(once(0)) + .collect(); + + let mut startup_info = STARTUPINFOW::default(); + startup_info.cb = std::mem::size_of::() as u32; + + // By default, Windows enables a "busy" cursor when a GUI application is launched. + // This cursor is disabled once the application starts processing window messages. + // Since the crash handler process doesn't process messages, this "busy" cursor stays enabled for a long time. + // Disable the cursor feedback to prevent this from happening. + startup_info.dwFlags = STARTF_FORCEOFFFEEDBACK; + + let mut process_info = PROCESS_INFORMATION::default(); + + unsafe { + CreateProcessW( + None, + Some(PWSTR(command_line.as_mut_ptr())), + None, + None, + false, + PROCESS_CREATION_FLAGS(0), + None, + None, + &startup_info, + &mut process_info, + ) + .expect("unable to spawn server process"); + + windows::Win32::Foundation::CloseHandle(process_info.hProcess).ok(); + windows::Win32::Foundation::CloseHandle(process_info.hThread).ok(); + } +} + pub fn crash_server(socket: &Path) { let Ok(mut server) = minidumper::Server::with_name(socket) else { log::info!("Couldn't create socket, there may already be a running crash server"); From 96a917091a80c0430b6b7d816d747e3bbf14f533 Mon Sep 17 00:00:00 2001 From: Andrew Farkas <6060305+HactarCE@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:33:41 -0500 Subject: [PATCH 14/67] Apply `show_completions_on_input: false` to word & snippet completions (#44021) Closes #43408 Previously, we checked the setting inside `is_completion_trigger()`, which only affects LSP completions. This was ok because user-defined snippets were tacked onto LSP completions. Then #42122 and #42398 made snippet completions their own thing, similar to word completions, surfacing #43408. This PR moves the settings check into `open_or_update_completions_menu()` so it applies to all completions. Release Notes: - Fixed setting `show_completions_on_input: false` so that it affects word and user-defined snippet completions as well as LSP completions --- crates/agent_ui/src/completion_provider.rs | 1 - crates/agent_ui/src/slash_command.rs | 1 - .../src/session/running/console.rs | 4 -- crates/editor/src/editor.rs | 40 +++++++++---------- crates/inspector_ui/src/div_inspector.rs | 1 - crates/keymap_editor/src/keymap_editor.rs | 1 - 6 files changed, 18 insertions(+), 30 deletions(-) diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 2e3cf0d551fc649e61ae26e47fa53301def2aacc..a2b6e0510e25c12cfbfb98d3e72cb0d2c830887a 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -1114,7 +1114,6 @@ impl CompletionProvider for PromptCompletio position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); diff --git a/crates/agent_ui/src/slash_command.rs b/crates/agent_ui/src/slash_command.rs index 7d3ea0105a0aafb4cfccf4076cb95e28c99dec28..e328ef6725e5e789bd402667da91417ad69a372d 100644 --- a/crates/agent_ui/src/slash_command.rs +++ b/crates/agent_ui/src/slash_command.rs @@ -341,7 +341,6 @@ impl CompletionProvider for SlashCommandCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { let buffer = buffer.read(cx); diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index 717169ff5ad1d0f479075ca996c550a774a4307a..d20108b61205bacd3ea09af0ea34fabbec621c20 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -559,7 +559,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool { let mut chars = text.chars(); @@ -570,9 +569,6 @@ impl CompletionProvider for ConsoleQueryBarCompletionProvider { }; let snapshot = buffer.read(cx).snapshot(); - if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { - return false; - } let classifier = snapshot .char_classifier_at(position) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 114dbac23e80814c64471d7123ef73a29ccfc115..babedf1e0829bb1105b2c9c3787d98aa662eedde 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5509,6 +5509,22 @@ impl Editor { }; let buffer_snapshot = buffer.read(cx).snapshot(); + let menu_is_open = matches!( + self.context_menu.borrow().as_ref(), + Some(CodeContextMenu::Completions(_)) + ); + + let language = buffer_snapshot + .language_at(buffer_position.text_anchor) + .map(|language| language.name()); + + let language_settings = language_settings(language.clone(), buffer_snapshot.file(), cx); + let completion_settings = language_settings.completions.clone(); + + if !menu_is_open && trigger.is_some() && !language_settings.show_completions_on_input { + return; + } + let query: Option> = Self::completion_query(&multibuffer_snapshot, buffer_position) .map(|query| query.into()); @@ -5517,14 +5533,8 @@ impl Editor { // Hide the current completions menu when query is empty. Without this, cached // completions from before the trigger char may be reused (#32774). - if query.is_none() { - let menu_is_open = matches!( - self.context_menu.borrow().as_ref(), - Some(CodeContextMenu::Completions(_)) - ); - if menu_is_open { - self.hide_context_menu(window, cx); - } + if query.is_none() && menu_is_open { + self.hide_context_menu(window, cx); } let mut ignore_word_threshold = false; @@ -5613,14 +5623,6 @@ impl Editor { (buffer_position..buffer_position, None) }; - let language = buffer_snapshot - .language_at(buffer_position) - .map(|language| language.name()); - - let completion_settings = language_settings(language.clone(), buffer_snapshot.file(), cx) - .completions - .clone(); - let show_completion_documentation = buffer_snapshot .settings_at(buffer_position, cx) .show_completion_documentation; @@ -5651,7 +5653,6 @@ impl Editor { position.text_anchor, trigger, trigger_in_words, - completions_source.is_some(), cx, ) }) @@ -23486,7 +23487,6 @@ pub trait CompletionProvider { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool; @@ -23865,7 +23865,6 @@ impl CompletionProvider for Entity { position: language::Anchor, text: &str, trigger_in_words: bool, - menu_is_open: bool, cx: &mut Context, ) -> bool { let mut chars = text.chars(); @@ -23880,9 +23879,6 @@ impl CompletionProvider for Entity { let buffer = buffer.read(cx); let snapshot = buffer.snapshot(); - if !menu_is_open && !snapshot.settings_at(position, cx).show_completions_on_input { - return false; - } let classifier = snapshot .char_classifier_at(position) .scope_context(Some(CharScopeContext::Completion)); diff --git a/crates/inspector_ui/src/div_inspector.rs b/crates/inspector_ui/src/div_inspector.rs index 35a5d2786f8d044dd9c41a3a4605538ea8f37c37..9b145e920e48605f19f566ca14a7caf63aff8f0a 100644 --- a/crates/inspector_ui/src/div_inspector.rs +++ b/crates/inspector_ui/src/div_inspector.rs @@ -686,7 +686,6 @@ impl CompletionProvider for RustStyleCompletionProvider { position: language::Anchor, _text: &str, _trigger_in_words: bool, - _menu_is_open: bool, cx: &mut Context, ) -> bool { completion_replace_range(&buffer.read(cx).snapshot(), &position).is_some() diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index ce78a1d60ac610bbf10383377fef667c0a4eaa36..113d5026eb89587714172ff4c76698bcadb5fd6a 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -3001,7 +3001,6 @@ impl CompletionProvider for KeyContextCompletionProvider { _position: language::Anchor, text: &str, _trigger_in_words: bool, - _menu_is_open: bool, _cx: &mut Context, ) -> bool { text.chars() From a2d57fc7b6933ecceeb1393f905457b1d33d2e35 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 2 Dec 2025 17:26:40 -0500 Subject: [PATCH 15/67] zed_extension_api: Fork new version of extension API (#44025) This PR forks a new version of the `zed_extension_api` in preparation for new changes. We're jumping from v0.6.0 to v0.8.0 for the WIT because we released v0.7.0 of the `zed_extension_api` without any WIT changes (it probably should have been v0.6.1, instead). Release Notes: - N/A --- Cargo.lock | 10 +- crates/extension_api/Cargo.toml | 5 +- crates/extension_api/src/extension_api.rs | 2 +- .../extension_api/wit/since_v0.8.0/common.wit | 12 + .../wit/since_v0.8.0/context-server.wit | 11 + crates/extension_api/wit/since_v0.8.0/dap.wit | 123 ++ .../wit/since_v0.8.0/extension.wit | 167 +++ .../extension_api/wit/since_v0.8.0/github.wit | 35 + .../wit/since_v0.8.0/http-client.wit | 67 + crates/extension_api/wit/since_v0.8.0/lsp.wit | 90 ++ .../extension_api/wit/since_v0.8.0/nodejs.wit | 13 + .../wit/since_v0.8.0/platform.wit | 24 + .../wit/since_v0.8.0/process.wit | 29 + .../wit/since_v0.8.0/settings.rs | 40 + .../wit/since_v0.8.0/slash-command.wit | 41 + crates/extension_host/src/wasm_host/wit.rs | 112 +- .../src/wasm_host/wit/since_v0_6_0.rs | 1013 +-------------- .../src/wasm_host/wit/since_v0_8_0.rs | 1109 +++++++++++++++++ 18 files changed, 1941 insertions(+), 962 deletions(-) create mode 100644 crates/extension_api/wit/since_v0.8.0/common.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/context-server.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/dap.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/extension.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/github.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/http-client.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/lsp.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/nodejs.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/platform.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/process.wit create mode 100644 crates/extension_api/wit/since_v0.8.0/settings.rs create mode 100644 crates/extension_api/wit/since_v0.8.0/slash-command.wit create mode 100644 crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs diff --git a/Cargo.lock b/Cargo.lock index 6f584fbc7fba2182b95343e24704662c53221b12..7f4813a3ee430462221732b3f6145170cada155b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21496,6 +21496,8 @@ dependencies = [ [[package]] name = "zed_extension_api" version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" dependencies = [ "serde", "serde_json", @@ -21504,9 +21506,7 @@ dependencies = [ [[package]] name = "zed_extension_api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" +version = "0.8.0" dependencies = [ "serde", "serde_json", @@ -21524,7 +21524,7 @@ dependencies = [ name = "zed_html" version = "0.2.3" dependencies = [ - "zed_extension_api 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", + "zed_extension_api 0.7.0", ] [[package]] @@ -21538,7 +21538,7 @@ dependencies = [ name = "zed_test_extension" version = "0.1.0" dependencies = [ - "zed_extension_api 0.7.0", + "zed_extension_api 0.8.0", ] [[package]] diff --git a/crates/extension_api/Cargo.toml b/crates/extension_api/Cargo.toml index 318a0024bf4d9bae76af888b6668d7c21f37f804..829455e62912883bea85f429a1a8917e6360d0fb 100644 --- a/crates/extension_api/Cargo.toml +++ b/crates/extension_api/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "zed_extension_api" -version = "0.7.0" +version = "0.8.0" description = "APIs for creating Zed extensions in Rust" repository = "https://github.com/zed-industries/zed" documentation = "https://docs.rs/zed_extension_api" keywords = ["zed", "extension"] edition.workspace = true -publish = true +# Change back to `true` when we're ready to publish v0.8.0. +publish = false license = "Apache-2.0" [lints] diff --git a/crates/extension_api/src/extension_api.rs b/crates/extension_api/src/extension_api.rs index 723e5442098f1a66b78b86fa7ed980a18944778b..9418623224289f795fed061acbfc6035a4cc5cdf 100644 --- a/crates/extension_api/src/extension_api.rs +++ b/crates/extension_api/src/extension_api.rs @@ -334,7 +334,7 @@ mod wit { wit_bindgen::generate!({ skip: ["init-extension"], - path: "./wit/since_v0.6.0", + path: "./wit/since_v0.8.0", }); } diff --git a/crates/extension_api/wit/since_v0.8.0/common.wit b/crates/extension_api/wit/since_v0.8.0/common.wit new file mode 100644 index 0000000000000000000000000000000000000000..139e7ba0ca4d1cc5ac78ccd23673ca749d6e46b2 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/common.wit @@ -0,0 +1,12 @@ +interface common { + /// A (half-open) range (`[start, end)`). + record range { + /// The start of the range (inclusive). + start: u32, + /// The end of the range (exclusive). + end: u32, + } + + /// A list of environment variables. + type env-vars = list>; +} diff --git a/crates/extension_api/wit/since_v0.8.0/context-server.wit b/crates/extension_api/wit/since_v0.8.0/context-server.wit new file mode 100644 index 0000000000000000000000000000000000000000..7234e0e6d0f6d444e92a056a92f6c90c7dc053b4 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/context-server.wit @@ -0,0 +1,11 @@ +interface context-server { + /// Configuration for context server setup and installation. + record context-server-configuration { + /// Installation instructions in Markdown format. + installation-instructions: string, + /// JSON schema for settings validation. + settings-schema: string, + /// Default settings template. + default-settings: string, + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/dap.wit b/crates/extension_api/wit/since_v0.8.0/dap.wit new file mode 100644 index 0000000000000000000000000000000000000000..693befe02f9c313455facd4839572528c3408fd1 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/dap.wit @@ -0,0 +1,123 @@ +interface dap { + use common.{env-vars}; + + /// Resolves a specified TcpArgumentsTemplate into TcpArguments + resolve-tcp-template: func(template: tcp-arguments-template) -> result; + + record launch-request { + program: string, + cwd: option, + args: list, + envs: env-vars, + } + + record attach-request { + process-id: option, + } + + variant debug-request { + launch(launch-request), + attach(attach-request) + } + + record tcp-arguments { + port: u16, + host: u32, + timeout: option, + } + + record tcp-arguments-template { + port: option, + host: option, + timeout: option, + } + + /// Debug Config is the "highest-level" configuration for a debug session. + /// It comes from a new process modal UI; thus, it is essentially debug-adapter-agnostic. + /// It is expected of the extension to translate this generic configuration into something that can be debugged by the adapter (debug scenario). + record debug-config { + /// Name of the debug task + label: string, + /// The debug adapter to use + adapter: string, + request: debug-request, + stop-on-entry: option, + } + + record task-template { + /// Human readable name of the task to display in the UI. + label: string, + /// Executable command to spawn. + command: string, + args: list, + env: env-vars, + cwd: option, + } + + /// A task template with substituted task variables. + type resolved-task = task-template; + + /// A task template for building a debug target. + type build-task-template = task-template; + + variant build-task-definition { + by-name(string), + template(build-task-definition-template-payload ) + } + record build-task-definition-template-payload { + locator-name: option, + template: build-task-template + } + + /// Debug Scenario is the user-facing configuration type (used in debug.json). It is still concerned with what to debug and not necessarily how to do it (except for any + /// debug-adapter-specific configuration options). + record debug-scenario { + /// Unsubstituted label for the task.DebugAdapterBinary + label: string, + /// Name of the Debug Adapter this configuration is intended for. + adapter: string, + /// An optional build step to be ran prior to starting a debug session. Build steps are used by Zed's locators to locate the executable to debug. + build: option, + /// JSON-encoded configuration for a given debug adapter. + config: string, + /// TCP connection parameters (if they were specified by user) + tcp-connection: option, + } + + enum start-debugging-request-arguments-request { + launch, + attach, + } + + record debug-task-definition { + /// Unsubstituted label for the task.DebugAdapterBinary + label: string, + /// Name of the Debug Adapter this configuration is intended for. + adapter: string, + /// JSON-encoded configuration for a given debug adapter. + config: string, + /// TCP connection parameters (if they were specified by user) + tcp-connection: option, + } + + record start-debugging-request-arguments { + /// JSON-encoded configuration for a given debug adapter. It is specific to each debug adapter. + /// `configuration` will have it's Zed variable references substituted prior to being passed to the debug adapter. + configuration: string, + request: start-debugging-request-arguments-request, + } + + /// The lowest-level representation of a debug session, which specifies: + /// - How to start a debug adapter process + /// - How to start a debug session with it (using DAP protocol) + /// for a given debug scenario. + record debug-adapter-binary { + command: option, + arguments: list, + envs: env-vars, + cwd: option, + /// Zed will use TCP transport if `connection` is specified. + connection: option, + request-args: start-debugging-request-arguments + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/extension.wit b/crates/extension_api/wit/since_v0.8.0/extension.wit new file mode 100644 index 0000000000000000000000000000000000000000..8195162b89a420d322970bf894bd9ec824119087 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/extension.wit @@ -0,0 +1,167 @@ +package zed:extension; + +world extension { + import context-server; + import dap; + import github; + import http-client; + import platform; + import process; + import nodejs; + + use common.{env-vars, range}; + use context-server.{context-server-configuration}; + use dap.{attach-request, build-task-template, debug-config, debug-adapter-binary, debug-task-definition, debug-request, debug-scenario, launch-request, resolved-task, start-debugging-request-arguments-request}; + use lsp.{completion, symbol}; + use process.{command}; + use slash-command.{slash-command, slash-command-argument-completion, slash-command-output}; + + /// Initializes the extension. + export init-extension: func(); + + /// The type of a downloaded file. + enum downloaded-file-type { + /// A gzipped file (`.gz`). + gzip, + /// A gzipped tar archive (`.tar.gz`). + gzip-tar, + /// A ZIP file (`.zip`). + zip, + /// An uncompressed file. + uncompressed, + } + + /// The installation status for a language server. + variant language-server-installation-status { + /// The language server has no installation status. + none, + /// The language server is being downloaded. + downloading, + /// The language server is checking for updates. + checking-for-update, + /// The language server installation failed for specified reason. + failed(string), + } + + record settings-location { + worktree-id: u64, + path: string, + } + + import get-settings: func(path: option, category: string, key: option) -> result; + + /// Downloads a file from the given URL and saves it to the given path within the extension's + /// working directory. + /// + /// The file will be extracted according to the given file type. + import download-file: func(url: string, file-path: string, file-type: downloaded-file-type) -> result<_, string>; + + /// Makes the file at the given path executable. + import make-file-executable: func(filepath: string) -> result<_, string>; + + /// Updates the installation status for the given language server. + import set-language-server-installation-status: func(language-server-name: string, status: language-server-installation-status); + + /// A Zed worktree. + resource worktree { + /// Returns the ID of the worktree. + id: func() -> u64; + /// Returns the root path of the worktree. + root-path: func() -> string; + /// Returns the textual contents of the specified file in the worktree. + read-text-file: func(path: string) -> result; + /// Returns the path to the given binary name, if one is present on the `$PATH`. + which: func(binary-name: string) -> option; + /// Returns the current shell environment. + shell-env: func() -> env-vars; + } + + /// A Zed project. + resource project { + /// Returns the IDs of all of the worktrees in this project. + worktree-ids: func() -> list; + } + + /// A key-value store. + resource key-value-store { + /// Inserts an entry under the specified key. + insert: func(key: string, value: string) -> result<_, string>; + } + + /// Returns the command used to start up the language server. + export language-server-command: func(language-server-id: string, worktree: borrow) -> result; + + /// Returns the initialization options to pass to the language server on startup. + /// + /// The initialization options are represented as a JSON string. + export language-server-initialization-options: func(language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the workspace configuration options to pass to the language server. + export language-server-workspace-configuration: func(language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the initialization options to pass to the other language server. + export language-server-additional-initialization-options: func(language-server-id: string, target-language-server-id: string, worktree: borrow) -> result, string>; + + /// Returns the workspace configuration options to pass to the other language server. + export language-server-additional-workspace-configuration: func(language-server-id: string, target-language-server-id: string, worktree: borrow) -> result, string>; + + /// A label containing some code. + record code-label { + /// The source code to parse with Tree-sitter. + code: string, + /// The spans to display in the label. + spans: list, + /// The range of the displayed label to include when filtering. + filter-range: range, + } + + /// A span within a code label. + variant code-label-span { + /// A range into the parsed code. + code-range(range), + /// A span containing a code literal. + literal(code-label-span-literal), + } + + /// A span containing a code literal. + record code-label-span-literal { + /// The literal text. + text: string, + /// The name of the highlight to use for this literal. + highlight-name: option, + } + + export labels-for-completions: func(language-server-id: string, completions: list) -> result>, string>; + export labels-for-symbols: func(language-server-id: string, symbols: list) -> result>, string>; + + + /// Returns the completions that should be shown when completing the provided slash command with the given query. + export complete-slash-command-argument: func(command: slash-command, args: list) -> result, string>; + + /// Returns the output from running the provided slash command. + export run-slash-command: func(command: slash-command, args: list, worktree: option>) -> result; + + /// Returns the command used to start up a context server. + export context-server-command: func(context-server-id: string, project: borrow) -> result; + + /// Returns the configuration for a context server. + export context-server-configuration: func(context-server-id: string, project: borrow) -> result, string>; + + /// Returns a list of packages as suggestions to be included in the `/docs` + /// search results. + /// + /// This can be used to provide completions for known packages (e.g., from the + /// local project or a registry) before a package has been indexed. + export suggest-docs-packages: func(provider-name: string) -> result, string>; + + /// Indexes the docs for the specified package. + export index-docs: func(provider-name: string, package-name: string, database: borrow) -> result<_, string>; + + /// Returns a configured debug adapter binary for a given debug task. + export get-dap-binary: func(adapter-name: string, config: debug-task-definition, user-installed-path: option, worktree: borrow) -> result; + /// Returns the kind of a debug scenario (launch or attach). + export dap-request-kind: func(adapter-name: string, config: string) -> result; + export dap-config-to-scenario: func(config: debug-config) -> result; + export dap-locator-create-scenario: func(locator-name: string, build-config-template: build-task-template, resolved-label: string, debug-adapter-name: string) -> option; + export run-dap-locator: func(locator-name: string, config: resolved-task) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/github.wit b/crates/extension_api/wit/since_v0.8.0/github.wit new file mode 100644 index 0000000000000000000000000000000000000000..21cd5d48056af08441d3bb5aa8547edd97a874d7 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/github.wit @@ -0,0 +1,35 @@ +interface github { + /// A GitHub release. + record github-release { + /// The version of the release. + version: string, + /// The list of assets attached to the release. + assets: list, + } + + /// An asset from a GitHub release. + record github-release-asset { + /// The name of the asset. + name: string, + /// The download URL for the asset. + download-url: string, + } + + /// The options used to filter down GitHub releases. + record github-release-options { + /// Whether releases without assets should be included. + require-assets: bool, + /// Whether pre-releases should be included. + pre-release: bool, + } + + /// Returns the latest release for the given GitHub repository. + /// + /// Takes repo as a string in the form "/", for example: "zed-industries/zed". + latest-github-release: func(repo: string, options: github-release-options) -> result; + + /// Returns the GitHub release with the specified tag name for the given GitHub repository. + /// + /// Returns an error if a release with the given tag name does not exist. + github-release-by-tag-name: func(repo: string, tag: string) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/http-client.wit b/crates/extension_api/wit/since_v0.8.0/http-client.wit new file mode 100644 index 0000000000000000000000000000000000000000..bb0206c17a52d4d20b99f445dca4ac606e0485f7 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/http-client.wit @@ -0,0 +1,67 @@ +interface http-client { + /// An HTTP request. + record http-request { + /// The HTTP method for the request. + method: http-method, + /// The URL to which the request should be made. + url: string, + /// The headers for the request. + headers: list>, + /// The request body. + body: option>, + /// The policy to use for redirects. + redirect-policy: redirect-policy, + } + + /// HTTP methods. + enum http-method { + /// `GET` + get, + /// `HEAD` + head, + /// `POST` + post, + /// `PUT` + put, + /// `DELETE` + delete, + /// `OPTIONS` + options, + /// `PATCH` + patch, + } + + /// The policy for dealing with redirects received from the server. + variant redirect-policy { + /// Redirects from the server will not be followed. + /// + /// This is the default behavior. + no-follow, + /// Redirects from the server will be followed up to the specified limit. + follow-limit(u32), + /// All redirects from the server will be followed. + follow-all, + } + + /// An HTTP response. + record http-response { + /// The response headers. + headers: list>, + /// The response body. + body: list, + } + + /// Performs an HTTP request and returns the response. + fetch: func(req: http-request) -> result; + + /// An HTTP response stream. + resource http-response-stream { + /// Retrieves the next chunk of data from the response stream. + /// + /// Returns `Ok(None)` if the stream has ended. + next-chunk: func() -> result>, string>; + } + + /// Performs an HTTP request and returns a response stream. + fetch-stream: func(req: http-request) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/lsp.wit b/crates/extension_api/wit/since_v0.8.0/lsp.wit new file mode 100644 index 0000000000000000000000000000000000000000..91a36c93a66467ea7dc7d78932d3821dae79d864 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/lsp.wit @@ -0,0 +1,90 @@ +interface lsp { + /// An LSP completion. + record completion { + label: string, + label-details: option, + detail: option, + kind: option, + insert-text-format: option, + } + + /// The kind of an LSP completion. + variant completion-kind { + text, + method, + function, + %constructor, + field, + variable, + class, + %interface, + module, + property, + unit, + value, + %enum, + keyword, + snippet, + color, + file, + reference, + folder, + enum-member, + constant, + struct, + event, + operator, + type-parameter, + other(s32), + } + + /// Label details for an LSP completion. + record completion-label-details { + detail: option, + description: option, + } + + /// Defines how to interpret the insert text in a completion item. + variant insert-text-format { + plain-text, + snippet, + other(s32), + } + + /// An LSP symbol. + record symbol { + kind: symbol-kind, + name: string, + } + + /// The kind of an LSP symbol. + variant symbol-kind { + file, + module, + namespace, + %package, + class, + method, + property, + field, + %constructor, + %enum, + %interface, + function, + variable, + constant, + %string, + number, + boolean, + array, + object, + key, + null, + enum-member, + struct, + event, + operator, + type-parameter, + other(s32), + } +} diff --git a/crates/extension_api/wit/since_v0.8.0/nodejs.wit b/crates/extension_api/wit/since_v0.8.0/nodejs.wit new file mode 100644 index 0000000000000000000000000000000000000000..c814548314162c862e81a98b3fba6950dc2a7f41 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/nodejs.wit @@ -0,0 +1,13 @@ +interface nodejs { + /// Returns the path to the Node binary used by Zed. + node-binary-path: func() -> result; + + /// Returns the latest version of the given NPM package. + npm-package-latest-version: func(package-name: string) -> result; + + /// Returns the installed version of the given NPM package, if it exists. + npm-package-installed-version: func(package-name: string) -> result, string>; + + /// Installs the specified NPM package. + npm-install-package: func(package-name: string, version: string) -> result<_, string>; +} diff --git a/crates/extension_api/wit/since_v0.8.0/platform.wit b/crates/extension_api/wit/since_v0.8.0/platform.wit new file mode 100644 index 0000000000000000000000000000000000000000..48472a99bc175fdc24231a690db021433d5a2505 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/platform.wit @@ -0,0 +1,24 @@ +interface platform { + /// An operating system. + enum os { + /// macOS. + mac, + /// Linux. + linux, + /// Windows. + windows, + } + + /// A platform architecture. + enum architecture { + /// AArch64 (e.g., Apple Silicon). + aarch64, + /// x86. + x86, + /// x86-64. + x8664, + } + + /// Gets the current operating system and architecture. + current-platform: func() -> tuple; +} diff --git a/crates/extension_api/wit/since_v0.8.0/process.wit b/crates/extension_api/wit/since_v0.8.0/process.wit new file mode 100644 index 0000000000000000000000000000000000000000..d9a5728a3d8f5bdaa578d9dd9fc087610688cf27 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/process.wit @@ -0,0 +1,29 @@ +interface process { + use common.{env-vars}; + + /// A command. + record command { + /// The command to execute. + command: string, + /// The arguments to pass to the command. + args: list, + /// The environment variables to set for the command. + env: env-vars, + } + + /// The output of a finished process. + record output { + /// The status (exit code) of the process. + /// + /// On Unix, this will be `None` if the process was terminated by a signal. + status: option, + /// The data that the process wrote to stdout. + stdout: list, + /// The data that the process wrote to stderr. + stderr: list, + } + + /// Executes the given command as a child process, waiting for it to finish + /// and collecting all of its output. + run-command: func(command: command) -> result; +} diff --git a/crates/extension_api/wit/since_v0.8.0/settings.rs b/crates/extension_api/wit/since_v0.8.0/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..19e28c1ba955a998fe7b97f3eacb57c4b1104154 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/settings.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, num::NonZeroU32}; + +/// The settings for a particular language. +#[derive(Debug, Serialize, Deserialize)] +pub struct LanguageSettings { + /// How many columns a tab should occupy. + pub tab_size: NonZeroU32, +} + +/// The settings for a particular language server. +#[derive(Default, Debug, Serialize, Deserialize)] +pub struct LspSettings { + /// The settings for the language server binary. + pub binary: Option, + /// The initialization options to pass to the language server. + pub initialization_options: Option, + /// The settings to pass to language server. + pub settings: Option, +} + +/// The settings for a particular context server. +#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextServerSettings { + /// The settings for the context server binary. + pub command: Option, + /// The settings to pass to the context server. + pub settings: Option, +} + +/// The settings for a command. +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommandSettings { + /// The path to the command. + pub path: Option, + /// The arguments to pass to the command. + pub arguments: Option>, + /// The environment variables. + pub env: Option>, +} diff --git a/crates/extension_api/wit/since_v0.8.0/slash-command.wit b/crates/extension_api/wit/since_v0.8.0/slash-command.wit new file mode 100644 index 0000000000000000000000000000000000000000..f52561c2ef412be071820f3a71621c3c4f3f9da3 --- /dev/null +++ b/crates/extension_api/wit/since_v0.8.0/slash-command.wit @@ -0,0 +1,41 @@ +interface slash-command { + use common.{range}; + + /// A slash command for use in the Assistant. + record slash-command { + /// The name of the slash command. + name: string, + /// The description of the slash command. + description: string, + /// The tooltip text to display for the run button. + tooltip-text: string, + /// Whether this slash command requires an argument. + requires-argument: bool, + } + + /// The output of a slash command. + record slash-command-output { + /// The text produced by the slash command. + text: string, + /// The list of sections to show in the slash command placeholder. + sections: list, + } + + /// A section in the slash command output. + record slash-command-output-section { + /// The range this section occupies. + range: range, + /// The label to display in the placeholder for this section. + label: string, + } + + /// A completion for a slash command argument. + record slash-command-argument-completion { + /// The label to display for this completion. + label: string, + /// The new text that should be inserted into the command when this completion is accepted. + new-text: string, + /// Whether the command should be run when accepting this completion. + run-command: bool, + } +} diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index 4c88af1b0a023441b237a26e5c14f1e6f0d0102d..5058c63365021a00dc9abf9fc05e9085757e161e 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -7,6 +7,7 @@ mod since_v0_3_0; mod since_v0_4_0; mod since_v0_5_0; mod since_v0_6_0; +mod since_v0_8_0; use dap::DebugRequest; use extension::{DebugTaskDefinition, KeyValueStoreDelegate, WorktreeDelegate}; use gpui::BackgroundExecutor; @@ -20,7 +21,7 @@ use crate::wasm_host::wit::since_v0_6_0::dap::StartDebuggingRequestArgumentsRequ use super::{WasmState, wasm_engine}; use anyhow::{Context as _, Result, anyhow}; use semver::Version; -use since_v0_6_0 as latest; +use since_v0_8_0 as latest; use std::{ops::RangeInclusive, path::PathBuf, sync::Arc}; use wasmtime::{ Store, @@ -66,7 +67,7 @@ pub fn wasm_api_version_range(release_channel: ReleaseChannel) -> RangeInclusive let max_version = match release_channel { ReleaseChannel::Dev | ReleaseChannel::Nightly => latest::MAX_VERSION, - ReleaseChannel::Stable | ReleaseChannel::Preview => latest::MAX_VERSION, + ReleaseChannel::Stable | ReleaseChannel::Preview => since_v0_6_0::MAX_VERSION, }; since_v0_0_1::MIN_VERSION..=max_version @@ -95,6 +96,7 @@ pub fn authorize_access_to_unreleased_wasm_api_version( } pub enum Extension { + V0_8_0(since_v0_8_0::Extension), V0_6_0(since_v0_6_0::Extension), V0_5_0(since_v0_5_0::Extension), V0_4_0(since_v0_4_0::Extension), @@ -118,10 +120,21 @@ impl Extension { let _ = release_channel; if version >= latest::MIN_VERSION { + authorize_access_to_unreleased_wasm_api_version(release_channel)?; + let extension = latest::Extension::instantiate_async(store, component, latest::linker(executor)) .await .context("failed to instantiate wasm extension")?; + Ok(Self::V0_8_0(extension)) + } else if version >= since_v0_6_0::MIN_VERSION { + let extension = since_v0_6_0::Extension::instantiate_async( + store, + component, + since_v0_6_0::linker(executor), + ) + .await + .context("failed to instantiate wasm extension")?; Ok(Self::V0_6_0(extension)) } else if version >= since_v0_5_0::MIN_VERSION { let extension = since_v0_5_0::Extension::instantiate_async( @@ -200,6 +213,7 @@ impl Extension { pub async fn call_init_extension(&self, store: &mut Store) -> Result<()> { match self { + Extension::V0_8_0(ext) => ext.call_init_extension(store).await, Extension::V0_6_0(ext) => ext.call_init_extension(store).await, Extension::V0_5_0(ext) => ext.call_init_extension(store).await, Extension::V0_4_0(ext) => ext.call_init_extension(store).await, @@ -220,6 +234,10 @@ impl Extension { resource: Resource>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_command(store, &language_server_id.0, resource) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_command(store, &language_server_id.0, resource) .await @@ -282,6 +300,14 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_initialization_options( + store, + &language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_initialization_options( store, @@ -371,6 +397,14 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_workspace_configuration( + store, + &language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_workspace_configuration( store, @@ -439,6 +473,15 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_additional_initialization_options( + store, + &language_server_id.0, + &target_language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_additional_initialization_options( store, @@ -483,6 +526,15 @@ impl Extension { resource: Resource>, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_language_server_additional_workspace_configuration( + store, + &language_server_id.0, + &target_language_server_id.0, + resource, + ) + .await + } Extension::V0_6_0(ext) => { ext.call_language_server_additional_workspace_configuration( store, @@ -526,10 +578,23 @@ impl Extension { completions: Vec, ) -> Result>, String>> { match self { - Extension::V0_6_0(ext) => { + Extension::V0_8_0(ext) => { ext.call_labels_for_completions(store, &language_server_id.0, &completions) .await } + Extension::V0_6_0(ext) => Ok(ext + .call_labels_for_completions( + store, + &language_server_id.0, + &completions.into_iter().collect::>(), + ) + .await? + .map(|labels| { + labels + .into_iter() + .map(|label| label.map(Into::into)) + .collect() + })), Extension::V0_5_0(ext) => Ok(ext .call_labels_for_completions( store, @@ -619,10 +684,23 @@ impl Extension { symbols: Vec, ) -> Result>, String>> { match self { - Extension::V0_6_0(ext) => { + Extension::V0_8_0(ext) => { ext.call_labels_for_symbols(store, &language_server_id.0, &symbols) .await } + Extension::V0_6_0(ext) => Ok(ext + .call_labels_for_symbols( + store, + &language_server_id.0, + &symbols.into_iter().collect::>(), + ) + .await? + .map(|labels| { + labels + .into_iter() + .map(|label| label.map(Into::into)) + .collect() + })), Extension::V0_5_0(ext) => Ok(ext .call_labels_for_symbols( store, @@ -712,6 +790,10 @@ impl Extension { arguments: &[String], ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_complete_slash_command_argument(store, command, arguments) + .await + } Extension::V0_6_0(ext) => { ext.call_complete_slash_command_argument(store, command, arguments) .await @@ -750,6 +832,10 @@ impl Extension { resource: Option>>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_run_slash_command(store, command, arguments, resource) + .await + } Extension::V0_6_0(ext) => { ext.call_run_slash_command(store, command, arguments, resource) .await @@ -787,6 +873,10 @@ impl Extension { project: Resource, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_context_server_command(store, &context_server_id, project) + .await + } Extension::V0_6_0(ext) => { ext.call_context_server_command(store, &context_server_id, project) .await @@ -823,6 +913,10 @@ impl Extension { project: Resource, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => { + ext.call_context_server_configuration(store, &context_server_id, project) + .await + } Extension::V0_6_0(ext) => { ext.call_context_server_configuration(store, &context_server_id, project) .await @@ -849,6 +943,7 @@ impl Extension { provider: &str, ) -> Result, String>> { match self { + Extension::V0_8_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_6_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_5_0(ext) => ext.call_suggest_docs_packages(store, provider).await, Extension::V0_4_0(ext) => ext.call_suggest_docs_packages(store, provider).await, @@ -869,6 +964,10 @@ impl Extension { kv_store: Resource>, ) -> Result> { match self { + Extension::V0_8_0(ext) => { + ext.call_index_docs(store, provider, package_name, kv_store) + .await + } Extension::V0_6_0(ext) => { ext.call_index_docs(store, provider, package_name, kv_store) .await @@ -898,6 +997,7 @@ impl Extension { } } } + pub async fn call_get_dap_binary( &self, store: &mut Store, @@ -924,6 +1024,7 @@ impl Extension { _ => anyhow::bail!("`get_dap_binary` not available prior to v0.6.0"), } } + pub async fn call_dap_request_kind( &self, store: &mut Store, @@ -944,6 +1045,7 @@ impl Extension { _ => anyhow::bail!("`dap_request_kind` not available prior to v0.6.0"), } } + pub async fn call_dap_config_to_scenario( &self, store: &mut Store, @@ -962,6 +1064,7 @@ impl Extension { _ => anyhow::bail!("`dap_config_to_scenario` not available prior to v0.6.0"), } } + pub async fn call_dap_locator_create_scenario( &self, store: &mut Store, @@ -988,6 +1091,7 @@ impl Extension { _ => anyhow::bail!("`dap_locator_create_scenario` not available prior to v0.6.0"), } } + pub async fn call_run_dap_locator( &self, store: &mut Store, diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index c96e5216c4703df2a73e1a0bc27c90d13adbb782..8595c278b95a433f782ea5c53e2c97c75aa353da 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -1,41 +1,13 @@ -use crate::wasm_host::wit::since_v0_6_0::{ - dap::{ - AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest, - StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate, - }, - slash_command::SlashCommandOutputSection, -}; -use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind}; -use crate::wasm_host::{WasmState, wit::ToWasmtimeResult}; -use ::http_client::{AsyncBody, HttpRequestExt}; -use ::settings::{Settings, WorktreeId}; -use anyhow::{Context as _, Result, bail}; -use async_compression::futures::bufread::GzipDecoder; -use async_tar::Archive; -use async_trait::async_trait; -use extension::{ - ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, -}; -use futures::{AsyncReadExt, lock::Mutex}; -use futures::{FutureExt as _, io::BufReader}; -use gpui::{BackgroundExecutor, SharedString}; -use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings}; -use project::project_settings::ProjectSettings; +use crate::wasm_host::WasmState; +use anyhow::Result; +use extension::{KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate}; +use gpui::BackgroundExecutor; use semver::Version; -use std::{ - env, - net::Ipv4Addr, - path::{Path, PathBuf}, - str::FromStr, - sync::{Arc, OnceLock}, -}; -use task::{SpawnInTerminal, ZedDebugConfig}; -use url::Url; -use util::{ - archive::extract_zip, fs::make_file_executable, maybe, paths::PathStyle, rel_path::RelPath, -}; +use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; +use super::latest; + pub const MIN_VERSION: Version = Version::new(0, 6, 0); pub const MAX_VERSION: Version = Version::new(0, 7, 0); @@ -44,10 +16,19 @@ wasmtime::component::bindgen!({ trappable_imports: true, path: "../extension_api/wit/since_v0.6.0", with: { - "worktree": ExtensionWorktree, - "project": ExtensionProject, - "key-value-store": ExtensionKeyValueStore, - "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/common": latest::zed::extension::common, + "zed:extension/github": latest::zed::extension::github, + "zed:extension/http-client": latest::zed::extension::http_client, + "zed:extension/lsp": latest::zed::extension::lsp, + "zed:extension/nodejs": latest::zed::extension::nodejs, + "zed:extension/platform": latest::zed::extension::platform, + "zed:extension/process": latest::zed::extension::process, + "zed:extension/slash-command": latest::zed::extension::slash_command, + "zed:extension/context-server": latest::zed::extension::context_server, + "zed:extension/dap": latest::zed::extension::dap, }, }); @@ -61,289 +42,32 @@ mod settings { pub type ExtensionWorktree = Arc; pub type ExtensionProject = Arc; pub type ExtensionKeyValueStore = Arc; -pub type ExtensionHttpResponseStream = Arc>>; pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { static LINKER: OnceLock> = OnceLock::new(); LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) } -impl From for std::ops::Range { - fn from(range: Range) -> Self { - let start = range.start as usize; - let end = range.end as usize; - start..end - } -} - -impl From for extension::Command { - fn from(value: Command) -> Self { - Self { - command: value.command.into(), - args: value.args, - env: value.env, - } - } -} - -impl From - for extension::StartDebuggingRequestArgumentsRequest -{ - fn from(value: StartDebuggingRequestArgumentsRequest) -> Self { - match value { - StartDebuggingRequestArgumentsRequest::Launch => Self::Launch, - StartDebuggingRequestArgumentsRequest::Attach => Self::Attach, - } - } -} -impl TryFrom for extension::StartDebuggingRequestArguments { - type Error = anyhow::Error; - - fn try_from(value: StartDebuggingRequestArguments) -> Result { - Ok(Self { - configuration: serde_json::from_str(&value.configuration)?, - request: value.request.into(), - }) - } -} -impl From for extension::TcpArguments { - fn from(value: TcpArguments) -> Self { - Self { - host: value.host.into(), - port: value.port, - timeout: value.timeout, - } - } -} - -impl From for TcpArgumentsTemplate { - fn from(value: extension::TcpArgumentsTemplate) -> Self { - Self { - host: value.host.map(Ipv4Addr::to_bits), - port: value.port, - timeout: value.timeout, - } - } -} - -impl From for extension::TcpArgumentsTemplate { - fn from(value: TcpArgumentsTemplate) -> Self { - Self { - host: value.host.map(Ipv4Addr::from_bits), - port: value.port, - timeout: value.timeout, - } - } -} - -impl TryFrom for DebugTaskDefinition { - type Error = anyhow::Error; - fn try_from(value: extension::DebugTaskDefinition) -> Result { - Ok(Self { - label: value.label.to_string(), - adapter: value.adapter.to_string(), - config: value.config.to_string(), - tcp_connection: value.tcp_connection.map(Into::into), - }) - } -} - -impl From for DebugRequest { - fn from(value: task::DebugRequest) -> Self { - match value { - task::DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), - task::DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), - } - } -} - -impl From for task::DebugRequest { - fn from(value: DebugRequest) -> Self { - match value { - DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), - DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), - } - } -} - -impl From for LaunchRequest { - fn from(value: task::LaunchRequest) -> Self { - Self { - program: value.program, - cwd: value.cwd.map(|p| p.to_string_lossy().into_owned()), - args: value.args, - envs: value.env.into_iter().collect(), - } - } -} - -impl From for AttachRequest { - fn from(value: task::AttachRequest) -> Self { - Self { - process_id: value.process_id, - } - } -} - -impl From for task::LaunchRequest { - fn from(value: LaunchRequest) -> Self { - Self { - program: value.program, - cwd: value.cwd.map(|p| p.into()), - args: value.args, - env: value.envs.into_iter().collect(), - } - } -} -impl From for task::AttachRequest { - fn from(value: AttachRequest) -> Self { - Self { - process_id: value.process_id, - } - } -} - -impl From for DebugConfig { - fn from(value: ZedDebugConfig) -> Self { - Self { - label: value.label.into(), - adapter: value.adapter.into(), - request: value.request.into(), - stop_on_entry: value.stop_on_entry, - } - } -} -impl TryFrom for extension::DebugAdapterBinary { - type Error = anyhow::Error; - fn try_from(value: DebugAdapterBinary) -> Result { - Ok(Self { - command: value.command, - arguments: value.arguments, - envs: value.envs.into_iter().collect(), - cwd: value.cwd.map(|s| s.into()), - connection: value.connection.map(Into::into), - request_args: value.request_args.try_into()?, - }) - } -} - -impl From for extension::BuildTaskDefinition { - fn from(value: BuildTaskDefinition) -> Self { - match value { - BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), - BuildTaskDefinition::Template(build_task_template) => Self::Template { - task_template: build_task_template.template.into(), - locator_name: build_task_template.locator_name.map(SharedString::from), - }, - } - } -} - -impl From for BuildTaskDefinition { - fn from(value: extension::BuildTaskDefinition) -> Self { - match value { - extension::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), - extension::BuildTaskDefinition::Template { - task_template, - locator_name, - } => Self::Template(BuildTaskDefinitionTemplatePayload { - template: task_template.into(), - locator_name: locator_name.map(String::from), - }), - } - } -} -impl From for extension::BuildTaskTemplate { - fn from(value: BuildTaskTemplate) -> Self { - Self { - label: value.label, - command: value.command, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd, - ..Default::default() - } - } -} -impl From for BuildTaskTemplate { - fn from(value: extension::BuildTaskTemplate) -> Self { - Self { - label: value.label, - command: value.command, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd, - } - } -} - -impl TryFrom for extension::DebugScenario { - type Error = anyhow::Error; - - fn try_from(value: DebugScenario) -> std::result::Result { - Ok(Self { - adapter: value.adapter.into(), - label: value.label.into(), - build: value.build.map(Into::into), - config: serde_json::Value::from_str(&value.config)?, - tcp_connection: value.tcp_connection.map(Into::into), - }) - } -} - -impl From for DebugScenario { - fn from(value: extension::DebugScenario) -> Self { - Self { - adapter: value.adapter.into(), - label: value.label.into(), - build: value.build.map(Into::into), - config: value.config.to_string(), - tcp_connection: value.tcp_connection.map(Into::into), - } - } -} - -impl TryFrom for ResolvedTask { - type Error = anyhow::Error; - - fn try_from(value: SpawnInTerminal) -> Result { - Ok(Self { - label: value.label, - command: value.command.context("missing command")?, - args: value.args, - env: value.env.into_iter().collect(), - cwd: value.cwd.map(|s| { - let s = s.to_string_lossy(); - if cfg!(target_os = "windows") { - s.replace('\\', "/") - } else { - s.into_owned() - } - }), - }) - } -} - -impl From for extension::CodeLabel { +impl From for latest::CodeLabel { fn from(value: CodeLabel) -> Self { Self { code: value.code, spans: value.spans.into_iter().map(Into::into).collect(), - filter_range: value.filter_range.into(), + filter_range: value.filter_range, } } } -impl From for extension::CodeLabelSpan { +impl From for latest::CodeLabelSpan { fn from(value: CodeLabelSpan) -> Self { match value { - CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()), + CodeLabelSpan::CodeRange(range) => Self::CodeRange(range), CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()), } } } -impl From for extension::CodeLabelSpanLiteral { +impl From for latest::CodeLabelSpanLiteral { fn from(value: CodeLabelSpanLiteral) -> Self { Self { text: value.text, @@ -352,167 +76,37 @@ impl From for extension::CodeLabelSpanLiteral { } } -impl From for Completion { - fn from(value: extension::Completion) -> Self { +impl From for latest::SettingsLocation { + fn from(value: SettingsLocation) -> Self { Self { - label: value.label, - label_details: value.label_details.map(Into::into), - detail: value.detail, - kind: value.kind.map(Into::into), - insert_text_format: value.insert_text_format.map(Into::into), + worktree_id: value.worktree_id, + path: value.path, } } } -impl From for CompletionLabelDetails { - fn from(value: extension::CompletionLabelDetails) -> Self { - Self { - detail: value.detail, - description: value.description, - } - } -} - -impl From for CompletionKind { - fn from(value: extension::CompletionKind) -> Self { +impl From for latest::LanguageServerInstallationStatus { + fn from(value: LanguageServerInstallationStatus) -> Self { match value { - extension::CompletionKind::Text => Self::Text, - extension::CompletionKind::Method => Self::Method, - extension::CompletionKind::Function => Self::Function, - extension::CompletionKind::Constructor => Self::Constructor, - extension::CompletionKind::Field => Self::Field, - extension::CompletionKind::Variable => Self::Variable, - extension::CompletionKind::Class => Self::Class, - extension::CompletionKind::Interface => Self::Interface, - extension::CompletionKind::Module => Self::Module, - extension::CompletionKind::Property => Self::Property, - extension::CompletionKind::Unit => Self::Unit, - extension::CompletionKind::Value => Self::Value, - extension::CompletionKind::Enum => Self::Enum, - extension::CompletionKind::Keyword => Self::Keyword, - extension::CompletionKind::Snippet => Self::Snippet, - extension::CompletionKind::Color => Self::Color, - extension::CompletionKind::File => Self::File, - extension::CompletionKind::Reference => Self::Reference, - extension::CompletionKind::Folder => Self::Folder, - extension::CompletionKind::EnumMember => Self::EnumMember, - extension::CompletionKind::Constant => Self::Constant, - extension::CompletionKind::Struct => Self::Struct, - extension::CompletionKind::Event => Self::Event, - extension::CompletionKind::Operator => Self::Operator, - extension::CompletionKind::TypeParameter => Self::TypeParameter, - extension::CompletionKind::Other(value) => Self::Other(value), + LanguageServerInstallationStatus::None => Self::None, + LanguageServerInstallationStatus::Downloading => Self::Downloading, + LanguageServerInstallationStatus::CheckingForUpdate => Self::CheckingForUpdate, + LanguageServerInstallationStatus::Failed(message) => Self::Failed(message), } } } -impl From for InsertTextFormat { - fn from(value: extension::InsertTextFormat) -> Self { +impl From for latest::DownloadedFileType { + fn from(value: DownloadedFileType) -> Self { match value { - extension::InsertTextFormat::PlainText => Self::PlainText, - extension::InsertTextFormat::Snippet => Self::Snippet, - extension::InsertTextFormat::Other(value) => Self::Other(value), + DownloadedFileType::Gzip => Self::Gzip, + DownloadedFileType::GzipTar => Self::GzipTar, + DownloadedFileType::Zip => Self::Zip, + DownloadedFileType::Uncompressed => Self::Uncompressed, } } } -impl From for Symbol { - fn from(value: extension::Symbol) -> Self { - Self { - kind: value.kind.into(), - name: value.name, - } - } -} - -impl From for SymbolKind { - fn from(value: extension::SymbolKind) -> Self { - match value { - extension::SymbolKind::File => Self::File, - extension::SymbolKind::Module => Self::Module, - extension::SymbolKind::Namespace => Self::Namespace, - extension::SymbolKind::Package => Self::Package, - extension::SymbolKind::Class => Self::Class, - extension::SymbolKind::Method => Self::Method, - extension::SymbolKind::Property => Self::Property, - extension::SymbolKind::Field => Self::Field, - extension::SymbolKind::Constructor => Self::Constructor, - extension::SymbolKind::Enum => Self::Enum, - extension::SymbolKind::Interface => Self::Interface, - extension::SymbolKind::Function => Self::Function, - extension::SymbolKind::Variable => Self::Variable, - extension::SymbolKind::Constant => Self::Constant, - extension::SymbolKind::String => Self::String, - extension::SymbolKind::Number => Self::Number, - extension::SymbolKind::Boolean => Self::Boolean, - extension::SymbolKind::Array => Self::Array, - extension::SymbolKind::Object => Self::Object, - extension::SymbolKind::Key => Self::Key, - extension::SymbolKind::Null => Self::Null, - extension::SymbolKind::EnumMember => Self::EnumMember, - extension::SymbolKind::Struct => Self::Struct, - extension::SymbolKind::Event => Self::Event, - extension::SymbolKind::Operator => Self::Operator, - extension::SymbolKind::TypeParameter => Self::TypeParameter, - extension::SymbolKind::Other(value) => Self::Other(value), - } - } -} - -impl From for SlashCommand { - fn from(value: extension::SlashCommand) -> Self { - Self { - name: value.name, - description: value.description, - tooltip_text: value.tooltip_text, - requires_argument: value.requires_argument, - } - } -} - -impl From for extension::SlashCommandOutput { - fn from(value: SlashCommandOutput) -> Self { - Self { - text: value.text, - sections: value.sections.into_iter().map(Into::into).collect(), - } - } -} - -impl From for extension::SlashCommandOutputSection { - fn from(value: SlashCommandOutputSection) -> Self { - Self { - range: value.range.start as usize..value.range.end as usize, - label: value.label, - } - } -} - -impl From for extension::SlashCommandArgumentCompletion { - fn from(value: SlashCommandArgumentCompletion) -> Self { - Self { - label: value.label, - new_text: value.new_text, - run_command: value.run_command, - } - } -} - -impl TryFrom for extension::ContextServerConfiguration { - type Error = anyhow::Error; - - fn try_from(value: ContextServerConfiguration) -> Result { - let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema) - .context("Failed to parse settings_schema")?; - - Ok(Self { - installation_instructions: value.installation_instructions, - default_settings: value.default_settings, - settings_schema, - }) - } -} - impl HostKeyValueStore for WasmState { async fn insert( &mut self, @@ -520,8 +114,7 @@ impl HostKeyValueStore for WasmState { key: String, value: String, ) -> wasmtime::Result> { - let kv_store = self.table.get(&kv_store)?; - kv_store.insert(key, value).await.to_wasmtime_result() + latest::HostKeyValueStore::insert(self, kv_store, key, value).await } async fn drop(&mut self, _worktree: Resource) -> Result<()> { @@ -535,8 +128,7 @@ impl HostProject for WasmState { &mut self, project: Resource, ) -> wasmtime::Result> { - let project = self.table.get(&project)?; - Ok(project.worktree_ids()) + latest::HostProject::worktree_ids(self, project).await } async fn drop(&mut self, _project: Resource) -> Result<()> { @@ -547,16 +139,14 @@ impl HostProject for WasmState { impl HostWorktree for WasmState { async fn id(&mut self, delegate: Resource>) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.id()) + latest::HostWorktree::id(self, delegate).await } async fn root_path( &mut self, delegate: Resource>, ) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.root_path()) + latest::HostWorktree::root_path(self, delegate).await } async fn read_text_file( @@ -564,19 +154,14 @@ impl HostWorktree for WasmState { delegate: Resource>, path: String, ) -> wasmtime::Result> { - let delegate = self.table.get(&delegate)?; - Ok(delegate - .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) - .await - .map_err(|error| error.to_string())) + latest::HostWorktree::read_text_file(self, delegate, path).await } async fn shell_env( &mut self, delegate: Resource>, ) -> wasmtime::Result { - let delegate = self.table.get(&delegate)?; - Ok(delegate.shell_env().await.into_iter().collect()) + latest::HostWorktree::shell_env(self, delegate).await } async fn which( @@ -584,8 +169,7 @@ impl HostWorktree for WasmState { delegate: Resource>, binary_name: String, ) -> wasmtime::Result> { - let delegate = self.table.get(&delegate)?; - Ok(delegate.which(binary_name).await) + latest::HostWorktree::which(self, delegate, binary_name).await } async fn drop(&mut self, _worktree: Resource) -> Result<()> { @@ -594,319 +178,6 @@ impl HostWorktree for WasmState { } } -impl common::Host for WasmState {} - -impl http_client::Host for WasmState { - async fn fetch( - &mut self, - request: http_client::HttpRequest, - ) -> wasmtime::Result> { - maybe!(async { - let url = &request.url; - let request = convert_request(&request)?; - let mut response = self.host.http_client.send(request).await?; - - if response.status().is_client_error() || response.status().is_server_error() { - bail!("failed to fetch '{url}': status code {}", response.status()) - } - convert_response(&mut response).await - }) - .await - .to_wasmtime_result() - } - - async fn fetch_stream( - &mut self, - request: http_client::HttpRequest, - ) -> wasmtime::Result, String>> { - let request = convert_request(&request)?; - let response = self.host.http_client.send(request); - maybe!(async { - let response = response.await?; - let stream = Arc::new(Mutex::new(response)); - let resource = self.table.push(stream)?; - Ok(resource) - }) - .await - .to_wasmtime_result() - } -} - -impl http_client::HostHttpResponseStream for WasmState { - async fn next_chunk( - &mut self, - resource: Resource, - ) -> wasmtime::Result>, String>> { - let stream = self.table.get(&resource)?.clone(); - maybe!(async move { - let mut response = stream.lock().await; - let mut buffer = vec![0; 8192]; // 8KB buffer - let bytes_read = response.body_mut().read(&mut buffer).await?; - if bytes_read == 0 { - Ok(None) - } else { - buffer.truncate(bytes_read); - Ok(Some(buffer)) - } - }) - .await - .to_wasmtime_result() - } - - async fn drop(&mut self, _resource: Resource) -> Result<()> { - Ok(()) - } -} - -impl From for ::http_client::Method { - fn from(value: http_client::HttpMethod) -> Self { - match value { - http_client::HttpMethod::Get => Self::GET, - http_client::HttpMethod::Post => Self::POST, - http_client::HttpMethod::Put => Self::PUT, - http_client::HttpMethod::Delete => Self::DELETE, - http_client::HttpMethod::Head => Self::HEAD, - http_client::HttpMethod::Options => Self::OPTIONS, - http_client::HttpMethod::Patch => Self::PATCH, - } - } -} - -fn convert_request( - extension_request: &http_client::HttpRequest, -) -> anyhow::Result<::http_client::Request> { - let mut request = ::http_client::Request::builder() - .method(::http_client::Method::from(extension_request.method)) - .uri(&extension_request.url) - .follow_redirects(match extension_request.redirect_policy { - http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow, - http_client::RedirectPolicy::FollowLimit(limit) => { - ::http_client::RedirectPolicy::FollowLimit(limit) - } - http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll, - }); - for (key, value) in &extension_request.headers { - request = request.header(key, value); - } - let body = extension_request - .body - .clone() - .map(AsyncBody::from) - .unwrap_or_default(); - request.body(body).map_err(anyhow::Error::from) -} - -async fn convert_response( - response: &mut ::http_client::Response, -) -> anyhow::Result { - let mut extension_response = http_client::HttpResponse { - body: Vec::new(), - headers: Vec::new(), - }; - - for (key, value) in response.headers() { - extension_response - .headers - .push((key.to_string(), value.to_str().unwrap_or("").to_string())); - } - - response - .body_mut() - .read_to_end(&mut extension_response.body) - .await?; - - Ok(extension_response) -} - -impl nodejs::Host for WasmState { - async fn node_binary_path(&mut self) -> wasmtime::Result> { - self.host - .node_runtime - .binary_path() - .await - .map(|path| path.to_string_lossy().into_owned()) - .to_wasmtime_result() - } - - async fn npm_package_latest_version( - &mut self, - package_name: String, - ) -> wasmtime::Result> { - self.host - .node_runtime - .npm_package_latest_version(&package_name) - .await - .to_wasmtime_result() - } - - async fn npm_package_installed_version( - &mut self, - package_name: String, - ) -> wasmtime::Result, String>> { - self.host - .node_runtime - .npm_package_installed_version(&self.work_dir(), &package_name) - .await - .to_wasmtime_result() - } - - async fn npm_install_package( - &mut self, - package_name: String, - version: String, - ) -> wasmtime::Result> { - self.capability_granter - .grant_npm_install_package(&package_name)?; - - self.host - .node_runtime - .npm_install_packages(&self.work_dir(), &[(&package_name, &version)]) - .await - .to_wasmtime_result() - } -} - -#[async_trait] -impl lsp::Host for WasmState {} - -impl From<::http_client::github::GithubRelease> for github::GithubRelease { - fn from(value: ::http_client::github::GithubRelease) -> Self { - Self { - version: value.tag_name, - assets: value.assets.into_iter().map(Into::into).collect(), - } - } -} - -impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset { - fn from(value: ::http_client::github::GithubReleaseAsset) -> Self { - Self { - name: value.name, - download_url: value.browser_download_url, - } - } -} - -impl github::Host for WasmState { - async fn latest_github_release( - &mut self, - repo: String, - options: github::GithubReleaseOptions, - ) -> wasmtime::Result> { - maybe!(async { - let release = ::http_client::github::latest_github_release( - &repo, - options.require_assets, - options.pre_release, - self.host.http_client.clone(), - ) - .await?; - Ok(release.into()) - }) - .await - .to_wasmtime_result() - } - - async fn github_release_by_tag_name( - &mut self, - repo: String, - tag: String, - ) -> wasmtime::Result> { - maybe!(async { - let release = ::http_client::github::get_release_by_tag_name( - &repo, - &tag, - self.host.http_client.clone(), - ) - .await?; - Ok(release.into()) - }) - .await - .to_wasmtime_result() - } -} - -impl platform::Host for WasmState { - async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> { - Ok(( - match env::consts::OS { - "macos" => platform::Os::Mac, - "linux" => platform::Os::Linux, - "windows" => platform::Os::Windows, - _ => panic!("unsupported os"), - }, - match env::consts::ARCH { - "aarch64" => platform::Architecture::Aarch64, - "x86" => platform::Architecture::X86, - "x86_64" => platform::Architecture::X8664, - _ => panic!("unsupported architecture"), - }, - )) - } -} - -impl From for process::Output { - fn from(output: std::process::Output) -> Self { - Self { - status: output.status.code(), - stdout: output.stdout, - stderr: output.stderr, - } - } -} - -impl process::Host for WasmState { - async fn run_command( - &mut self, - command: process::Command, - ) -> wasmtime::Result> { - maybe!(async { - self.capability_granter - .grant_exec(&command.command, &command.args)?; - - let output = util::command::new_smol_command(command.command.as_str()) - .args(&command.args) - .envs(command.env) - .output() - .await?; - - Ok(output.into()) - }) - .await - .to_wasmtime_result() - } -} - -#[async_trait] -impl slash_command::Host for WasmState {} - -#[async_trait] -impl context_server::Host for WasmState {} - -impl dap::Host for WasmState { - async fn resolve_tcp_template( - &mut self, - template: TcpArgumentsTemplate, - ) -> wasmtime::Result> { - maybe!(async { - let (host, port, timeout) = - ::dap::configure_tcp_connection(task::TcpArgumentsTemplate { - port: template.port, - host: template.host.map(Ipv4Addr::from_bits), - timeout: template.timeout, - }) - .await?; - Ok(TcpArguments { - port, - host: host.to_bits(), - timeout, - }) - }) - .await - .to_wasmtime_result() - } -} - impl ExtensionImports for WasmState { async fn get_settings( &mut self, @@ -914,96 +185,13 @@ impl ExtensionImports for WasmState { category: String, key: Option, ) -> wasmtime::Result> { - self.on_main_thread(|cx| { - async move { - let path = location.as_ref().and_then(|location| { - RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() - }); - let location = path - .as_ref() - .zip(location.as_ref()) - .map(|(path, location)| ::settings::SettingsLocation { - worktree_id: WorktreeId::from_proto(location.worktree_id), - path, - }); - - cx.update(|cx| match category.as_str() { - "language" => { - let key = key.map(|k| LanguageName::new(&k)); - let settings = AllLanguageSettings::get(location, cx).language( - location, - key.as_ref(), - cx, - ); - Ok(serde_json::to_string(&settings::LanguageSettings { - tab_size: settings.tab_size, - })?) - } - "lsp" => { - let settings = key - .and_then(|key| { - ProjectSettings::get(location, cx) - .lsp - .get(&::lsp::LanguageServerName::from_proto(key)) - }) - .cloned() - .unwrap_or_default(); - Ok(serde_json::to_string(&settings::LspSettings { - binary: settings.binary.map(|binary| settings::CommandSettings { - path: binary.path, - arguments: binary.arguments, - env: binary.env.map(|env| env.into_iter().collect()), - }), - settings: settings.settings, - initialization_options: settings.initialization_options, - })?) - } - "context_servers" => { - let settings = key - .and_then(|key| { - ProjectSettings::get(location, cx) - .context_servers - .get(key.as_str()) - }) - .cloned() - .unwrap_or_else(|| { - project::project_settings::ContextServerSettings::default_extension( - ) - }); - - match settings { - project::project_settings::ContextServerSettings::Stdio { - enabled: _, - command, - } => Ok(serde_json::to_string(&settings::ContextServerSettings { - command: Some(settings::CommandSettings { - path: command.path.to_str().map(|path| path.to_string()), - arguments: Some(command.args), - env: command.env.map(|env| env.into_iter().collect()), - }), - settings: None, - })?), - project::project_settings::ContextServerSettings::Extension { - enabled: _, - settings, - } => Ok(serde_json::to_string(&settings::ContextServerSettings { - command: None, - settings: Some(settings), - })?), - project::project_settings::ContextServerSettings::Http { .. } => { - bail!("remote context server settings not supported in 0.6.0") - } - } - } - _ => { - bail!("Unknown settings category: {}", category); - } - }) - } - .boxed_local() - }) - .await? - .to_wasmtime_result() + latest::ExtensionImports::get_settings( + self, + location.map(|location| location.into()), + category, + key, + ) + .await } async fn set_language_server_installation_status( @@ -1011,18 +199,12 @@ impl ExtensionImports for WasmState { server_name: String, status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { - let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, - LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, - LanguageServerInstallationStatus::None => BinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, - }; - - self.host - .proxy - .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); - - Ok(()) + latest::ExtensionImports::set_language_server_installation_status( + self, + server_name, + status.into(), + ) + .await } async fn download_file( @@ -1031,79 +213,10 @@ impl ExtensionImports for WasmState { path: String, file_type: DownloadedFileType, ) -> wasmtime::Result> { - maybe!(async { - let parsed_url = Url::parse(&url)?; - self.capability_granter.grant_download_file(&parsed_url)?; - - let path = PathBuf::from(path); - let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref()); - - self.host.fs.create_dir(&extension_work_dir).await?; - - let destination_path = self - .host - .writeable_path_from_extension(&self.manifest.id, &path)?; - - let mut response = self - .host - .http_client - .get(&url, Default::default(), true) - .await - .context("downloading release")?; - - anyhow::ensure!( - response.status().is_success(), - "download failed with status {}", - response.status() - ); - let body = BufReader::new(response.body_mut()); - - match file_type { - DownloadedFileType::Uncompressed => { - futures::pin_mut!(body); - self.host - .fs - .create_file_with(&destination_path, body) - .await?; - } - DownloadedFileType::Gzip => { - let body = GzipDecoder::new(body); - futures::pin_mut!(body); - self.host - .fs - .create_file_with(&destination_path, body) - .await?; - } - DownloadedFileType::GzipTar => { - let body = GzipDecoder::new(body); - futures::pin_mut!(body); - self.host - .fs - .extract_tar_file(&destination_path, Archive::new(body)) - .await?; - } - DownloadedFileType::Zip => { - futures::pin_mut!(body); - extract_zip(&destination_path, body) - .await - .with_context(|| format!("unzipping {path:?} archive"))?; - } - } - - Ok(()) - }) - .await - .to_wasmtime_result() + latest::ExtensionImports::download_file(self, url, path, file_type.into()).await } async fn make_file_executable(&mut self, path: String) -> wasmtime::Result> { - let path = self - .host - .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?; - - make_file_executable(&path) - .await - .with_context(|| format!("setting permissions for path {path:?}")) - .to_wasmtime_result() + latest::ExtensionImports::make_file_executable(self, path).await } } diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs new file mode 100644 index 0000000000000000000000000000000000000000..a2776f9f3b5b055d00787fb59c9bbca582352b1f --- /dev/null +++ b/crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs @@ -0,0 +1,1109 @@ +use crate::wasm_host::wit::since_v0_6_0::{ + dap::{ + AttachRequest, BuildTaskDefinition, BuildTaskDefinitionTemplatePayload, LaunchRequest, + StartDebuggingRequestArguments, TcpArguments, TcpArgumentsTemplate, + }, + slash_command::SlashCommandOutputSection, +}; +use crate::wasm_host::wit::{CompletionKind, CompletionLabelDetails, InsertTextFormat, SymbolKind}; +use crate::wasm_host::{WasmState, wit::ToWasmtimeResult}; +use ::http_client::{AsyncBody, HttpRequestExt}; +use ::settings::{Settings, WorktreeId}; +use anyhow::{Context as _, Result, bail}; +use async_compression::futures::bufread::GzipDecoder; +use async_tar::Archive; +use async_trait::async_trait; +use extension::{ + ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, +}; +use futures::{AsyncReadExt, lock::Mutex}; +use futures::{FutureExt as _, io::BufReader}; +use gpui::{BackgroundExecutor, SharedString}; +use language::{BinaryStatus, LanguageName, language_settings::AllLanguageSettings}; +use project::project_settings::ProjectSettings; +use semver::Version; +use std::{ + env, + net::Ipv4Addr, + path::{Path, PathBuf}, + str::FromStr, + sync::{Arc, OnceLock}, +}; +use task::{SpawnInTerminal, ZedDebugConfig}; +use url::Url; +use util::{ + archive::extract_zip, fs::make_file_executable, maybe, paths::PathStyle, rel_path::RelPath, +}; +use wasmtime::component::{Linker, Resource}; + +pub const MIN_VERSION: Version = Version::new(0, 8, 0); +pub const MAX_VERSION: Version = Version::new(0, 8, 0); + +wasmtime::component::bindgen!({ + async: true, + trappable_imports: true, + path: "../extension_api/wit/since_v0.8.0", + with: { + "worktree": ExtensionWorktree, + "project": ExtensionProject, + "key-value-store": ExtensionKeyValueStore, + "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream + }, +}); + +pub use self::zed::extension::*; + +mod settings { + #![allow(dead_code)] + include!(concat!(env!("OUT_DIR"), "/since_v0.8.0/settings.rs")); +} + +pub type ExtensionWorktree = Arc; +pub type ExtensionProject = Arc; +pub type ExtensionKeyValueStore = Arc; +pub type ExtensionHttpResponseStream = Arc>>; + +pub fn linker(executor: &BackgroundExecutor) -> &'static Linker { + static LINKER: OnceLock> = OnceLock::new(); + LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker)) +} + +impl From for std::ops::Range { + fn from(range: Range) -> Self { + let start = range.start as usize; + let end = range.end as usize; + start..end + } +} + +impl From for extension::Command { + fn from(value: Command) -> Self { + Self { + command: value.command.into(), + args: value.args, + env: value.env, + } + } +} + +impl From + for extension::StartDebuggingRequestArgumentsRequest +{ + fn from(value: StartDebuggingRequestArgumentsRequest) -> Self { + match value { + StartDebuggingRequestArgumentsRequest::Launch => Self::Launch, + StartDebuggingRequestArgumentsRequest::Attach => Self::Attach, + } + } +} +impl TryFrom for extension::StartDebuggingRequestArguments { + type Error = anyhow::Error; + + fn try_from(value: StartDebuggingRequestArguments) -> Result { + Ok(Self { + configuration: serde_json::from_str(&value.configuration)?, + request: value.request.into(), + }) + } +} +impl From for extension::TcpArguments { + fn from(value: TcpArguments) -> Self { + Self { + host: value.host.into(), + port: value.port, + timeout: value.timeout, + } + } +} + +impl From for TcpArgumentsTemplate { + fn from(value: extension::TcpArgumentsTemplate) -> Self { + Self { + host: value.host.map(Ipv4Addr::to_bits), + port: value.port, + timeout: value.timeout, + } + } +} + +impl From for extension::TcpArgumentsTemplate { + fn from(value: TcpArgumentsTemplate) -> Self { + Self { + host: value.host.map(Ipv4Addr::from_bits), + port: value.port, + timeout: value.timeout, + } + } +} + +impl TryFrom for DebugTaskDefinition { + type Error = anyhow::Error; + fn try_from(value: extension::DebugTaskDefinition) -> Result { + Ok(Self { + label: value.label.to_string(), + adapter: value.adapter.to_string(), + config: value.config.to_string(), + tcp_connection: value.tcp_connection.map(Into::into), + }) + } +} + +impl From for DebugRequest { + fn from(value: task::DebugRequest) -> Self { + match value { + task::DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), + task::DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), + } + } +} + +impl From for task::DebugRequest { + fn from(value: DebugRequest) -> Self { + match value { + DebugRequest::Launch(launch_request) => Self::Launch(launch_request.into()), + DebugRequest::Attach(attach_request) => Self::Attach(attach_request.into()), + } + } +} + +impl From for LaunchRequest { + fn from(value: task::LaunchRequest) -> Self { + Self { + program: value.program, + cwd: value.cwd.map(|p| p.to_string_lossy().into_owned()), + args: value.args, + envs: value.env.into_iter().collect(), + } + } +} + +impl From for AttachRequest { + fn from(value: task::AttachRequest) -> Self { + Self { + process_id: value.process_id, + } + } +} + +impl From for task::LaunchRequest { + fn from(value: LaunchRequest) -> Self { + Self { + program: value.program, + cwd: value.cwd.map(|p| p.into()), + args: value.args, + env: value.envs.into_iter().collect(), + } + } +} +impl From for task::AttachRequest { + fn from(value: AttachRequest) -> Self { + Self { + process_id: value.process_id, + } + } +} + +impl From for DebugConfig { + fn from(value: ZedDebugConfig) -> Self { + Self { + label: value.label.into(), + adapter: value.adapter.into(), + request: value.request.into(), + stop_on_entry: value.stop_on_entry, + } + } +} +impl TryFrom for extension::DebugAdapterBinary { + type Error = anyhow::Error; + fn try_from(value: DebugAdapterBinary) -> Result { + Ok(Self { + command: value.command, + arguments: value.arguments, + envs: value.envs.into_iter().collect(), + cwd: value.cwd.map(|s| s.into()), + connection: value.connection.map(Into::into), + request_args: value.request_args.try_into()?, + }) + } +} + +impl From for extension::BuildTaskDefinition { + fn from(value: BuildTaskDefinition) -> Self { + match value { + BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), + BuildTaskDefinition::Template(build_task_template) => Self::Template { + task_template: build_task_template.template.into(), + locator_name: build_task_template.locator_name.map(SharedString::from), + }, + } + } +} + +impl From for BuildTaskDefinition { + fn from(value: extension::BuildTaskDefinition) -> Self { + match value { + extension::BuildTaskDefinition::ByName(name) => Self::ByName(name.into()), + extension::BuildTaskDefinition::Template { + task_template, + locator_name, + } => Self::Template(BuildTaskDefinitionTemplatePayload { + template: task_template.into(), + locator_name: locator_name.map(String::from), + }), + } + } +} +impl From for extension::BuildTaskTemplate { + fn from(value: BuildTaskTemplate) -> Self { + Self { + label: value.label, + command: value.command, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd, + ..Default::default() + } + } +} +impl From for BuildTaskTemplate { + fn from(value: extension::BuildTaskTemplate) -> Self { + Self { + label: value.label, + command: value.command, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd, + } + } +} + +impl TryFrom for extension::DebugScenario { + type Error = anyhow::Error; + + fn try_from(value: DebugScenario) -> std::result::Result { + Ok(Self { + adapter: value.adapter.into(), + label: value.label.into(), + build: value.build.map(Into::into), + config: serde_json::Value::from_str(&value.config)?, + tcp_connection: value.tcp_connection.map(Into::into), + }) + } +} + +impl From for DebugScenario { + fn from(value: extension::DebugScenario) -> Self { + Self { + adapter: value.adapter.into(), + label: value.label.into(), + build: value.build.map(Into::into), + config: value.config.to_string(), + tcp_connection: value.tcp_connection.map(Into::into), + } + } +} + +impl TryFrom for ResolvedTask { + type Error = anyhow::Error; + + fn try_from(value: SpawnInTerminal) -> Result { + Ok(Self { + label: value.label, + command: value.command.context("missing command")?, + args: value.args, + env: value.env.into_iter().collect(), + cwd: value.cwd.map(|s| { + let s = s.to_string_lossy(); + if cfg!(target_os = "windows") { + s.replace('\\', "/") + } else { + s.into_owned() + } + }), + }) + } +} + +impl From for extension::CodeLabel { + fn from(value: CodeLabel) -> Self { + Self { + code: value.code, + spans: value.spans.into_iter().map(Into::into).collect(), + filter_range: value.filter_range.into(), + } + } +} + +impl From for extension::CodeLabelSpan { + fn from(value: CodeLabelSpan) -> Self { + match value { + CodeLabelSpan::CodeRange(range) => Self::CodeRange(range.into()), + CodeLabelSpan::Literal(literal) => Self::Literal(literal.into()), + } + } +} + +impl From for extension::CodeLabelSpanLiteral { + fn from(value: CodeLabelSpanLiteral) -> Self { + Self { + text: value.text, + highlight_name: value.highlight_name, + } + } +} + +impl From for Completion { + fn from(value: extension::Completion) -> Self { + Self { + label: value.label, + label_details: value.label_details.map(Into::into), + detail: value.detail, + kind: value.kind.map(Into::into), + insert_text_format: value.insert_text_format.map(Into::into), + } + } +} + +impl From for CompletionLabelDetails { + fn from(value: extension::CompletionLabelDetails) -> Self { + Self { + detail: value.detail, + description: value.description, + } + } +} + +impl From for CompletionKind { + fn from(value: extension::CompletionKind) -> Self { + match value { + extension::CompletionKind::Text => Self::Text, + extension::CompletionKind::Method => Self::Method, + extension::CompletionKind::Function => Self::Function, + extension::CompletionKind::Constructor => Self::Constructor, + extension::CompletionKind::Field => Self::Field, + extension::CompletionKind::Variable => Self::Variable, + extension::CompletionKind::Class => Self::Class, + extension::CompletionKind::Interface => Self::Interface, + extension::CompletionKind::Module => Self::Module, + extension::CompletionKind::Property => Self::Property, + extension::CompletionKind::Unit => Self::Unit, + extension::CompletionKind::Value => Self::Value, + extension::CompletionKind::Enum => Self::Enum, + extension::CompletionKind::Keyword => Self::Keyword, + extension::CompletionKind::Snippet => Self::Snippet, + extension::CompletionKind::Color => Self::Color, + extension::CompletionKind::File => Self::File, + extension::CompletionKind::Reference => Self::Reference, + extension::CompletionKind::Folder => Self::Folder, + extension::CompletionKind::EnumMember => Self::EnumMember, + extension::CompletionKind::Constant => Self::Constant, + extension::CompletionKind::Struct => Self::Struct, + extension::CompletionKind::Event => Self::Event, + extension::CompletionKind::Operator => Self::Operator, + extension::CompletionKind::TypeParameter => Self::TypeParameter, + extension::CompletionKind::Other(value) => Self::Other(value), + } + } +} + +impl From for InsertTextFormat { + fn from(value: extension::InsertTextFormat) -> Self { + match value { + extension::InsertTextFormat::PlainText => Self::PlainText, + extension::InsertTextFormat::Snippet => Self::Snippet, + extension::InsertTextFormat::Other(value) => Self::Other(value), + } + } +} + +impl From for Symbol { + fn from(value: extension::Symbol) -> Self { + Self { + kind: value.kind.into(), + name: value.name, + } + } +} + +impl From for SymbolKind { + fn from(value: extension::SymbolKind) -> Self { + match value { + extension::SymbolKind::File => Self::File, + extension::SymbolKind::Module => Self::Module, + extension::SymbolKind::Namespace => Self::Namespace, + extension::SymbolKind::Package => Self::Package, + extension::SymbolKind::Class => Self::Class, + extension::SymbolKind::Method => Self::Method, + extension::SymbolKind::Property => Self::Property, + extension::SymbolKind::Field => Self::Field, + extension::SymbolKind::Constructor => Self::Constructor, + extension::SymbolKind::Enum => Self::Enum, + extension::SymbolKind::Interface => Self::Interface, + extension::SymbolKind::Function => Self::Function, + extension::SymbolKind::Variable => Self::Variable, + extension::SymbolKind::Constant => Self::Constant, + extension::SymbolKind::String => Self::String, + extension::SymbolKind::Number => Self::Number, + extension::SymbolKind::Boolean => Self::Boolean, + extension::SymbolKind::Array => Self::Array, + extension::SymbolKind::Object => Self::Object, + extension::SymbolKind::Key => Self::Key, + extension::SymbolKind::Null => Self::Null, + extension::SymbolKind::EnumMember => Self::EnumMember, + extension::SymbolKind::Struct => Self::Struct, + extension::SymbolKind::Event => Self::Event, + extension::SymbolKind::Operator => Self::Operator, + extension::SymbolKind::TypeParameter => Self::TypeParameter, + extension::SymbolKind::Other(value) => Self::Other(value), + } + } +} + +impl From for SlashCommand { + fn from(value: extension::SlashCommand) -> Self { + Self { + name: value.name, + description: value.description, + tooltip_text: value.tooltip_text, + requires_argument: value.requires_argument, + } + } +} + +impl From for extension::SlashCommandOutput { + fn from(value: SlashCommandOutput) -> Self { + Self { + text: value.text, + sections: value.sections.into_iter().map(Into::into).collect(), + } + } +} + +impl From for extension::SlashCommandOutputSection { + fn from(value: SlashCommandOutputSection) -> Self { + Self { + range: value.range.start as usize..value.range.end as usize, + label: value.label, + } + } +} + +impl From for extension::SlashCommandArgumentCompletion { + fn from(value: SlashCommandArgumentCompletion) -> Self { + Self { + label: value.label, + new_text: value.new_text, + run_command: value.run_command, + } + } +} + +impl TryFrom for extension::ContextServerConfiguration { + type Error = anyhow::Error; + + fn try_from(value: ContextServerConfiguration) -> Result { + let settings_schema: serde_json::Value = serde_json::from_str(&value.settings_schema) + .context("Failed to parse settings_schema")?; + + Ok(Self { + installation_instructions: value.installation_instructions, + default_settings: value.default_settings, + settings_schema, + }) + } +} + +impl HostKeyValueStore for WasmState { + async fn insert( + &mut self, + kv_store: Resource, + key: String, + value: String, + ) -> wasmtime::Result> { + let kv_store = self.table.get(&kv_store)?; + kv_store.insert(key, value).await.to_wasmtime_result() + } + + async fn drop(&mut self, _worktree: Resource) -> Result<()> { + // We only ever hand out borrows of key-value stores. + Ok(()) + } +} + +impl HostProject for WasmState { + async fn worktree_ids( + &mut self, + project: Resource, + ) -> wasmtime::Result> { + let project = self.table.get(&project)?; + Ok(project.worktree_ids()) + } + + async fn drop(&mut self, _project: Resource) -> Result<()> { + // We only ever hand out borrows of projects. + Ok(()) + } +} + +impl HostWorktree for WasmState { + async fn id(&mut self, delegate: Resource>) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.id()) + } + + async fn root_path( + &mut self, + delegate: Resource>, + ) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.root_path()) + } + + async fn read_text_file( + &mut self, + delegate: Resource>, + path: String, + ) -> wasmtime::Result> { + let delegate = self.table.get(&delegate)?; + Ok(delegate + .read_text_file(&RelPath::new(Path::new(&path), PathStyle::Posix)?) + .await + .map_err(|error| error.to_string())) + } + + async fn shell_env( + &mut self, + delegate: Resource>, + ) -> wasmtime::Result { + let delegate = self.table.get(&delegate)?; + Ok(delegate.shell_env().await.into_iter().collect()) + } + + async fn which( + &mut self, + delegate: Resource>, + binary_name: String, + ) -> wasmtime::Result> { + let delegate = self.table.get(&delegate)?; + Ok(delegate.which(binary_name).await) + } + + async fn drop(&mut self, _worktree: Resource) -> Result<()> { + // We only ever hand out borrows of worktrees. + Ok(()) + } +} + +impl common::Host for WasmState {} + +impl http_client::Host for WasmState { + async fn fetch( + &mut self, + request: http_client::HttpRequest, + ) -> wasmtime::Result> { + maybe!(async { + let url = &request.url; + let request = convert_request(&request)?; + let mut response = self.host.http_client.send(request).await?; + + if response.status().is_client_error() || response.status().is_server_error() { + bail!("failed to fetch '{url}': status code {}", response.status()) + } + convert_response(&mut response).await + }) + .await + .to_wasmtime_result() + } + + async fn fetch_stream( + &mut self, + request: http_client::HttpRequest, + ) -> wasmtime::Result, String>> { + let request = convert_request(&request)?; + let response = self.host.http_client.send(request); + maybe!(async { + let response = response.await?; + let stream = Arc::new(Mutex::new(response)); + let resource = self.table.push(stream)?; + Ok(resource) + }) + .await + .to_wasmtime_result() + } +} + +impl http_client::HostHttpResponseStream for WasmState { + async fn next_chunk( + &mut self, + resource: Resource, + ) -> wasmtime::Result>, String>> { + let stream = self.table.get(&resource)?.clone(); + maybe!(async move { + let mut response = stream.lock().await; + let mut buffer = vec![0; 8192]; // 8KB buffer + let bytes_read = response.body_mut().read(&mut buffer).await?; + if bytes_read == 0 { + Ok(None) + } else { + buffer.truncate(bytes_read); + Ok(Some(buffer)) + } + }) + .await + .to_wasmtime_result() + } + + async fn drop(&mut self, _resource: Resource) -> Result<()> { + Ok(()) + } +} + +impl From for ::http_client::Method { + fn from(value: http_client::HttpMethod) -> Self { + match value { + http_client::HttpMethod::Get => Self::GET, + http_client::HttpMethod::Post => Self::POST, + http_client::HttpMethod::Put => Self::PUT, + http_client::HttpMethod::Delete => Self::DELETE, + http_client::HttpMethod::Head => Self::HEAD, + http_client::HttpMethod::Options => Self::OPTIONS, + http_client::HttpMethod::Patch => Self::PATCH, + } + } +} + +fn convert_request( + extension_request: &http_client::HttpRequest, +) -> anyhow::Result<::http_client::Request> { + let mut request = ::http_client::Request::builder() + .method(::http_client::Method::from(extension_request.method)) + .uri(&extension_request.url) + .follow_redirects(match extension_request.redirect_policy { + http_client::RedirectPolicy::NoFollow => ::http_client::RedirectPolicy::NoFollow, + http_client::RedirectPolicy::FollowLimit(limit) => { + ::http_client::RedirectPolicy::FollowLimit(limit) + } + http_client::RedirectPolicy::FollowAll => ::http_client::RedirectPolicy::FollowAll, + }); + for (key, value) in &extension_request.headers { + request = request.header(key, value); + } + let body = extension_request + .body + .clone() + .map(AsyncBody::from) + .unwrap_or_default(); + request.body(body).map_err(anyhow::Error::from) +} + +async fn convert_response( + response: &mut ::http_client::Response, +) -> anyhow::Result { + let mut extension_response = http_client::HttpResponse { + body: Vec::new(), + headers: Vec::new(), + }; + + for (key, value) in response.headers() { + extension_response + .headers + .push((key.to_string(), value.to_str().unwrap_or("").to_string())); + } + + response + .body_mut() + .read_to_end(&mut extension_response.body) + .await?; + + Ok(extension_response) +} + +impl nodejs::Host for WasmState { + async fn node_binary_path(&mut self) -> wasmtime::Result> { + self.host + .node_runtime + .binary_path() + .await + .map(|path| path.to_string_lossy().into_owned()) + .to_wasmtime_result() + } + + async fn npm_package_latest_version( + &mut self, + package_name: String, + ) -> wasmtime::Result> { + self.host + .node_runtime + .npm_package_latest_version(&package_name) + .await + .to_wasmtime_result() + } + + async fn npm_package_installed_version( + &mut self, + package_name: String, + ) -> wasmtime::Result, String>> { + self.host + .node_runtime + .npm_package_installed_version(&self.work_dir(), &package_name) + .await + .to_wasmtime_result() + } + + async fn npm_install_package( + &mut self, + package_name: String, + version: String, + ) -> wasmtime::Result> { + self.capability_granter + .grant_npm_install_package(&package_name)?; + + self.host + .node_runtime + .npm_install_packages(&self.work_dir(), &[(&package_name, &version)]) + .await + .to_wasmtime_result() + } +} + +#[async_trait] +impl lsp::Host for WasmState {} + +impl From<::http_client::github::GithubRelease> for github::GithubRelease { + fn from(value: ::http_client::github::GithubRelease) -> Self { + Self { + version: value.tag_name, + assets: value.assets.into_iter().map(Into::into).collect(), + } + } +} + +impl From<::http_client::github::GithubReleaseAsset> for github::GithubReleaseAsset { + fn from(value: ::http_client::github::GithubReleaseAsset) -> Self { + Self { + name: value.name, + download_url: value.browser_download_url, + } + } +} + +impl github::Host for WasmState { + async fn latest_github_release( + &mut self, + repo: String, + options: github::GithubReleaseOptions, + ) -> wasmtime::Result> { + maybe!(async { + let release = ::http_client::github::latest_github_release( + &repo, + options.require_assets, + options.pre_release, + self.host.http_client.clone(), + ) + .await?; + Ok(release.into()) + }) + .await + .to_wasmtime_result() + } + + async fn github_release_by_tag_name( + &mut self, + repo: String, + tag: String, + ) -> wasmtime::Result> { + maybe!(async { + let release = ::http_client::github::get_release_by_tag_name( + &repo, + &tag, + self.host.http_client.clone(), + ) + .await?; + Ok(release.into()) + }) + .await + .to_wasmtime_result() + } +} + +impl platform::Host for WasmState { + async fn current_platform(&mut self) -> Result<(platform::Os, platform::Architecture)> { + Ok(( + match env::consts::OS { + "macos" => platform::Os::Mac, + "linux" => platform::Os::Linux, + "windows" => platform::Os::Windows, + _ => panic!("unsupported os"), + }, + match env::consts::ARCH { + "aarch64" => platform::Architecture::Aarch64, + "x86" => platform::Architecture::X86, + "x86_64" => platform::Architecture::X8664, + _ => panic!("unsupported architecture"), + }, + )) + } +} + +impl From for process::Output { + fn from(output: std::process::Output) -> Self { + Self { + status: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + } + } +} + +impl process::Host for WasmState { + async fn run_command( + &mut self, + command: process::Command, + ) -> wasmtime::Result> { + maybe!(async { + self.capability_granter + .grant_exec(&command.command, &command.args)?; + + let output = util::command::new_smol_command(command.command.as_str()) + .args(&command.args) + .envs(command.env) + .output() + .await?; + + Ok(output.into()) + }) + .await + .to_wasmtime_result() + } +} + +#[async_trait] +impl slash_command::Host for WasmState {} + +#[async_trait] +impl context_server::Host for WasmState {} + +impl dap::Host for WasmState { + async fn resolve_tcp_template( + &mut self, + template: TcpArgumentsTemplate, + ) -> wasmtime::Result> { + maybe!(async { + let (host, port, timeout) = + ::dap::configure_tcp_connection(task::TcpArgumentsTemplate { + port: template.port, + host: template.host.map(Ipv4Addr::from_bits), + timeout: template.timeout, + }) + .await?; + Ok(TcpArguments { + port, + host: host.to_bits(), + timeout, + }) + }) + .await + .to_wasmtime_result() + } +} + +impl ExtensionImports for WasmState { + async fn get_settings( + &mut self, + location: Option, + category: String, + key: Option, + ) -> wasmtime::Result> { + self.on_main_thread(|cx| { + async move { + let path = location.as_ref().and_then(|location| { + RelPath::new(Path::new(&location.path), PathStyle::Posix).ok() + }); + let location = path + .as_ref() + .zip(location.as_ref()) + .map(|(path, location)| ::settings::SettingsLocation { + worktree_id: WorktreeId::from_proto(location.worktree_id), + path, + }); + + cx.update(|cx| match category.as_str() { + "language" => { + let key = key.map(|k| LanguageName::new(&k)); + let settings = AllLanguageSettings::get(location, cx).language( + location, + key.as_ref(), + cx, + ); + Ok(serde_json::to_string(&settings::LanguageSettings { + tab_size: settings.tab_size, + })?) + } + "lsp" => { + let settings = key + .and_then(|key| { + ProjectSettings::get(location, cx) + .lsp + .get(&::lsp::LanguageServerName::from_proto(key)) + }) + .cloned() + .unwrap_or_default(); + Ok(serde_json::to_string(&settings::LspSettings { + binary: settings.binary.map(|binary| settings::CommandSettings { + path: binary.path, + arguments: binary.arguments, + env: binary.env.map(|env| env.into_iter().collect()), + }), + settings: settings.settings, + initialization_options: settings.initialization_options, + })?) + } + "context_servers" => { + let settings = key + .and_then(|key| { + ProjectSettings::get(location, cx) + .context_servers + .get(key.as_str()) + }) + .cloned() + .unwrap_or_else(|| { + project::project_settings::ContextServerSettings::default_extension( + ) + }); + + match settings { + project::project_settings::ContextServerSettings::Stdio { + enabled: _, + command, + } => Ok(serde_json::to_string(&settings::ContextServerSettings { + command: Some(settings::CommandSettings { + path: command.path.to_str().map(|path| path.to_string()), + arguments: Some(command.args), + env: command.env.map(|env| env.into_iter().collect()), + }), + settings: None, + })?), + project::project_settings::ContextServerSettings::Extension { + enabled: _, + settings, + } => Ok(serde_json::to_string(&settings::ContextServerSettings { + command: None, + settings: Some(settings), + })?), + project::project_settings::ContextServerSettings::Http { .. } => { + bail!("remote context server settings not supported in 0.6.0") + } + } + } + _ => { + bail!("Unknown settings category: {}", category); + } + }) + } + .boxed_local() + }) + .await? + .to_wasmtime_result() + } + + async fn set_language_server_installation_status( + &mut self, + server_name: String, + status: LanguageServerInstallationStatus, + ) -> wasmtime::Result<()> { + let status = match status { + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, + LanguageServerInstallationStatus::None => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, + }; + + self.host + .proxy + .update_language_server_status(::lsp::LanguageServerName(server_name.into()), status); + + Ok(()) + } + + async fn download_file( + &mut self, + url: String, + path: String, + file_type: DownloadedFileType, + ) -> wasmtime::Result> { + maybe!(async { + let parsed_url = Url::parse(&url)?; + self.capability_granter.grant_download_file(&parsed_url)?; + + let path = PathBuf::from(path); + let extension_work_dir = self.host.work_dir.join(self.manifest.id.as_ref()); + + self.host.fs.create_dir(&extension_work_dir).await?; + + let destination_path = self + .host + .writeable_path_from_extension(&self.manifest.id, &path)?; + + let mut response = self + .host + .http_client + .get(&url, Default::default(), true) + .await + .context("downloading release")?; + + anyhow::ensure!( + response.status().is_success(), + "download failed with status {}", + response.status() + ); + let body = BufReader::new(response.body_mut()); + + match file_type { + DownloadedFileType::Uncompressed => { + futures::pin_mut!(body); + self.host + .fs + .create_file_with(&destination_path, body) + .await?; + } + DownloadedFileType::Gzip => { + let body = GzipDecoder::new(body); + futures::pin_mut!(body); + self.host + .fs + .create_file_with(&destination_path, body) + .await?; + } + DownloadedFileType::GzipTar => { + let body = GzipDecoder::new(body); + futures::pin_mut!(body); + self.host + .fs + .extract_tar_file(&destination_path, Archive::new(body)) + .await?; + } + DownloadedFileType::Zip => { + futures::pin_mut!(body); + extract_zip(&destination_path, body) + .await + .with_context(|| format!("unzipping {path:?} archive"))?; + } + } + + Ok(()) + }) + .await + .to_wasmtime_result() + } + + async fn make_file_executable(&mut self, path: String) -> wasmtime::Result> { + let path = self + .host + .writeable_path_from_extension(&self.manifest.id, Path::new(&path))?; + + make_file_executable(&path) + .await + .with_context(|| format!("setting permissions for path {path:?}")) + .to_wasmtime_result() + } +} From bcf9142bbcaf53c121251467423cc70660218d85 Mon Sep 17 00:00:00 2001 From: Dino Date: Tue, 2 Dec 2025 22:29:48 +0000 Subject: [PATCH 16/67] Update tree-sitter-bash to 0.25.1 (#44009) With the merging and publishing of https://github.com/tree-sitter/tree-sitter-bash/pull/311 , we can now go ahead and update the version of `tree-sitter-bash` that Zed relies on to the latest version. Closes #42091 Release Notes: - Improved grammar for "Shell Script" --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f4813a3ee430462221732b3f6145170cada155b..eb77b9edfb7bd358c414d3bf9b1f8aec6a05f539 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18007,9 +18007,9 @@ dependencies = [ [[package]] name = "tree-sitter-bash" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "871b0606e667e98a1237ebdc1b0d7056e0aebfdc3141d12b399865d4cb6ed8a6" +checksum = "9e5ec769279cc91b561d3df0d8a5deb26b0ad40d183127f409494d6d8fc53062" dependencies = [ "cc", "tree-sitter-language", diff --git a/Cargo.toml b/Cargo.toml index b3e77414fe511445a73d3341b53ab8f8f589d884..e73e0108f2726c1223a64ba0221c41d8b4394262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -672,7 +672,7 @@ toml = "0.8" toml_edit = { version = "0.22", default-features = false, features = ["display", "parse", "serde"] } tower-http = "0.4.4" tree-sitter = { version = "0.25.10", features = ["wasm"] } -tree-sitter-bash = "0.25.0" +tree-sitter-bash = "0.25.1" tree-sitter-c = "0.23" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" From 22bf449b9e1d605e66e5ebb32588a2def8b6b478 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 2 Dec 2025 19:30:18 -0300 Subject: [PATCH 17/67] settings_ui: Fix some non-title case settings items (#44026) Release Notes: - N/A --- crates/settings_ui/src/page_data.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index fd1bbbcc6e0be6abcfbbdeeb85c0c33203db5ee1..1525271a39776f4b8b456244f40e3dfbc43cbaac 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -6526,7 +6526,7 @@ fn language_settings_data() -> Vec { files: USER | PROJECT, }), SettingsPageItem::SettingItem(SettingItem { - title: "Jsx Tag Auto Close", + title: "JSX Tag Auto Close", description: "Whether to automatically close JSX tags.", field: Box::new(SettingField { json_path: Some("languages.$(language).jsx_tag_auto_close"), @@ -7053,7 +7053,7 @@ fn language_settings_data() -> Vec { files: USER | PROJECT, }), SettingsPageItem::SettingItem(SettingItem { - title: "Colorize brackets", + title: "Colorize Brackets", description: "Whether to colorize brackets in the editor.", field: Box::new(SettingField { json_path: Some("languages.$(language).colorize_brackets"), From 8a12ecf8491663af8e030a6f2ef3e4203239de1b Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Tue, 2 Dec 2025 19:30:43 -0300 Subject: [PATCH 18/67] commit view: Display message within editor (#44024) #42441 moved the commit message out of the multi-buffer editor into its own header element which looks nicer, but unfortunately can make the view become unusable when the commit message is too long since it doesn't scroll with the diff. This PR maintains the metadata in its own element, but moves the commit message back to the editor so the user can scroll past it. This does mean that we lose markdown rendering for now, but we think this is a good solution for the moment. https://github.com/user-attachments/assets/d67cf22e-1a79-451a-932a-cdc8a65e43de Release Notes: - N/A --------- Co-authored-by: cameron --- crates/editor/src/editor.rs | 4 + crates/git_ui/src/commit_view.rs | 303 +++++++++---------------------- 2 files changed, 94 insertions(+), 213 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index babedf1e0829bb1105b2c9c3787d98aa662eedde..6f936a211d3b5eb308b26e4351350666e616bf6c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -22598,6 +22598,10 @@ impl Editor { } } + pub fn last_gutter_dimensions(&self) -> &GutterDimensions { + &self.gutter_dimensions + } + pub fn wait_for_diff_to_load(&self) -> Option>> { self.load_diff_task.clone() } diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 60060a389eea47e8bbcde19b43c823d49f27091e..4f6633a18c031b8f231f43f8b0efc13e7fd710a7 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1,19 +1,18 @@ use anyhow::{Context as _, Result}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; -use editor::{Addon, Editor, EditorEvent, MultiBuffer}; +use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle}; +use editor::{Addon, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer}; use git::repository::{CommitDetails, CommitDiff, RepoPath}; use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ AnyElement, App, AppContext as _, Asset, AsyncApp, AsyncWindowContext, Context, Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, - PromptLevel, Render, Styled, Task, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, - actions, px, + PromptLevel, Render, Styled, Task, WeakEntity, Window, actions, }; use language::{ - Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, TextBuffer, - ToPoint, + Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, + TextBuffer, ToPoint, }; -use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use multi_buffer::ExcerptInfo; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; @@ -63,7 +62,6 @@ pub struct CommitView { multibuffer: Entity, repository: Entity, remote: Option, - markdown: Entity, } struct GitBlob { @@ -167,6 +165,8 @@ impl CommitView { .map(|worktree| worktree.read(cx).id()); let repository_clone = repository.clone(); + let commit_message = commit.message.clone(); + cx.spawn(async move |this, cx| { for file in commit_diff.files { let is_deleted = file.new_text.is_none(); @@ -227,6 +227,58 @@ impl CommitView { }); })?; } + + let message_buffer = cx.new(|cx| { + let mut buffer = Buffer::local(commit_message, cx); + buffer.set_capability(Capability::ReadOnly, cx); + buffer + })?; + + this.update(cx, |this, cx| { + this.multibuffer.update(cx, |multibuffer, cx| { + let range = ExcerptRange { + context: Anchor::MIN..Anchor::MAX, + primary: Anchor::MIN..Anchor::MAX, + }; + multibuffer.insert_excerpts_after( + ExcerptId::min(), + message_buffer.clone(), + [range], + cx, + ) + }); + + this.editor.update(cx, |editor, cx| { + editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx); + + editor.insert_blocks( + [BlockProperties { + placement: BlockPlacement::Above(editor::Anchor::min()), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }] + .into_iter() + .chain( + editor + .buffer() + .read(cx) + .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx) + .map(|anchor| BlockProperties { + placement: BlockPlacement::Below(anchor), + height: Some(1), + style: BlockStyle::Sticky, + render: Arc::new(|_| gpui::Empty.into_any_element()), + priority: 0, + }), + ), + None, + cx, + ) + }); + })?; + anyhow::Ok(()) }) .detach(); @@ -246,14 +298,6 @@ impl CommitView { }) }); - let processed_message = if let Some(ref remote) = remote { - Self::process_github_issues(&commit.message, remote) - } else { - commit.message.to_string() - }; - - let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); - Self { commit, editor, @@ -261,18 +305,9 @@ impl CommitView { stash, repository, remote, - markdown, } } - fn fallback_commit_avatar() -> AnyElement { - Icon::new(IconName::Person) - .color(Color::Muted) - .size(IconSize::Medium) - .into_element() - .into_any() - } - fn render_commit_avatar( &self, sha: &SharedString, @@ -280,21 +315,34 @@ impl CommitView { window: &mut Window, cx: &mut App, ) -> AnyElement { + let size = size.into(); let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars()); if let Some(remote) = remote { let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone()); if let Some(Some(url)) = window.use_asset::(&avatar_asset, cx) { - Avatar::new(url.to_string()) + return Avatar::new(url.to_string()) .size(size) .into_element() - .into_any() - } else { - Self::fallback_commit_avatar() + .into_any(); } - } else { - Self::fallback_commit_avatar() } + + v_flex() + .w(size) + .h(size) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_full() + .justify_center() + .items_center() + .child( + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::Medium) + .into_element(), + ) + .into_any() } fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -322,14 +370,24 @@ impl CommitView { v_flex() .p_4() + .pl_0() .gap_4() .border_b_1() .border_color(cx.theme().colors().border) .child( h_flex() .items_start() - .gap_3() - .child(self.render_commit_avatar(&commit.sha, gpui::rems(3.0), window, cx)) + .child( + h_flex() + .w(self.editor.read(cx).last_gutter_dimensions().full_width()) + .justify_center() + .child(self.render_commit_avatar( + &commit.sha, + gpui::rems(3.0), + window, + cx, + )), + ) .child( v_flex() .gap_1() @@ -353,66 +411,6 @@ impl CommitView { .on_click(move |_, _, cx| cx.open_url(&url)) })), ) - .child(self.render_commit_message(window, cx)) - } - - fn process_github_issues(message: &str, remote: &GitRemote) -> String { - let mut result = String::new(); - let chars: Vec = message.chars().collect(); - let mut i = 0; - - while i < chars.len() { - if chars[i] == '#' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() { - let mut j = i + 1; - while j < chars.len() && chars[j].is_ascii_digit() { - j += 1; - } - let issue_number = &message[i + 1..i + (j - i)]; - let url = format!( - "{}/{}/{}/issues/{}", - remote.host.base_url().as_str().trim_end_matches('/'), - remote.owner, - remote.repo, - issue_number - ); - result.push_str(&format!("[#{}]({})", issue_number, url)); - i = j; - } else if i + 3 < chars.len() - && chars[i] == 'G' - && chars[i + 1] == 'H' - && chars[i + 2] == '-' - && chars[i + 3].is_ascii_digit() - { - let mut j = i + 3; - while j < chars.len() && chars[j].is_ascii_digit() { - j += 1; - } - let issue_number = &message[i + 3..i + (j - i)]; - let url = format!( - "{}/{}/{}/issues/{}", - remote.host.base_url().as_str().trim_end_matches('/'), - remote.owner, - remote.repo, - issue_number - ); - result.push_str(&format!("[GH-{}]({})", issue_number, url)); - i = j; - } else { - result.push(chars[i]); - i += 1; - } - } - - result - } - - fn render_commit_message( - &self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let style = hover_markdown_style(window, cx); - MarkdownElement::new(self.markdown.clone(), style) } fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { @@ -963,12 +961,6 @@ impl Item for CommitView { .update(cx, |editor, cx| editor.clone(window, cx)) }); let multibuffer = editor.read(cx).buffer().clone(); - let processed_message = if let Some(ref remote) = self.remote { - Self::process_github_issues(&self.commit.message, remote) - } else { - self.commit.message.to_string() - }; - let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); Self { editor, multibuffer, @@ -976,7 +968,6 @@ impl Item for CommitView { stash: self.stash, repository: self.repository.clone(), remote: self.remote.clone(), - markdown, } }))) } @@ -1046,117 +1037,3 @@ fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool .map(|entry| entry.oid.to_string() == sha) .unwrap_or(false) } - -fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { - let colors = cx.theme().colors(); - let mut style = MarkdownStyle::default(); - style.base_text_style = window.text_style(); - style.syntax = cx.theme().syntax().clone(); - style.selection_background_color = colors.element_selection_background; - style.link = TextStyleRefinement { - color: Some(colors.text_accent), - underline: Some(UnderlineStyle { - thickness: px(1.0), - color: Some(colors.text_accent), - wavy: false, - }), - ..Default::default() - }; - style -} - -#[cfg(test)] -mod tests { - use super::*; - use git_hosting_providers::Github; - - fn create_test_remote() -> GitRemote { - GitRemote { - host: Arc::new(Github::public_instance()), - owner: "zed-industries".into(), - repo: "zed".into(), - } - } - - #[test] - fn test_process_github_issues_simple_issue_number() { - let remote = create_test_remote(); - let message = "Fix bug #123"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix bug [#123](https://github.com/zed-industries/zed/issues/123)" - ); - } - - #[test] - fn test_process_github_issues_multiple_issue_numbers() { - let remote = create_test_remote(); - let message = "Fix #123 and #456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [#456](https://github.com/zed-industries/zed/issues/456)" - ); - } - - #[test] - fn test_process_github_issues_gh_format() { - let remote = create_test_remote(); - let message = "Fix GH-789"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [GH-789](https://github.com/zed-industries/zed/issues/789)" - ); - } - - #[test] - fn test_process_github_issues_mixed_formats() { - let remote = create_test_remote(); - let message = "Fix #123 and GH-456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [GH-456](https://github.com/zed-industries/zed/issues/456)" - ); - } - - #[test] - fn test_process_github_issues_no_issues() { - let remote = create_test_remote(); - let message = "This is a commit message without any issues"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!(result, message); - } - - #[test] - fn test_process_github_issues_hash_without_number() { - let remote = create_test_remote(); - let message = "Use # for comments"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!(result, message); - } - - #[test] - fn test_process_github_issues_consecutive_issues() { - let remote = create_test_remote(); - let message = "#123#456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "[#123](https://github.com/zed-industries/zed/issues/123)[#456](https://github.com/zed-industries/zed/issues/456)" - ); - } - - #[test] - fn test_process_github_issues_multiline() { - let remote = create_test_remote(); - let message = "Fix #123\n\nThis also fixes #456"; - let result = CommitView::process_github_issues(message, &remote); - assert_eq!( - result, - "Fix [#123](https://github.com/zed-industries/zed/issues/123)\n\nThis also fixes [#456](https://github.com/zed-industries/zed/issues/456)" - ); - } -} From b4e1d86a1631f37026f201ce64fbaaa54af2de44 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Tue, 2 Dec 2025 23:44:03 +0100 Subject: [PATCH 19/67] git: Use UI font in commit and blame popovers (#43975) Closes #30353 Release Notes: - Fixed: Hover tooltips in git commit and blame popovers now consistently use the UI font --- crates/git_ui/src/blame_ui.rs | 3 --- crates/git_ui/src/commit_tooltip.rs | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index d3f89831898c4ef3e3fa5c088d0094c0efa6e8b5..47703e09824a49c633798c7967652d7f48f821be 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -198,9 +198,6 @@ impl BlameRenderer for GitBlameRenderer { let link_color = cx.theme().colors().text_accent; let markdown_style = { let mut style = hover_markdown_style(window, cx); - if let Some(code_block) = &style.code_block.text { - style.base_text_style.refine(code_block); - } style.link.refine(&TextStyleRefinement { color: Some(link_color), underline: Some(UnderlineStyle { diff --git a/crates/git_ui/src/commit_tooltip.rs b/crates/git_ui/src/commit_tooltip.rs index 26bd42c6549457df0f530580bbfc838797134860..6dfe92427df5b9fd5aa051aeb1635b2e782ad3a4 100644 --- a/crates/git_ui/src/commit_tooltip.rs +++ b/crates/git_ui/src/commit_tooltip.rs @@ -197,10 +197,7 @@ impl Render for CommitTooltip { time_format::TimestampFormat::MediumAbsolute, ); let markdown_style = { - let mut style = hover_markdown_style(window, cx); - if let Some(code_block) = &style.code_block.text { - style.base_text_style.refine(code_block); - } + let style = hover_markdown_style(window, cx); style }; From 39536cae83756ba36bfb334b8b7429bd04ddb584 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 2 Dec 2025 17:44:22 -0500 Subject: [PATCH 20/67] docs: Add Conda package to Linux community-maintained packages list (#44029) Release Notes: - N/A --- docs/src/linux.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/src/linux.md b/docs/src/linux.md index 715b3a1bab4b6d580886207b50f54b741f72e5c2..b535a5e78a8c82892602f016ecea1b333447a0c9 100644 --- a/docs/src/linux.md +++ b/docs/src/linux.md @@ -41,6 +41,7 @@ There are several third-party Zed packages for various Linux distributions and p - Arch: [`zed`](https://archlinux.org/packages/extra/x86_64/zed/) - Arch (AUR): [`zed-git`](https://aur.archlinux.org/packages/zed-git), [`zed-preview`](https://aur.archlinux.org/packages/zed-preview), [`zed-preview-bin`](https://aur.archlinux.org/packages/zed-preview-bin) - Alpine: `zed` ([aarch64](https://pkgs.alpinelinux.org/package/edge/testing/aarch64/zed)) ([x86_64](https://pkgs.alpinelinux.org/package/edge/testing/x86_64/zed)) +- Conda: [`zed`](https://anaconda.org/conda-forge/zed) - Nix: `zed-editor` ([unstable](https://search.nixos.org/packages?channel=unstable&show=zed-editor)) - Fedora/Ultramarine (Terra): [`zed`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/stable), [`zed-preview`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/preview), [`zed-nightly`](https://github.com/terrapkg/packages/tree/frawhide/anda/devs/zed/nightly) - Solus: [`zed`](https://github.com/getsolus/packages/tree/main/packages/z/zed) From 98dec9246e10d7d7606e676c4e44be15fa490de3 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 2 Dec 2025 18:28:30 -0500 Subject: [PATCH 21/67] zed: Promote comment to a doc comment (#44031) This PR promotes a line comment above a variant member to a doc comment, so that the docs show up on hover. Release Notes: - N/A --- crates/zed/src/zed/open_listener.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 13b636731798ebe13bb7c9ae8d97bf52356ea0b2..5e855aa5a949254ba32658c26a59c48c7413844e 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -54,7 +54,7 @@ pub enum OpenRequestKind { schema_path: String, }, Setting { - // None just opens settings without navigating to a specific path + /// `None` opens settings without navigating to a specific path. setting_path: Option, }, } From 65b4e9b10ac0bf588f5b90fb65e500e27ae7123d Mon Sep 17 00:00:00 2001 From: Rani Malach <137104974+Rani367@users.noreply.github.com> Date: Wed, 3 Dec 2025 01:44:08 +0200 Subject: [PATCH 22/67] extensions_ui: Add upsell banners for integrated extensions (#43872) Add informational banners for extensions that have been integrated into Zed core: - Basedpyright (Python language server) - Ruff (Python linter) - Ty (Python language server) These banners appear when users search for these extensions, informing them that the functionality is now built-in and linking to relevant documentation. The banners trigger when: - Users search by extension ID (e.g., 'id:ruff') - Users search using relevant keywords (e.g., 'basedpyright', 'pyright', 'ruff', 'ty') Supersedes #43844 Closes #43837 Release Notes: - Added banners to the extensions page when searching for Basedpyright, Ruff, or Ty, indicating that these features are now built-in. --------- Co-authored-by: Marshall Bowers --- crates/extensions_ui/src/extensions_ui.rs | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index e6d30527e0d7672255bf8f61cfd56fe06b409920..11a5d1797a7173a9b5d23e2eae19bf028f37d7ed 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -229,8 +229,10 @@ enum Feature { AgentClaude, AgentCodex, AgentGemini, + ExtensionBasedpyright, ExtensionRuff, ExtensionTailwind, + ExtensionTy, Git, LanguageBash, LanguageC, @@ -251,8 +253,13 @@ fn keywords_by_feature() -> &'static BTreeMap> { (Feature::AgentClaude, vec!["claude", "claude code"]), (Feature::AgentCodex, vec!["codex", "codex cli"]), (Feature::AgentGemini, vec!["gemini", "gemini cli"]), + ( + Feature::ExtensionBasedpyright, + vec!["basedpyright", "pyright"], + ), (Feature::ExtensionRuff, vec!["ruff"]), (Feature::ExtensionTailwind, vec!["tail", "tailwind"]), + (Feature::ExtensionTy, vec!["ty"]), (Feature::Git, vec!["git"]), (Feature::LanguageBash, vec!["sh", "bash"]), (Feature::LanguageC, vec!["c", "clang"]), @@ -1364,6 +1371,23 @@ impl ExtensionsPage { return; }; + if let Some(id) = search.strip_prefix("id:") { + self.upsells.clear(); + + let upsell = match id.to_lowercase().as_str() { + "ruff" => Some(Feature::ExtensionRuff), + "basedpyright" => Some(Feature::ExtensionBasedpyright), + "ty" => Some(Feature::ExtensionTy), + _ => None, + }; + + if let Some(upsell) = upsell { + self.upsells.insert(upsell); + } + + return; + } + let search = search.to_lowercase(); let search_terms = search .split_whitespace() @@ -1482,6 +1506,12 @@ impl ExtensionsPage { false, cx, ), + Feature::ExtensionBasedpyright => self.render_feature_upsell_banner( + "Basedpyright (Python language server) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python#basedpyright".into(), + false, + cx, + ), Feature::ExtensionRuff => self.render_feature_upsell_banner( "Ruff (linter for Python) support is built-in to Zed!".into(), "https://zed.dev/docs/languages/python#code-formatting--linting".into(), @@ -1494,6 +1524,12 @@ impl ExtensionsPage { false, cx, ), + Feature::ExtensionTy => self.render_feature_upsell_banner( + "Ty (Python language server) support is built-in to Zed!".into(), + "https://zed.dev/docs/languages/python".into(), + false, + cx, + ), Feature::Git => self.render_feature_upsell_banner( "Zed comes with basic Git support—more features are coming in the future." .into(), From 2bf47879dee6dc8c21613b83e058a1dd4b9bde29 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Tue, 2 Dec 2025 20:47:01 -0500 Subject: [PATCH 23/67] Hide "File History" for untracked files in Git Panel context menu (#44035) --- crates/git_ui/src/git_panel.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 092768c2cd97fa82079979301704ee66c969196e..bd17788506faa62f33618d4450000af1e7b8aec9 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4011,15 +4011,21 @@ impl GitPanel { if entry.status.is_created() { context_menu = - context_menu.action("Add to .gitignore", git::AddToGitignore.boxed_clone()); + context_menu.action("Add to .gitignore", git::AddToGitignore.boxed_clone()) } - context_menu + let mut context_menu = context_menu .separator() .action("Open Diff", Confirm.boxed_clone()) - .action("Open File", SecondaryConfirm.boxed_clone()) - .separator() - .action("File History", Box::new(git::FileHistory)) + .action("Open File", SecondaryConfirm.boxed_clone()); + + if !entry.status.is_created() { + context_menu = context_menu + .separator() + .action("File History", Box::new(git::FileHistory)); + } + + context_menu }); self.selected_entry = Some(ix); self.set_context_menu(context_menu, position, window, cx); From ad51017f20d8e3ae07e5127cceb2abebbba6ebe0 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 3 Dec 2025 10:27:55 +0200 Subject: [PATCH 24/67] Properly filter out the greedy bracket pairs (#44022) Follow-up of https://github.com/zed-industries/zed/pull/43607 Release Notes: - N/A --- crates/editor/src/bracket_colorization.rs | 13 ++ crates/language/src/buffer.rs | 153 +++++++++------------- 2 files changed, 78 insertions(+), 88 deletions(-) diff --git a/crates/editor/src/bracket_colorization.rs b/crates/editor/src/bracket_colorization.rs index 053ddbc002a95ee65ca34088310afb16a1141b82..e4933b3ad5d8a2cae80e882abaa2eb34dfd3a429 100644 --- a/crates/editor/src/bracket_colorization.rs +++ b/crates/editor/src/bracket_colorization.rs @@ -333,6 +333,19 @@ where &bracket_colors_markup(&mut cx), "All markdown brackets should be colored based on their depth" ); + + cx.set_state(indoc! {r#"ˇ{{}}"#}); + cx.executor().advance_clock(Duration::from_millis(100)); + cx.executor().run_until_parked(); + + assert_eq!( + r#"«1{«2{}2»}1» +1 hsla(207.80, 16.20%, 69.19%, 1.00) +2 hsla(29.00, 54.00%, 65.88%, 1.00) +"#, + &bracket_colors_markup(&mut cx), + "All markdown brackets should be colored based on their depth, again" + ); } #[gpui::test] diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 46d7f655627e1c01612b703a64bc0ab58d1b6669..c6eb3ff66b08b03f39466af4a8b65805003a8bd3 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -32,7 +32,6 @@ use gpui::{ Task, TaskLabel, TextStyle, }; -use itertools::Itertools; use lsp::{LanguageServerId, NumberOrString}; use parking_lot::{Mutex, RawMutex, lock_api::MutexGuard}; use serde::{Deserialize, Serialize}; @@ -45,7 +44,7 @@ use std::{ borrow::Cow, cell::Cell, cmp::{self, Ordering, Reverse}, - collections::{BTreeMap, BTreeSet, hash_map}, + collections::{BTreeMap, BTreeSet}, future::Future, iter::{self, Iterator, Peekable}, mem, @@ -4284,7 +4283,6 @@ impl BufferSnapshot { let mut new_bracket_matches = HashMap::default(); let mut all_bracket_matches = HashMap::default(); - let mut bracket_matches_to_color = HashMap::default(); for chunk in tree_sitter_data .chunks @@ -4301,7 +4299,10 @@ impl BufferSnapshot { let bracket_matches = match tree_sitter_data.brackets_by_chunks[chunk.id].take() { Some(cached_brackets) => cached_brackets, None => { - let mut bracket_pairs_ends = Vec::new(); + let mut all_brackets = Vec::new(); + let mut opens = Vec::new(); + let mut color_pairs = Vec::new(); + let mut matches = self.syntax .matches(chunk_range.clone(), &self.text, |grammar| { @@ -4313,100 +4314,76 @@ impl BufferSnapshot { .map(|grammar| grammar.brackets_config.as_ref().unwrap()) .collect::>(); - let chunk_range = chunk_range.clone(); - let tree_sitter_matches = iter::from_fn(|| { - while let Some(mat) = matches.peek() { - let mut open = None; - let mut close = None; - let depth = mat.depth; - let config = configs[mat.grammar_index]; - let pattern = &config.patterns[mat.pattern_index]; - for capture in mat.captures { - if capture.index == config.open_capture_ix { - open = Some(capture.node.byte_range()); - } else if capture.index == config.close_capture_ix { - close = Some(capture.node.byte_range()); - } + while let Some(mat) = matches.peek() { + let mut open = None; + let mut close = None; + let syntax_layer_depth = mat.depth; + let config = configs[mat.grammar_index]; + let pattern = &config.patterns[mat.pattern_index]; + for capture in mat.captures { + if capture.index == config.open_capture_ix { + open = Some(capture.node.byte_range()); + } else if capture.index == config.close_capture_ix { + close = Some(capture.node.byte_range()); } + } - matches.advance(); + matches.advance(); - let Some((open_range, close_range)) = open.zip(close) else { - continue; - }; + let Some((open_range, close_range)) = open.zip(close) else { + continue; + }; - let bracket_range = open_range.start..=close_range.end; - if !bracket_range.overlaps(&chunk_range) { - continue; - } + let bracket_range = open_range.start..=close_range.end; + if !bracket_range.overlaps(&chunk_range) { + continue; + } - if !pattern.rainbow_exclude - // Also, certain languages have "brackets" that are not brackets, e.g. tags. and such - // bracket will match the entire tag with all text inside. - // For now, avoid highlighting any pair that has more than single char in each bracket. - // We need to colorize `` bracket pairs, so cannot make this check stricter. - && (open_range.len() == 1 || close_range.len() == 1) - { - // Certain tree-sitter grammars may return more bracket pairs than needed: - // see `test_markdown_bracket_colorization` for a set-up that returns pairs with the same start bracket and different end one. - // Pick the pair with the shortest range in case of ambiguity. - match bracket_matches_to_color.entry(open_range.clone()) { - hash_map::Entry::Vacant(v) => { - v.insert(close_range.clone()); - } - hash_map::Entry::Occupied(mut o) => { - let previous_close_range = o.get(); - let previous_length = - previous_close_range.end - open_range.start; - let new_length = close_range.end - open_range.start; - if new_length < previous_length { - o.insert(close_range.clone()); - } - } - } - } - return Some((open_range, close_range, pattern, depth)); + let index = all_brackets.len(); + all_brackets.push(BracketMatch { + open_range: open_range.clone(), + close_range: close_range.clone(), + newline_only: pattern.newline_only, + syntax_layer_depth, + color_index: None, + }); + + // Certain languages have "brackets" that are not brackets, e.g. tags. and such + // bracket will match the entire tag with all text inside. + // For now, avoid highlighting any pair that has more than single char in each bracket. + // We need to colorize `` bracket pairs, so cannot make this check stricter. + let should_color = !pattern.rainbow_exclude + && (open_range.len() == 1 || close_range.len() == 1); + if should_color { + opens.push(open_range.clone()); + color_pairs.push((open_range, close_range, index)); } - None - }) - .sorted_by_key(|(open_range, _, _, _)| open_range.start) - .collect::>(); + } - let new_matches = tree_sitter_matches - .into_iter() - .map(|(open_range, close_range, pattern, syntax_layer_depth)| { - let participates_in_colorizing = - bracket_matches_to_color.get(&open_range).is_some_and( - |close_range_to_color| close_range_to_color == &close_range, - ); - let color_index = if participates_in_colorizing { - while let Some(&last_bracket_end) = bracket_pairs_ends.last() { - if last_bracket_end <= open_range.start { - bracket_pairs_ends.pop(); - } else { - break; - } - } + opens.sort_by_key(|r| (r.start, r.end)); + opens.dedup_by(|a, b| a.start == b.start && a.end == b.end); + color_pairs.sort_by_key(|(_, close, _)| close.end); - let bracket_depth = bracket_pairs_ends.len(); - bracket_pairs_ends.push(close_range.end); - Some(bracket_depth) - } else { - None - }; + let mut open_stack = Vec::new(); + let mut open_index = 0; + for (open, close, index) in color_pairs { + while open_index < opens.len() && opens[open_index].start < close.start { + open_stack.push(opens[open_index].clone()); + open_index += 1; + } - BracketMatch { - open_range, - close_range, - syntax_layer_depth, - newline_only: pattern.newline_only, - color_index, - } - }) - .collect::>(); + if open_stack.last() == Some(&open) { + let depth_index = open_stack.len() - 1; + all_brackets[index].color_index = Some(depth_index); + open_stack.pop(); + } + } - new_bracket_matches.insert(chunk.id, new_matches.clone()); - new_matches + all_brackets.sort_by_key(|bracket_match| { + (bracket_match.open_range.start, bracket_match.open_range.end) + }); + new_bracket_matches.insert(chunk.id, all_brackets.clone()); + all_brackets } }; all_bracket_matches.insert(chunk.row_range(), bracket_matches); From 9857fd233dc22cc2c44fc5c12d8d8620154d0a73 Mon Sep 17 00:00:00 2001 From: lipcut <96253127+lipcut@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:30:54 +0800 Subject: [PATCH 25/67] Make highlighting of C preprocessing directive same as C++ (#44043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small fix for consistency between C and C++ highlighting. Related to https://github.com/zed-industries/zed/issues/9461 Release Notes: - Change syntax highlighting for preprocessing directive in C so it can be configured with `keyword.directive` instead of being treated as other `keyword`. The behavior should be like the C++ one now. 圖片 --- crates/languages/src/c/highlights.scm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/languages/src/c/highlights.scm b/crates/languages/src/c/highlights.scm index 40e0d7147e98287f5ed7587d690e25bc8bacaa0b..46c970e69d97a232dc9d83aa6b9470de74f74833 100644 --- a/crates/languages/src/c/highlights.scm +++ b/crates/languages/src/c/highlights.scm @@ -36,7 +36,7 @@ "#ifndef" "#include" (preproc_directive) -] @keyword +] @keyword.directive [ "=" From 50d0f29624e8a0264fa65ee21733a6e1844e9934 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 3 Dec 2025 11:29:18 +0100 Subject: [PATCH 26/67] languages: Fix python run module task failing on windows (#44064) Fixes #40155 Release Notes: - Fixed python's run module task not working on windows platforms Co-authored by: Smit Barmase --- crates/languages/src/python.rs | 58 ++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 56512ee8af39df52283aa88d6885a192732ed020..bcdc7969b4f2b22f5136c733afd477f7d0cf0187 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -28,6 +28,7 @@ use std::env::consts; use terminal::terminal_settings::TerminalSettings; use util::command::new_smol_command; use util::fs::{make_file_executable, remove_matching}; +use util::paths::PathStyle; use util::rel_path::RelPath; use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; @@ -884,7 +885,7 @@ impl PythonContextProvider { variables: &task::TaskVariables, ) -> Option<(VariableName, String)> { let python_module_name = - python_module_name_from_relative_path(variables.get(&VariableName::RelativeFile)?); + python_module_name_from_relative_path(variables.get(&VariableName::RelativeFile)?)?; let unittest_class_name = variables.get(&VariableName::Custom(Cow::Borrowed("_unittest_class_name"))); @@ -941,9 +942,10 @@ impl PythonContextProvider { &self, variables: &task::TaskVariables, ) -> Result<(VariableName, String)> { - let python_module_name = python_module_name_from_relative_path( - variables.get(&VariableName::RelativeFile).unwrap_or(""), - ); + let python_module_name = variables + .get(&VariableName::RelativeFile) + .and_then(|module| python_module_name_from_relative_path(module)) + .unwrap_or_default(); let module_target = (PYTHON_MODULE_NAME_TASK_VARIABLE.clone(), python_module_name); @@ -951,12 +953,15 @@ impl PythonContextProvider { } } -fn python_module_name_from_relative_path(relative_path: &str) -> String { - let path_with_dots = relative_path.replace('/', "."); - path_with_dots - .strip_suffix(".py") - .unwrap_or(&path_with_dots) - .to_string() +fn python_module_name_from_relative_path(relative_path: &str) -> Option { + let rel_path = RelPath::new(relative_path.as_ref(), PathStyle::local()).ok()?; + let path_with_dots = rel_path.display(PathStyle::Posix).replace('/', "."); + Some( + path_with_dots + .strip_suffix(".py") + .map(ToOwned::to_owned) + .unwrap_or(path_with_dots), + ) } fn is_python_env_global(k: &PythonEnvironmentKind) -> bool { @@ -2311,6 +2316,8 @@ mod tests { use settings::SettingsStore; use std::num::NonZeroU32; + use crate::python::python_module_name_from_relative_path; + #[gpui::test] async fn test_python_autoindent(cx: &mut TestAppContext) { cx.executor().set_block_on_ticks(usize::MAX..=usize::MAX); @@ -2439,4 +2446,35 @@ mod tests { buffer }); } + + #[test] + fn test_python_module_name_from_relative_path() { + assert_eq!( + python_module_name_from_relative_path("foo/bar.py"), + Some("foo.bar".to_string()) + ); + assert_eq!( + python_module_name_from_relative_path("foo/bar"), + Some("foo.bar".to_string()) + ); + if cfg!(windows) { + assert_eq!( + python_module_name_from_relative_path("foo\\bar.py"), + Some("foo.bar".to_string()) + ); + assert_eq!( + python_module_name_from_relative_path("foo\\bar"), + Some("foo.bar".to_string()) + ); + } else { + assert_eq!( + python_module_name_from_relative_path("foo\\bar.py"), + Some("foo\\bar".to_string()) + ); + assert_eq!( + python_module_name_from_relative_path("foo\\bar"), + Some("foo\\bar".to_string()) + ); + } + } } From fe6fa1bbdce5ff89d5b9942f9a17a87d72557078 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 3 Dec 2025 12:11:23 +0100 Subject: [PATCH 27/67] Revert "acp: Add a timeout when initializing an ACP agent so the user isn't waiting forever" (#44066) Reverts zed-industries/zed#43663 --- crates/agent_ui/src/acp/thread_view.rs | 62 +------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index a9b4127ea97f62dde3cb2af299050bc0e06a06bc..9c4717c5189eb3397ec153560156d9c77e5125ba 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -498,17 +498,7 @@ impl AcpThreadView { Some(new_version_available_tx), ); - let agent_name = agent.name(); - let timeout = cx.background_executor().timer(Duration::from_secs(30)); - let connect_task = smol::future::or( - agent.connect(root_dir.as_deref(), delegate, cx), - async move { - timeout.await; - Err(anyhow::Error::new(LoadError::Other( - format!("{agent_name} is unable to initialize after 30 seconds.").into(), - ))) - }, - ); + let connect_task = agent.connect(root_dir.as_deref(), delegate, cx); let load_task = cx.spawn_in(window, async move |this, cx| { let connection = match connect_task.await { Ok((connection, login)) => { @@ -7399,54 +7389,4 @@ pub(crate) mod tests { assert_eq!(text, expected_txt); }) } - - #[gpui::test] - async fn test_initialize_timeout(cx: &mut TestAppContext) { - init_test(cx); - - struct InfiniteInitialize; - - impl AgentServer for InfiniteInitialize { - fn telemetry_id(&self) -> &'static str { - "test" - } - - fn logo(&self) -> ui::IconName { - ui::IconName::Ai - } - - fn name(&self) -> SharedString { - "Test".into() - } - - fn connect( - &self, - _root_dir: Option<&Path>, - _delegate: AgentServerDelegate, - cx: &mut App, - ) -> Task, Option)>> - { - cx.spawn(async |_| futures::future::pending().await) - } - - fn into_any(self: Rc) -> Rc { - self - } - } - - let (thread_view, cx) = setup_thread_view(InfiniteInitialize, cx).await; - - cx.executor().advance_clock(Duration::from_secs(31)); - cx.run_until_parked(); - - let error = thread_view.read_with(cx, |thread_view, _| match &thread_view.thread_state { - ThreadState::LoadError(err) => err.clone(), - _ => panic!("Incorrect thread state"), - }); - - match error { - LoadError::Other(str) => assert!(str.contains("initialize")), - _ => panic!("Unexpected load error"), - } - } } From 0f67f08795eb7e97ca4003eac7974b9d8bd965f9 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 3 Dec 2025 12:24:08 +0100 Subject: [PATCH 28/67] Update to ACP SDK v0.8.0 (#44063) Uses the latest version of the SDK + schema crate. A bit painful because we needed to move to `#[non_exhaustive]` on all of these structs/enums, but will be much easier going forward. Also, since we depend on unstable features, I am pinning the version so we don't accidentally introduce compilation errors from other update cycles. Release Notes: - N/A --- Cargo.lock | 9 +- Cargo.toml | 2 +- crates/acp_thread/src/acp_thread.rs | 420 +++++++------------- crates/acp_thread/src/connection.rs | 26 +- crates/acp_thread/src/mention.rs | 2 +- crates/acp_thread/src/terminal.rs | 44 +- crates/agent/src/agent.rs | 55 +-- crates/agent/src/db.rs | 2 +- crates/agent/src/history_store.rs | 6 +- crates/agent/src/tests/mod.rs | 100 ++--- crates/agent/src/thread.rs | 210 ++++------ crates/agent/src/tools/edit_file_tool.rs | 20 +- crates/agent/src/tools/find_path_tool.rs | 50 ++- crates/agent/src/tools/read_file_tool.rs | 22 +- crates/agent/src/tools/terminal_tool.rs | 7 +- crates/agent/src/tools/thinking_tool.rs | 6 +- crates/agent/src/tools/web_search_tool.rs | 47 +-- crates/agent_servers/src/acp.rs | 218 ++++------ crates/agent_servers/src/claude.rs | 4 +- crates/agent_servers/src/codex.rs | 4 +- crates/agent_servers/src/custom.rs | 4 +- crates/agent_servers/src/e2e_tests.rs | 25 +- crates/agent_ui/src/acp/entry_view_state.rs | 23 +- crates/agent_ui/src/acp/message_editor.rs | 186 +++------ crates/agent_ui/src/acp/model_selector.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 226 ++++------- crates/eval/src/example.rs | 13 +- crates/eval/src/instance.rs | 7 +- 28 files changed, 631 insertions(+), 1109 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb77b9edfb7bd358c414d3bf9b1f8aec6a05f539..3c535c27415b776e3b4210a236f39a6f6d376954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,9 +215,9 @@ dependencies = [ [[package]] name = "agent-client-protocol" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525705e39c11cd73f7bc784e3681a9386aa30c8d0630808d3dc2237eb4f9cb1b" +checksum = "3e639d6b544ad39f5b4e05802db5eb04e1518284eb05fda1839931003e0244c8" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -226,16 +226,15 @@ dependencies = [ "derive_more 2.0.1", "futures 0.3.31", "log", - "parking_lot", "serde", "serde_json", ] [[package]] name = "agent-client-protocol-schema" -version = "0.6.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecf16c18fea41282d6bbadd1549a06be6836bddb1893f44a6235f340fa24e2af" +checksum = "f182f5e14bef8232b239719bd99166bb11e986c08fc211f28e392f880d3093ba" dependencies = [ "anyhow", "derive_more 2.0.1", diff --git a/Cargo.toml b/Cargo.toml index e73e0108f2726c1223a64ba0221c41d8b4394262..6cd80981ce62a245310e6e1a1d447bdc804aa32a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -439,7 +439,7 @@ zlog_settings = { path = "crates/zlog_settings" } # External crates # -agent-client-protocol = { version = "0.7.0", features = ["unstable"] } +agent-client-protocol = { version = "=0.8.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = "0.25.1-rc1" any_vec = "0.14" diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a42eaa491f7f98e9965cd3aba801690ed996a39a..9c7590ccd6c5871c4db72b89eff344b3eca877a7 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -201,17 +201,19 @@ impl ToolCall { }; let mut content = Vec::with_capacity(tool_call.content.len()); for item in tool_call.content { - content.push(ToolCallContent::from_acp( + if let Some(item) = ToolCallContent::from_acp( item, language_registry.clone(), path_style, terminals, cx, - )?); + )? { + content.push(item); + } } let result = Self { - id: tool_call.id, + id: tool_call.tool_call_id, label: cx .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), kind: tool_call.kind, @@ -241,6 +243,7 @@ impl ToolCall { locations, raw_input, raw_output, + .. } = fields; if let Some(kind) = kind { @@ -262,21 +265,29 @@ impl ToolCall { } if let Some(content) = content { - let new_content_len = content.len(); + let mut new_content_len = content.len(); let mut content = content.into_iter(); // Reuse existing content if we can for (old, new) in self.content.iter_mut().zip(content.by_ref()) { - old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + let valid_content = + old.update_from_acp(new, language_registry.clone(), path_style, terminals, cx)?; + if !valid_content { + new_content_len -= 1; + } } for new in content { - self.content.push(ToolCallContent::from_acp( + if let Some(new) = ToolCallContent::from_acp( new, language_registry.clone(), path_style, terminals, cx, - )?) + )? { + self.content.push(new); + } else { + new_content_len -= 1; + } } self.content.truncate(new_content_len); } @@ -425,6 +436,7 @@ impl From for ToolCallStatus { acp::ToolCallStatus::InProgress => Self::InProgress, acp::ToolCallStatus::Completed => Self::Completed, acp::ToolCallStatus::Failed => Self::Failed, + _ => Self::Pending, } } } @@ -537,7 +549,7 @@ impl ContentBlock { .. }) => Self::resource_link_md(&uri, path_style), acp::ContentBlock::Image(image) => Self::image_md(&image), - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(), + _ => String::new(), } } @@ -591,15 +603,17 @@ impl ToolCallContent { path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result { + ) -> Result> { match content { - acp::ToolCallContent::Content { content } => Ok(Self::ContentBlock(ContentBlock::new( - content, - &language_registry, - path_style, - cx, - ))), - acp::ToolCallContent::Diff { diff } => Ok(Self::Diff(cx.new(|cx| { + acp::ToolCallContent::Content(acp::Content { content, .. }) => { + Ok(Some(Self::ContentBlock(ContentBlock::new( + content, + &language_registry, + path_style, + cx, + )))) + } + acp::ToolCallContent::Diff(diff) => Ok(Some(Self::Diff(cx.new(|cx| { Diff::finalized( diff.path.to_string_lossy().into_owned(), diff.old_text, @@ -607,12 +621,13 @@ impl ToolCallContent { language_registry, cx, ) - }))), - acp::ToolCallContent::Terminal { terminal_id } => terminals + })))), + acp::ToolCallContent::Terminal(acp::Terminal { terminal_id, .. }) => terminals .get(&terminal_id) .cloned() - .map(Self::Terminal) + .map(|terminal| Some(Self::Terminal(terminal))) .ok_or_else(|| anyhow::anyhow!("Terminal with id `{}` not found", terminal_id)), + _ => Ok(None), } } @@ -623,9 +638,9 @@ impl ToolCallContent { path_style: PathStyle, terminals: &HashMap>, cx: &mut App, - ) -> Result<()> { + ) -> Result { let needs_update = match (&self, &new) { - (Self::Diff(old_diff), acp::ToolCallContent::Diff { diff: new_diff }) => { + (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => { old_diff.read(cx).needs_update( new_diff.old_text.as_deref().unwrap_or(""), &new_diff.new_text, @@ -635,10 +650,14 @@ impl ToolCallContent { _ => true, }; - if needs_update { - *self = Self::from_acp(new, language_registry, path_style, terminals, cx)?; + if let Some(update) = Self::from_acp(new, language_registry, path_style, terminals, cx)? { + if needs_update { + *self = update; + } + Ok(true) + } else { + Ok(false) } - Ok(()) } pub fn to_markdown(&self, cx: &App) -> String { @@ -660,7 +679,7 @@ pub enum ToolCallUpdate { impl ToolCallUpdate { fn id(&self) -> &acp::ToolCallId { match self { - Self::UpdateFields(update) => &update.id, + Self::UpdateFields(update) => &update.tool_call_id, Self::UpdateDiff(diff) => &diff.id, Self::UpdateTerminal(terminal) => &terminal.id, } @@ -732,6 +751,7 @@ impl Plan { acp::PlanEntryStatus::Completed => { stats.completed += 1; } + _ => {} } } @@ -1154,6 +1174,7 @@ impl AcpThread { current_mode_id, .. }) => cx.emit(AcpThreadEvent::ModeUpdated(current_mode_id)), + _ => {} } Ok(()) } @@ -1287,11 +1308,7 @@ impl AcpThread { label: cx.new(|cx| Markdown::new("Tool call not found".into(), None, None, cx)), kind: acp::ToolKind::Fetch, content: vec![ToolCallContent::ContentBlock(ContentBlock::new( - acp::ContentBlock::Text(acp::TextContent { - text: "Tool call not found".to_string(), - annotations: None, - meta: None, - }), + "Tool call not found".into(), &languages, path_style, cx, @@ -1315,7 +1332,7 @@ impl AcpThread { let location_updated = update.fields.locations.is_some(); call.update_fields(update.fields, languages, path_style, &self.terminals, cx)?; if location_updated { - self.resolve_locations(update.id, cx); + self.resolve_locations(update.tool_call_id, cx); } } ToolCallUpdate::UpdateDiff(update) => { @@ -1353,7 +1370,7 @@ impl AcpThread { ) -> Result<(), acp::Error> { let language_registry = self.project.read(cx).languages().clone(); let path_style = self.project.read(cx).path_style(cx); - let id = update.id.clone(); + let id = update.tool_call_id.clone(); let agent = self.connection().telemetry_id(); let session = self.session_id(); @@ -1518,16 +1535,16 @@ impl AcpThread { // some tools would (incorrectly) continue to auto-accept. if let Some(allow_once_option) = options.iter().find_map(|option| { if matches!(option.kind, acp::PermissionOptionKind::AllowOnce) { - Some(option.id.clone()) + Some(option.option_id.clone()) } else { None } }) { self.upsert_tool_call_inner(tool_call, ToolCallStatus::Pending, cx)?; return Ok(async { - acp::RequestPermissionOutcome::Selected { - option_id: allow_once_option, - } + acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new( + allow_once_option, + )) } .boxed()); } @@ -1543,7 +1560,9 @@ impl AcpThread { let fut = async { match rx.await { - Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, + Ok(option) => acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome::new(option), + ), Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, } } @@ -1570,6 +1589,7 @@ impl AcpThread { acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways => { ToolCallStatus::InProgress } + _ => ToolCallStatus::InProgress, }; let curr_status = mem::replace(&mut call.status, new_status); @@ -1648,14 +1668,7 @@ impl AcpThread { message: &str, cx: &mut Context, ) -> BoxFuture<'static, Result<()>> { - self.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: message.to_string(), - annotations: None, - meta: None, - })], - cx, - ) + self.send(vec![message.into()], cx) } pub fn send( @@ -1669,11 +1682,7 @@ impl AcpThread { self.project.read(cx).path_style(cx), cx, ); - let request = acp::PromptRequest { - prompt: message.clone(), - session_id: self.session_id.clone(), - meta: None, - }; + let request = acp::PromptRequest::new(self.session_id.clone(), message.clone()); let git_store = self.project.read(cx).git_store().clone(); let message_id = if self.connection.truncate(&self.session_id, cx).is_some() { @@ -1765,7 +1774,7 @@ impl AcpThread { result, Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Cancelled, - meta: None, + .. })) ); @@ -1781,7 +1790,7 @@ impl AcpThread { // Handle refusal - distinguish between user prompt and tool call refusals if let Ok(Ok(acp::PromptResponse { stop_reason: acp::StopReason::Refusal, - meta: _, + .. })) = result { if let Some((user_msg_ix, _)) = this.last_user_message() { @@ -2017,7 +2026,7 @@ impl AcpThread { })?; Ok(project.open_buffer(path, cx)) }) - .map_err(|e| acp::Error::internal_error().with_data(e.to_string())) + .map_err(|e| acp::Error::internal_error().data(e.to_string())) .flatten()?; let buffer = load.await?; @@ -2050,7 +2059,7 @@ impl AcpThread { let start_position = Point::new(line, 0); if start_position > max_point { - return Err(acp::Error::invalid_params().with_data(format!( + return Err(acp::Error::invalid_params().data(format!( "Attempting to read beyond the end of the file, line {}:{}", max_point.row + 1, max_point.column @@ -2202,7 +2211,7 @@ impl AcpThread { let language_registry = project.read(cx).languages().clone(); let is_windows = project.read(cx).path_style(cx).is_windows(); - let terminal_id = acp::TerminalId(Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(Uuid::new_v4().to_string()); let terminal_task = cx.spawn({ let terminal_id = terminal_id.clone(); async move |_this, cx| { @@ -2412,7 +2421,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created - should be buffered by acp_thread thread.update(cx, |thread, cx| { @@ -2474,7 +2483,7 @@ mod tests { .await .unwrap(); - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); // Send Output BEFORE Created thread.update(cx, |thread, cx| { @@ -2492,11 +2501,7 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); @@ -2553,15 +2558,7 @@ mod tests { // Test creating a new user message thread.update(cx, |thread, cx| { - thread.push_user_content_block( - None, - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Hello, ".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(None, "Hello, ".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2577,15 +2574,7 @@ mod tests { // Test appending to existing user message let message_1_id = UserMessageId::new(); thread.update(cx, |thread, cx| { - thread.push_user_content_block( - Some(message_1_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "world!".to_string(), - meta: None, - }), - cx, - ); + thread.push_user_content_block(Some(message_1_id.clone()), "world!".into(), cx); }); thread.update(cx, |thread, cx| { @@ -2600,26 +2589,14 @@ mod tests { // Test creating new user message after assistant message thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "Assistant response".to_string(), - meta: None, - }), - false, - cx, - ); + thread.push_assistant_content_block("Assistant response".into(), false, cx); }); let message_2_id = UserMessageId::new(); thread.update(cx, |thread, cx| { thread.push_user_content_block( Some(message_2_id.clone()), - acp::ContentBlock::Text(acp::TextContent { - annotations: None, - text: "New user message".to_string(), - meta: None, - }), + "New user message".into(), cx, ); }); @@ -2647,27 +2624,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { - content: "Thinking ".into(), - meta: None, - }), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "Thinking ".into(), + )), cx, ) .unwrap(); thread .handle_session_update( - acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk { - content: "hard!".into(), - meta: None, - }), + acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new( + "hard!".into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2735,10 +2707,7 @@ mod tests { .unwrap() .await .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() }, @@ -2969,7 +2938,7 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let id = acp::ToolCallId("test".into()); + let id = acp::ToolCallId::new("test"); let connection = Rc::new(FakeAgentConnection::new().on_user_message({ let id = id.clone(); @@ -2979,26 +2948,17 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::InProgress, - content: vec![], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new(id.clone(), "Label") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::InProgress), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3040,14 +3000,10 @@ mod tests { thread .update(cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( id, - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ) }) @@ -3079,33 +3035,21 @@ mod tests { thread .update(&mut cx, |thread, cx| { thread.handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("test".into()), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/test/test.txt".into(), - old_text: None, - new_text: "foo".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("test", "Label") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff(acp::Diff::new( + "/test/test.txt", + "foo", + ))]), + ), cx, ) }) .unwrap() .unwrap(); - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3158,18 +3102,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: content.text.to_uppercase().into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3325,34 +3265,22 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Test Tool".into(), - kind: acp::ToolKind::Fetch, - status: acp::ToolCallStatus::Completed, - content: vec![], - locations: vec![], - raw_input: Some(serde_json::json!({"query": "test"})), - raw_output: Some( - serde_json::json!({"result": "inappropriate content"}), - ), - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Test Tool") + .kind(acp::ToolKind::Fetch) + .status(acp::ToolCallStatus::Completed) + .raw_input(serde_json::json!({"query": "test"})) + .raw_output(serde_json::json!({"result": "inappropriate content"})), + ), cx, ) .unwrap(); })?; // Now return refusal because of the tool result - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } else { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } } .boxed_local() @@ -3380,16 +3308,7 @@ mod tests { }); // Send a user message - this will trigger tool call and then refusal - let send_task = thread.update(cx, |thread, cx| { - thread.send( - vec![acp::ContentBlock::Text(acp::TextContent { - text: "Hello".into(), - annotations: None, - meta: None, - })], - cx, - ) - }); + let send_task = thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx)); cx.background_executor.spawn(send_task).detach(); cx.run_until_parked(); @@ -3435,21 +3354,11 @@ mod tests { let refuse_next = refuse_next.clone(); move |_request, _thread, _cx| { if refuse_next.load(SeqCst) { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::Refusal)) } + .boxed_local() } else { - async move { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) - } - .boxed_local() + async move { Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } + .boxed_local() } } })); @@ -3506,10 +3415,7 @@ mod tests { let refuse_next = refuse_next.clone(); async move { if refuse_next.load(SeqCst) { - return Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - }); + return Ok(acp::PromptResponse::new(acp::StopReason::Refusal)); } let acp::ContentBlock::Text(content) = &request.prompt[0] else { @@ -3518,18 +3424,14 @@ mod tests { thread.update(&mut cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: content.text.to_uppercase().into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + content.text.to_uppercase().into(), + )), cx, ) .unwrap(); })?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) } .boxed_local() } @@ -3668,13 +3570,12 @@ mod tests { _cwd: &Path, cx: &mut App, ) -> Task>> { - let session_id = acp::SessionId( + let session_id = acp::SessionId::new( rand::rng() .sample_iter(&distr::Alphanumeric) .take(7) .map(char::from) - .collect::() - .into(), + .collect::(), ); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { @@ -3684,12 +3585,12 @@ mod tests { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -3718,10 +3619,7 @@ mod tests { let thread = thread.clone(); cx.spawn(async move |cx| handler(params, thread, cx.clone()).await) } else { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn))) } } @@ -3776,17 +3674,13 @@ mod tests { .unwrap(); // Try to update a tool call that doesn't exist - let nonexistent_id = acp::ToolCallId("nonexistent-tool-call".into()); + let nonexistent_id = acp::ToolCallId::new("nonexistent-tool-call"); thread.update(cx, |thread, cx| { let result = thread.handle_session_update( - acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate { - id: nonexistent_id.clone(), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - ..Default::default() - }, - meta: None, - }), + acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new( + nonexistent_id.clone(), + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::Completed), + )), cx, ); @@ -3861,7 +3755,7 @@ mod tests { .unwrap(); // Create 2 terminals BEFORE the checkpoint that have completed running - let terminal_id_1 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id_1 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal_1 = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -3900,17 +3794,13 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id_1.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); }); - let terminal_id_2 = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id_2 = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal_2 = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -3949,11 +3839,7 @@ mod tests { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { terminal_id: terminal_id_2.clone(), - status: acp::TerminalExitStatus { - exit_code: Some(0), - signal: None, - meta: None, - }, + status: acp::TerminalExitStatus::new().exit_code(0), }, cx, ); @@ -3973,7 +3859,7 @@ mod tests { // Create a terminal AFTER the checkpoint we'll restore to. // This simulates the AI agent starting a long-running terminal command. - let terminal_id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let terminal_id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let mock_terminal = cx.new(|cx| { let builder = ::terminal::TerminalBuilder::new_display_only( ::terminal::terminal_settings::CursorShape::default(), @@ -4015,21 +3901,15 @@ mod tests { thread.update(cx, |thread, cx| { thread .handle_session_update( - acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("terminal-tool-1".into()), - title: "Running command".into(), - kind: acp::ToolKind::Execute, - status: acp::ToolCallStatus::InProgress, - content: vec![acp::ToolCallContent::Terminal { - terminal_id: terminal_id.clone(), - }], - locations: vec![], - raw_input: Some( - serde_json::json!({"command": "sleep 1000", "cd": "/test"}), - ), - raw_output: None, - meta: None, - }), + acp::SessionUpdate::ToolCall( + acp::ToolCall::new("terminal-tool-1", "Running command") + .kind(acp::ToolKind::Execute) + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Terminal(acp::Terminal::new( + terminal_id.clone(), + ))]) + .raw_input(serde_json::json!({"command": "sleep 1000", "cd": "/test"})), + ), cx, ) .unwrap(); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 80bec0ee9d351711bdf435cfe63eb99eb1e499e3..8213786a182e1d93d1bfc1a8918a8830ecaa754b 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -336,7 +336,7 @@ mod test_support { _cwd: &Path, cx: &mut gpui::App, ) -> Task>> { - let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); + let session_id = acp::SessionId::new(self.sessions.lock().len().to_string()); let action_log = cx.new(|_| ActionLog::new(project.clone())); let thread = cx.new(|cx| { AcpThread::new( @@ -345,12 +345,12 @@ mod test_support { project, action_log, session_id.clone(), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }); @@ -389,10 +389,7 @@ mod test_support { response_tx.replace(tx); cx.spawn(async move |_| { let stop_reason = rx.await?; - Ok(acp::PromptResponse { - stop_reason, - meta: None, - }) + Ok(acp::PromptResponse::new(stop_reason)) }) } else { for update in self.next_prompt_updates.lock().drain(..) { @@ -400,7 +397,7 @@ mod test_support { let update = update.clone(); let permission_request = if let acp::SessionUpdate::ToolCall(tool_call) = &update - && let Some(options) = self.permission_requests.get(&tool_call.id) + && let Some(options) = self.permission_requests.get(&tool_call.tool_call_id) { Some((tool_call.clone(), options.clone())) } else { @@ -429,10 +426,7 @@ mod test_support { cx.spawn(async move |_| { try_join_all(tasks).await?; - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index b78eac4903a259a1044892fb2c8233f7e973f025..c1b7032cfaa904764055bb79a3cac7e7ac74b0c1 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -108,7 +108,7 @@ impl MentionUri { if let Some(thread_id) = path.strip_prefix("/agent/thread/") { let name = single_query_param(&url, "name")?.context("Missing thread name")?; Ok(Self::Thread { - id: acp::SessionId(thread_id.into()), + id: acp::SessionId::new(thread_id), name, }) } else if let Some(path) = path.strip_prefix("/agent/text-thread/") { diff --git a/crates/acp_thread/src/terminal.rs b/crates/acp_thread/src/terminal.rs index 8b08868616e19b0d1855558a057af8eebc314e4a..fb9115650d1277e7e9982bfc851d8df142f048ad 100644 --- a/crates/acp_thread/src/terminal.rs +++ b/crates/acp_thread/src/terminal.rs @@ -75,11 +75,15 @@ impl Terminal { let exit_status = exit_status.map(portable_pty::ExitStatus::from); - acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, + let mut status = acp::TerminalExitStatus::new(); + + if let Some(exit_status) = exit_status.as_ref() { + status = status.exit_code(exit_status.exit_code()); + if let Some(signal) = exit_status.signal() { + status = status.signal(signal); + } } + status }) .shared(), } @@ -101,27 +105,23 @@ impl Terminal { pub fn current_output(&self, cx: &App) -> acp::TerminalOutputResponse { if let Some(output) = self.output.as_ref() { - let exit_status = output.exit_status.map(portable_pty::ExitStatus::from); - - acp::TerminalOutputResponse { - output: output.content.clone(), - truncated: output.original_content_len > output.content.len(), - exit_status: Some(acp::TerminalExitStatus { - exit_code: exit_status.as_ref().map(|e| e.exit_code()), - signal: exit_status.and_then(|e| e.signal().map(Into::into)), - meta: None, - }), - meta: None, + let mut exit_status = acp::TerminalExitStatus::new(); + if let Some(status) = output.exit_status.map(portable_pty::ExitStatus::from) { + exit_status = exit_status.exit_code(status.exit_code()); + if let Some(signal) = status.signal() { + exit_status = exit_status.signal(signal); + } } + + acp::TerminalOutputResponse::new( + output.content.clone(), + output.original_content_len > output.content.len(), + ) + .exit_status(exit_status) } else { let (current_content, original_len) = self.truncated_output(cx); - - acp::TerminalOutputResponse { - truncated: current_content.len() < original_len, - output: current_content, - exit_status: None, - meta: None, - } + let truncated = current_content.len() < original_len; + acp::TerminalOutputResponse::new(current_content, truncated) } } diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index 404cd6549e5786b92c49379918346b83fcc0e0c1..aec0767c25422dbfeae6fdddcf33e54f8045995c 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -170,7 +170,7 @@ impl LanguageModels { } fn model_id(model: &Arc) -> acp::ModelId { - acp::ModelId(format!("{}/{}", model.provider_id().0, model.id().0).into()) + acp::ModelId::new(format!("{}/{}", model.provider_id().0, model.id().0)) } fn authenticate_all_language_model_providers(cx: &mut App) -> Task<()> { @@ -789,28 +789,12 @@ impl NativeAgentConnection { } ThreadEvent::AgentText(text) => { acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - false, - cx, - ) + thread.push_assistant_content_block(text.into(), false, cx) })?; } ThreadEvent::AgentThinking(text) => { acp_thread.update(cx, |thread, cx| { - thread.push_assistant_content_block( - acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - true, - cx, - ) + thread.push_assistant_content_block(text.into(), true, cx) })?; } ThreadEvent::ToolCallAuthorization(ToolCallAuthorization { @@ -824,8 +808,9 @@ impl NativeAgentConnection { ) })??; cx.background_spawn(async move { - if let acp::RequestPermissionOutcome::Selected { option_id } = - outcome_task.await + if let acp::RequestPermissionOutcome::Selected( + acp::SelectedPermissionOutcome { option_id, .. }, + ) = outcome_task.await { response .send(option_id) @@ -852,10 +837,7 @@ impl NativeAgentConnection { } ThreadEvent::Stop(stop_reason) => { log::debug!("Assistant message complete: {:?}", stop_reason); - return Ok(acp::PromptResponse { - stop_reason, - meta: None, - }); + return Ok(acp::PromptResponse::new(stop_reason)); } } } @@ -867,10 +849,7 @@ impl NativeAgentConnection { } log::debug!("Response stream completed"); - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - meta: None, - }) + anyhow::Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)) }) } } @@ -1374,7 +1353,7 @@ mod internal_tests { IndexMap::from_iter([( AgentModelGroupName("Fake".into()), vec![AgentModelInfo { - id: acp::ModelId("fake/fake".into()), + id: acp::ModelId::new("fake/fake"), name: "Fake".into(), description: None, icon: Some(ui::IconName::ZedAssistant), @@ -1435,7 +1414,7 @@ mod internal_tests { // Select a model let selector = connection.model_selector(&session_id).unwrap(); - let model_id = acp::ModelId("fake/fake".into()); + let model_id = acp::ModelId::new("fake/fake"); cx.update(|cx| selector.select_model(model_id.clone(), cx)) .await .unwrap(); @@ -1521,20 +1500,14 @@ mod internal_tests { thread.send( vec![ "What does ".into(), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: "b.md".into(), - uri: MentionUri::File { + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + "b.md", + MentionUri::File { abs_path: path!("/a/b.md").into(), } .to_uri() .to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), + )), " mean?".into(), ], cx, diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index d5166c5df931b6f7fad63769449aaa9784b5263f..7a88c5870574cae424bd1fff50f1d20cdb00fa44 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -366,7 +366,7 @@ impl ThreadsDatabase { for (id, summary, updated_at) in rows { threads.push(DbThreadMetadata { - id: acp::SessionId(id), + id: acp::SessionId::new(id), title: summary.into(), updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc), }); diff --git a/crates/agent/src/history_store.rs b/crates/agent/src/history_store.rs index efc0e3966d30fbc8bc7857c9da0404ce7dd4201f..5a1b923d139060ed7df679a69d96928d03559c9d 100644 --- a/crates/agent/src/history_store.rs +++ b/crates/agent/src/history_store.rs @@ -354,9 +354,9 @@ impl HistoryStore { .into_iter() .take(MAX_RECENTLY_OPENED_ENTRIES) .flat_map(|entry| match entry { - SerializedRecentOpen::AcpThread(id) => Some(HistoryEntryId::AcpThread( - acp::SessionId(id.as_str().into()), - )), + SerializedRecentOpen::AcpThread(id) => { + Some(HistoryEntryId::AcpThread(acp::SessionId::new(id.as_str()))) + } SerializedRecentOpen::TextThread(file_name) => Some( HistoryEntryId::TextThread(text_threads_dir().join(file_name).into()), ), diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index b33080671980eb28c7900aea4bb0942d152a054a..5948200dd796a336cbccbc1644c3bb200960de51 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -493,14 +493,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { // Approve the first tool_call_auth_1 .response - .send(tool_call_auth_1.options[1].id.clone()) + .send(tool_call_auth_1.options[1].option_id.clone()) .unwrap(); cx.run_until_parked(); // Reject the second tool_call_auth_2 .response - .send(tool_call_auth_1.options[2].id.clone()) + .send(tool_call_auth_1.options[2].option_id.clone()) .unwrap(); cx.run_until_parked(); @@ -510,14 +510,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![ language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_1.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_1.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), output: Some("Allowed".into()) }), language_model::MessageContent::ToolResult(LanguageModelToolResult { - tool_use_id: tool_call_auth_2.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_2.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), @@ -543,7 +543,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { let tool_call_auth_3 = next_tool_call_authorization(&mut events).await; tool_call_auth_3 .response - .send(tool_call_auth_3.options[0].id.clone()) + .send(tool_call_auth_3.options[0].option_id.clone()) .unwrap(); cx.run_until_parked(); let completion = fake_model.pending_completions().pop().unwrap(); @@ -552,7 +552,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { message.content, vec![language_model::MessageContent::ToolResult( LanguageModelToolResult { - tool_use_id: tool_call_auth_3.tool_call.id.0.to_string().into(), + tool_use_id: tool_call_auth_3.tool_call.tool_call_id.0.to_string().into(), tool_name: ToolRequiringPermission::name().into(), is_error: false, content: "Allowed".into(), @@ -1353,20 +1353,20 @@ async fn test_cancellation(cx: &mut TestAppContext) { ThreadEvent::ToolCall(tool_call) => { assert_eq!(tool_call.title, expected_tools.remove(0)); if tool_call.title == "Echo" { - echo_id = Some(tool_call.id); + echo_id = Some(tool_call.tool_call_id); } } ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( acp::ToolCallUpdate { - id, + tool_call_id, fields: acp::ToolCallUpdateFields { status: Some(acp::ToolCallStatus::Completed), .. }, - meta: None, + .. }, - )) if Some(&id) == echo_id.as_ref() => { + )) if Some(&tool_call_id) == echo_id.as_ref() => { echo_completed = true; } _ => {} @@ -1995,11 +1995,7 @@ async fn test_agent_connection(cx: &mut TestAppContext) { .update(|cx| { connection.prompt( Some(acp_thread::UserMessageId::new()), - acp::PromptRequest { - session_id: session_id.clone(), - prompt: vec!["ghi".into()], - meta: None, - }, + acp::PromptRequest::new(session_id.clone(), vec!["ghi".into()]), cx, ) }) @@ -2056,68 +2052,50 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let tool_call = expect_tool_call(&mut events).await; assert_eq!( tool_call, - acp::ToolCall { - id: acp::ToolCallId("1".into()), - title: "Thinking".into(), - kind: acp::ToolKind::Think, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(json!({})), - raw_output: None, - meta: Some(json!({ "tool_name": "thinking" })), - } + acp::ToolCall::new("1", "Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({})) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + "thinking".into() + )])) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - title: Some("Thinking".into()), - kind: Some(acp::ToolKind::Think), - raw_input: Some(json!({ "content": "Thinking hard!" })), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .title("Thinking") + .kind(acp::ToolKind::Think) + .raw_input(json!({ "content": "Thinking hard!"})) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - content: Some(vec!["Thinking hard!".into()]), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new().content(vec!["Thinking hard!".into()]) + ) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, - acp::ToolCallUpdate { - id: acp::ToolCallId("1".into()), - fields: acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), - raw_output: Some("Finished thinking.".into()), - ..Default::default() - }, - meta: None, - } + acp::ToolCallUpdate::new( + "1", + acp::ToolCallUpdateFields::new() + .status(acp::ToolCallStatus::Completed) + .raw_output("Finished thinking.".into()) + ) ); } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 294c96b3ecb7800ab5b5f62749d335682efebd60..da95c4294757a23960d6c5c78aa905e63834debb 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -619,12 +619,9 @@ pub struct Thread { impl Thread { fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { let image = model.map_or(true, |model| model.supports_images()); - acp::PromptCapabilities { - meta: None, - image, - audio: false, - embedded_context: true, - } + acp::PromptCapabilities::new() + .image(image) + .embedded_context(true) } pub fn new( @@ -640,7 +637,7 @@ impl Thread { let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel(Self::prompt_capabilities(model.as_deref())); Self { - id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), + id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()), prompt_id: PromptId::new(), updated_at: Utc::now(), title: None, @@ -737,17 +734,11 @@ impl Thread { let Some(tool) = tool else { stream .0 - .unbounded_send(Ok(ThreadEvent::ToolCall(acp::ToolCall { - meta: None, - id: acp::ToolCallId(tool_use.id.to_string().into()), - title: tool_use.name.to_string(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::Failed, - content: Vec::new(), - locations: Vec::new(), - raw_input: Some(tool_use.input.clone()), - raw_output: None, - }))) + .unbounded_send(Ok(ThreadEvent::ToolCall( + acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string()) + .status(acp::ToolCallStatus::Failed) + .raw_input(tool_use.input.clone()), + ))) .ok(); return; }; @@ -775,24 +766,20 @@ impl Thread { .log_err(); } - stream.update_tool_call_fields( - &tool_use.id, - acp::ToolCallUpdateFields { - status: Some( - tool_result - .as_ref() - .map_or(acp::ToolCallStatus::Failed, |result| { - if result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - } - }), - ), - raw_output: output, - ..Default::default() + let mut fields = acp::ToolCallUpdateFields::new().status(tool_result.as_ref().map_or( + acp::ToolCallStatus::Failed, + |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } }, - ); + )); + if let Some(output) = output { + fields = fields.raw_output(output); + } + stream.update_tool_call_fields(&tool_use.id, fields); } pub fn from_db( @@ -1272,18 +1259,15 @@ impl Thread { while let Some(tool_result) = tool_results.next().await { log::debug!("Tool finished {:?}", tool_result); - event_stream.update_tool_call_fields( - &tool_result.tool_use_id, - acp::ToolCallUpdateFields { - status: Some(if tool_result.is_error { - acp::ToolCallStatus::Failed - } else { - acp::ToolCallStatus::Completed - }), - raw_output: tool_result.output.clone(), - ..Default::default() - }, - ); + let mut fields = acp::ToolCallUpdateFields::new().status(if tool_result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + }); + if let Some(output) = &tool_result.output { + fields = fields.raw_output(output.clone()); + } + event_stream.update_tool_call_fields(&tool_result.tool_use_id, fields); this.update(cx, |this, _cx| { this.pending_message() .tool_results @@ -1560,12 +1544,10 @@ impl Thread { } else { event_stream.update_tool_call_fields( &tool_use.id, - acp::ToolCallUpdateFields { - title: Some(title.into()), - kind: Some(kind), - raw_input: Some(tool_use.input.clone()), - ..Default::default() - }, + acp::ToolCallUpdateFields::new() + .title(title) + .kind(kind) + .raw_input(tool_use.input.clone()), ); } @@ -1587,10 +1569,9 @@ impl Thread { let fs = self.project.read(cx).fs().clone(); let tool_event_stream = ToolCallEventStream::new(tool_use.id.clone(), event_stream.clone(), Some(fs)); - tool_event_stream.update_fields(acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::InProgress), - ..Default::default() - }); + tool_event_stream.update_fields( + acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress), + ); let supports_images = self.model().is_some_and(|model| model.supports_images()); let tool_result = tool.run(tool_use.input, tool_event_stream, cx); log::debug!("Running tool {}", tool_use.name); @@ -2381,19 +2362,13 @@ impl ThreadEventStream { kind: acp::ToolKind, input: serde_json::Value, ) -> acp::ToolCall { - acp::ToolCall { - meta: Some(serde_json::json!({ - "tool_name": tool_name - })), - id: acp::ToolCallId(id.to_string().into()), - title, - kind, - status: acp::ToolCallStatus::Pending, - content: vec![], - locations: vec![], - raw_input: Some(input), - raw_output: None, - } + acp::ToolCall::new(id.to_string(), title) + .kind(kind) + .raw_input(input) + .meta(acp::Meta::from_iter([( + "tool_name".into(), + tool_name.into(), + )])) } fn update_tool_call_fields( @@ -2403,12 +2378,7 @@ impl ThreadEventStream { ) { self.0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( - acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(tool_use_id.to_string().into()), - fields, - } - .into(), + acp::ToolCallUpdate::new(tool_use_id.to_string(), fields).into(), ))) .ok(); } @@ -2471,7 +2441,7 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdateDiff { - id: acp::ToolCallId(self.tool_use_id.to_string().into()), + id: acp::ToolCallId::new(self.tool_use_id.to_string()), diff, } .into(), @@ -2489,33 +2459,26 @@ impl ToolCallEventStream { .0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { - tool_call: acp::ToolCallUpdate { - meta: None, - id: acp::ToolCallId(self.tool_use_id.to_string().into()), - fields: acp::ToolCallUpdateFields { - title: Some(title.into()), - ..Default::default() - }, - }, + tool_call: acp::ToolCallUpdate::new( + self.tool_use_id.to_string(), + acp::ToolCallUpdateFields::new().title(title), + ), options: vec![ - acp::PermissionOption { - id: acp::PermissionOptionId("always_allow".into()), - name: "Always Allow".into(), - kind: acp::PermissionOptionKind::AllowAlways, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("allow".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }, - acp::PermissionOption { - id: acp::PermissionOptionId("deny".into()), - name: "Deny".into(), - kind: acp::PermissionOptionKind::RejectOnce, - meta: None, - }, + acp::PermissionOption::new( + acp::PermissionOptionId::new("always_allow"), + "Always Allow", + acp::PermissionOptionKind::AllowAlways, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("allow"), + "Allow", + acp::PermissionOptionKind::AllowOnce, + ), + acp::PermissionOption::new( + acp::PermissionOptionId::new("deny"), + "Deny", + acp::PermissionOptionKind::RejectOnce, + ), ], response: response_tx, }, @@ -2660,7 +2623,15 @@ impl UserMessageContent { // TODO Self::Text("[blob]".to_string()) } + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } }, + other => { + log::warn!("Unexpected content type: {:?}", other); + Self::Text("[unknown]".to_string()) + } } } } @@ -2668,32 +2639,15 @@ impl UserMessageContent { impl From for acp::ContentBlock { fn from(content: UserMessageContent) -> Self { match content { - UserMessageContent::Text(text) => acp::ContentBlock::Text(acp::TextContent { - text, - annotations: None, - meta: None, - }), - UserMessageContent::Image(image) => acp::ContentBlock::Image(acp::ImageContent { - data: image.source.to_string(), - mime_type: "image/png".to_string(), - meta: None, - annotations: None, - uri: None, - }), - UserMessageContent::Mention { uri, content } => { - acp::ContentBlock::Resource(acp::EmbeddedResource { - meta: None, - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - meta: None, - mime_type: None, - text: content, - uri: uri.to_uri().to_string(), - }, - ), - annotations: None, - }) + UserMessageContent::Text(text) => text.into(), + UserMessageContent::Image(image) => { + acp::ContentBlock::Image(acp::ImageContent::new(image.source, "image/png")) } + UserMessageContent::Mention { uri, content } => acp::ContentBlock::Resource( + acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(content, uri.to_uri().to_string()), + )), + ), } } } diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index de2dd384693c8af3e04007895c843743c5ead722..cbe96a6b20d6e325beb9aedb6cf6d2eca1df171a 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -273,14 +273,9 @@ impl AgentTool for EditFileTool { }; let abs_path = project.read(cx).absolute_path(&project_path, cx); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path, - line: None, - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields( + ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]), + ); } let authorize = self.authorize(&input, &event_stream, cx); @@ -389,10 +384,11 @@ impl AgentTool for EditFileTool { range.start.to_point(&buffer.snapshot()).row }).ok(); if let Some(abs_path) = abs_path.clone() { - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![ToolCallLocation { path: abs_path, line, meta: None }]), - ..Default::default() - }); + let mut location = ToolCallLocation::new(abs_path); + if let Some(line) = line { + location = location.line(line); + } + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![location])); } emitted_location = true; } diff --git a/crates/agent/src/tools/find_path_tool.rs b/crates/agent/src/tools/find_path_tool.rs index 70d7b29f75d4da984c4acda13dcdbfe7bc69fbbc..3c34f14c3a78f0fa8a6ee6794ef2567fe13d5d3c 100644 --- a/crates/agent/src/tools/find_path_tool.rs +++ b/crates/agent/src/tools/find_path_tool.rs @@ -118,33 +118,29 @@ impl AgentTool for FindPathTool { let paginated_matches: &[PathBuf] = &matches[cmp::min(input.offset, matches.len()) ..cmp::min(input.offset + RESULTS_PER_PAGE, matches.len())]; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(if paginated_matches.is_empty() { - "No matches".into() - } else if paginated_matches.len() == 1 { - "1 match".into() - } else { - format!("{} matches", paginated_matches.len()) - }), - content: Some( - paginated_matches - .iter() - .map(|path| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: format!("file://{}", path.display()), - name: path.to_string_lossy().into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(if paginated_matches.is_empty() { + "No matches".into() + } else if paginated_matches.len() == 1 { + "1 match".into() + } else { + format!("{} matches", paginated_matches.len()) + }) + .content( + paginated_matches + .iter() + .map(|path| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + path.to_string_lossy(), + format!("file://{}", path.display()), + )), + )) + }) + .collect(), + ), + ); Ok(FindPathToolOutput { offset: input.offset, diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index fd7b85d5ee4d075f5ab5f3fcdef2d1919e763dd7..4457a6e5ca21a2fc88c76c718160d1d59171e66a 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -152,15 +152,12 @@ impl AgentTool for ReadFileTool { } let file_path = input.path.clone(); + let mut location = acp::ToolCallLocation::new(&abs_path); + if let Some(line) = input.start_line { + location = location.line(line.saturating_sub(1)); + } - event_stream.update_fields(ToolCallUpdateFields { - locations: Some(vec![acp::ToolCallLocation { - path: abs_path.clone(), - line: input.start_line.map(|line| line.saturating_sub(1)), - meta: None, - }]), - ..Default::default() - }); + event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![location])); if image_store::is_image_file(&self.project, &project_path, cx) { return cx.spawn(async move |cx| { @@ -289,12 +286,9 @@ impl AgentTool for ReadFileTool { text, } .to_string(); - event_stream.update_fields(ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Content { - content: markdown.into(), - }]), - ..Default::default() - }) + event_stream.update_fields(ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Content(acp::Content::new(markdown)), + ])); } })?; diff --git a/crates/agent/src/tools/terminal_tool.rs b/crates/agent/src/tools/terminal_tool.rs index 6d30c19152001deaef5deeacbdf266e28ac03d08..2db4a2d86038579fca62224f3a7c567f93fc6922 100644 --- a/crates/agent/src/tools/terminal_tool.rs +++ b/crates/agent/src/tools/terminal_tool.rs @@ -112,10 +112,9 @@ impl AgentTool for TerminalTool { .await?; let terminal_id = terminal.id(cx)?; - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![acp::ToolCallContent::Terminal { terminal_id }]), - ..Default::default() - }); + event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ + acp::ToolCallContent::Terminal(acp::Terminal::new(terminal_id)), + ])); let exit_status = terminal.wait_for_exit(cx)?.await; let output = terminal.current_output(cx)?; diff --git a/crates/agent/src/tools/thinking_tool.rs b/crates/agent/src/tools/thinking_tool.rs index 0a68f7545f81ce3202c110b1435d33b57adf409c..96024326f6f1610f500972b1a98be45258e3966b 100644 --- a/crates/agent/src/tools/thinking_tool.rs +++ b/crates/agent/src/tools/thinking_tool.rs @@ -43,10 +43,8 @@ impl AgentTool for ThinkingTool { event_stream: ToolCallEventStream, _cx: &mut App, ) -> Task> { - event_stream.update_fields(acp::ToolCallUpdateFields { - content: Some(vec![input.content.into()]), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().content(vec![input.content.into()])); Task::ready(Ok("Finished thinking.".to_string())) } } diff --git a/crates/agent/src/tools/web_search_tool.rs b/crates/agent/src/tools/web_search_tool.rs index 03e9db6601579e082e4d83de50f1999209d9f197..d78b692126f62d6ed7fd00f585618ab6b6ba55e2 100644 --- a/crates/agent/src/tools/web_search_tool.rs +++ b/crates/agent/src/tools/web_search_tool.rs @@ -76,10 +76,8 @@ impl AgentTool for WebSearchTool { let response = match search_task.await { Ok(response) => response, Err(err) => { - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some("Web Search Failed".to_string()), - ..Default::default() - }); + event_stream + .update_fields(acp::ToolCallUpdateFields::new().title("Web Search Failed")); return Err(err); } }; @@ -107,26 +105,23 @@ fn emit_update(response: &WebSearchResponse, event_stream: &ToolCallEventStream) } else { format!("{} results", response.results.len()) }; - event_stream.update_fields(acp::ToolCallUpdateFields { - title: Some(format!("Searched the web: {result_text}")), - content: Some( - response - .results - .iter() - .map(|result| acp::ToolCallContent::Content { - content: acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: result.title.clone(), - uri: result.url.clone(), - title: Some(result.title.clone()), - description: Some(result.text.clone()), - mime_type: None, - annotations: None, - size: None, - meta: None, - }), - }) - .collect(), - ), - ..Default::default() - }); + event_stream.update_fields( + acp::ToolCallUpdateFields::new() + .title(format!("Searched the web: {result_text}")) + .content( + response + .results + .iter() + .map(|result| { + acp::ToolCallContent::Content(acp::Content::new( + acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(result.title.clone(), result.url.clone()) + .title(result.title.clone()) + .description(result.text.clone()), + ), + )) + }) + .collect(), + ), + ); } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index a44bdd1f22478e92ace192c939561f855c2814bd..f035e981919deb2fa15069866507abd8be0ac209 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -76,7 +76,7 @@ pub async fn connect( Ok(Rc::new(conn) as _) } -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; +const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::ProtocolVersion::V1; impl AcpConnection { pub async fn stdio( @@ -173,29 +173,27 @@ impl AcpConnection { }); })?; + let mut client_info = acp::Implementation::new("zed", version); + if let Some(release_channel) = release_channel { + client_info = client_info.title(release_channel); + } let response = connection - .initialize(acp::InitializeRequest { - protocol_version: acp::VERSION, - client_capabilities: acp::ClientCapabilities { - fs: acp::FileSystemCapability { - read_text_file: true, - write_text_file: true, - meta: None, - }, - terminal: true, - meta: Some(serde_json::json!({ - // Experimental: Allow for rendering terminal output from the agents - "terminal_output": true, - "terminal-auth": true, - })), - }, - client_info: Some(acp::Implementation { - name: "zed".to_owned(), - title: release_channel.map(|c| c.to_owned()), - version, - }), - meta: None, - }) + .initialize( + acp::InitializeRequest::new(acp::ProtocolVersion::V1) + .client_capabilities( + acp::ClientCapabilities::new() + .fs(acp::FileSystemCapability::new() + .read_text_file(true) + .write_text_file(true)) + .terminal(true) + // Experimental: Allow for rendering terminal output from the agents + .meta(acp::Meta::from_iter([ + ("terminal_output".into(), true.into()), + ("terminal-auth".into(), true.into()), + ])), + ) + .client_info(client_info), + ) .await?; if response.protocol_version < MINIMUM_SUPPORTED_VERSION { @@ -253,14 +251,13 @@ impl AgentConnection for AcpConnection { let default_model = self.default_model.clone(); let cwd = cwd.to_path_buf(); let context_server_store = project.read(cx).context_server_store().read(cx); - let mcp_servers = - if project.read(cx).is_local() { - context_server_store - .configured_server_ids() - .iter() - .filter_map(|id| { - let configuration = context_server_store.configuration_for_server(id)?; - match &*configuration { + let mcp_servers = if project.read(cx).is_local() { + context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + match &*configuration { project::context_server_store::ContextServerConfiguration::Custom { command, .. @@ -268,47 +265,41 @@ impl AgentConnection for AcpConnection { | project::context_server_store::ContextServerConfiguration::Extension { command, .. - } => Some(acp::McpServer::Stdio { - name: id.0.to_string(), - command: command.path.clone(), - args: command.args.clone(), - env: if let Some(env) = command.env.as_ref() { - env.iter() - .map(|(name, value)| acp::EnvVariable { - name: name.clone(), - value: value.clone(), - meta: None, - }) - .collect() - } else { - vec![] - }, - }), + } => Some(acp::McpServer::Stdio( + acp::McpServerStdio::new(id.0.to_string(), &command.path) + .args(command.args.clone()) + .env(if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable::new(name, value)) + .collect() + } else { + vec![] + }), + )), project::context_server_store::ContextServerConfiguration::Http { url, headers, - } => Some(acp::McpServer::Http { - name: id.0.to_string(), - url: url.to_string(), - headers: headers.iter().map(|(name, value)| acp::HttpHeader { - name: name.clone(), - value: value.clone(), - meta: None, - }).collect(), - }), + } => Some(acp::McpServer::Http( + acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers( + headers + .iter() + .map(|(name, value)| acp::HttpHeader::new(name, value)) + .collect(), + ), + )), } - }) - .collect() - } else { - // In SSH projects, the external agent is running on the remote - // machine, and currently we only run MCP servers on the local - // machine. So don't pass any MCP servers to the agent in that case. - Vec::new() - }; + }) + .collect() + } else { + // In SSH projects, the external agent is running on the remote + // machine, and currently we only run MCP servers on the local + // machine. So don't pass any MCP servers to the agent in that case. + Vec::new() + }; cx.spawn(async move |cx| { let response = conn - .new_session(acp::NewSessionRequest { mcp_servers, cwd, meta: None }) + .new_session(acp::NewSessionRequest::new(cwd).mcp_servers(mcp_servers)) .await .map_err(|err| { if err.code == acp::ErrorCode::AUTH_REQUIRED.code { @@ -341,11 +332,7 @@ impl AgentConnection for AcpConnection { let modes = modes.clone(); let conn = conn.clone(); async move |_| { - let result = conn.set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id: default_mode, - meta: None, - }) + let result = conn.set_session_mode(acp::SetSessionModeRequest::new(session_id, default_mode)) .await.log_err(); if result.is_none() { @@ -388,11 +375,7 @@ impl AgentConnection for AcpConnection { let models = models.clone(); let conn = conn.clone(); async move |_| { - let result = conn.set_session_model(acp::SetSessionModelRequest { - session_id, - model_id: default_model, - meta: None, - }) + let result = conn.set_session_model(acp::SetSessionModelRequest::new(session_id, default_model)) .await.log_err(); if result.is_none() { @@ -456,12 +439,8 @@ impl AgentConnection for AcpConnection { fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { let conn = self.connection.clone(); cx.foreground_executor().spawn(async move { - conn.authenticate(acp::AuthenticateRequest { - method_id: method_id.clone(), - meta: None, - }) - .await?; - + conn.authenticate(acp::AuthenticateRequest::new(method_id)) + .await?; Ok(()) }) } @@ -515,10 +494,7 @@ impl AgentConnection for AcpConnection { && (details.contains("This operation was aborted") || details.contains("The user aborted a request")) { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Cancelled, - meta: None, - }) + Ok(acp::PromptResponse::new(acp::StopReason::Cancelled)) } else { Err(anyhow!(details)) } @@ -535,10 +511,7 @@ impl AgentConnection for AcpConnection { session.suppress_abort_err = true; } let conn = self.connection.clone(); - let params = acp::CancelNotification { - session_id: session_id.clone(), - meta: None, - }; + let params = acp::CancelNotification::new(session_id.clone()); cx.foreground_executor() .spawn(async move { conn.cancel(params).await }) .detach(); @@ -619,11 +592,7 @@ impl acp_thread::AgentSessionModes for AcpSessionModes { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_mode(acp::SetSessionModeRequest { - session_id, - mode_id, - meta: None, - }) + .set_session_mode(acp::SetSessionModeRequest::new(session_id, mode_id)) .await; if result.is_err() { @@ -682,11 +651,7 @@ impl acp_thread::AgentModelSelector for AcpModelSelector { let state = self.state.clone(); cx.foreground_executor().spawn(async move { let result = connection - .set_session_model(acp::SetSessionModelRequest { - session_id, - model_id, - meta: None, - }) + .set_session_model(acp::SetSessionModelRequest::new(session_id, model_id)) .await; if result.is_err() { @@ -748,10 +713,7 @@ impl acp::Client for ClientDelegate { let outcome = task.await; - Ok(acp::RequestPermissionResponse { - outcome, - meta: None, - }) + Ok(acp::RequestPermissionResponse::new(outcome)) } async fn write_text_file( @@ -783,10 +745,7 @@ impl acp::Client for ClientDelegate { let content = task.await?; - Ok(acp::ReadTextFileResponse { - content, - meta: None, - }) + Ok(acp::ReadTextFileResponse::new(content)) } async fn session_notification( @@ -821,7 +780,7 @@ impl acp::Client for ClientDelegate { if let Some(terminal_info) = meta.get("terminal_info") { if let Some(id_str) = terminal_info.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); let cwd = terminal_info .get("cwd") .and_then(|v| v.as_str().map(PathBuf::from)); @@ -837,7 +796,7 @@ impl acp::Client for ClientDelegate { let lower = cx.new(|cx| builder.subscribe(cx)); thread.on_terminal_provider_event( TerminalProviderEvent::Created { - terminal_id: terminal_id.clone(), + terminal_id, label: tc.title.clone(), cwd, output_byte_limit: None, @@ -862,15 +821,12 @@ impl acp::Client for ClientDelegate { if let Some(meta) = &tcu.meta { if let Some(term_out) = meta.get("terminal_output") { if let Some(id_str) = term_out.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); + let terminal_id = acp::TerminalId::new(id_str); if let Some(s) = term_out.get("data").and_then(|v| v.as_str()) { let data = s.as_bytes().to_vec(); let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( - TerminalProviderEvent::Output { - terminal_id: terminal_id.clone(), - data, - }, + TerminalProviderEvent::Output { terminal_id, data }, cx, ); }); @@ -881,21 +837,19 @@ impl acp::Client for ClientDelegate { // terminal_exit if let Some(term_exit) = meta.get("terminal_exit") { if let Some(id_str) = term_exit.get("terminal_id").and_then(|v| v.as_str()) { - let terminal_id = acp::TerminalId(id_str.into()); - let status = acp::TerminalExitStatus { - exit_code: term_exit - .get("exit_code") - .and_then(|v| v.as_u64()) - .map(|i| i as u32), - signal: term_exit - .get("signal") - .and_then(|v| v.as_str().map(|s| s.to_string())), - meta: None, - }; + let terminal_id = acp::TerminalId::new(id_str); + let mut status = acp::TerminalExitStatus::new(); + if let Some(code) = term_exit.get("exit_code").and_then(|v| v.as_u64()) { + status = status.exit_code(code as u32) + } + if let Some(signal) = term_exit.get("signal").and_then(|v| v.as_str()) { + status = status.signal(signal); + } + let _ = session.thread.update(&mut self.cx.clone(), |thread, cx| { thread.on_terminal_provider_event( TerminalProviderEvent::Exit { - terminal_id: terminal_id.clone(), + terminal_id, status, }, cx, @@ -932,7 +886,7 @@ impl acp::Client for ClientDelegate { // Register with renderer let terminal_entity = thread.update(&mut self.cx.clone(), |thread, cx| { thread.register_terminal_created( - acp::TerminalId(uuid::Uuid::new_v4().to_string().into()), + acp::TerminalId::new(uuid::Uuid::new_v4().to_string()), format!("{} {}", args.command, args.args.join(" ")), args.cwd.clone(), args.output_byte_limit, @@ -942,10 +896,7 @@ impl acp::Client for ClientDelegate { })?; let terminal_id = terminal_entity.read_with(&self.cx, |terminal, _| terminal.id().clone())?; - Ok(acp::CreateTerminalResponse { - terminal_id, - meta: None, - }) + Ok(acp::CreateTerminalResponse::new(terminal_id)) } async fn kill_terminal_command( @@ -1006,10 +957,7 @@ impl acp::Client for ClientDelegate { })?? .await; - Ok(acp::WaitForTerminalExitResponse { - exit_status, - meta: None, - }) + Ok(acp::WaitForTerminalExitResponse::new(exit_status)) } } diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index ac79ab7484de90a84ce3d6720f54bcec6addc6b5..f49dce59c4282eb278e16ef664c75ed56652de2e 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -41,7 +41,7 @@ impl AgentServer for ClaudeCode { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -62,7 +62,7 @@ impl AgentServer for ClaudeCode { settings .as_ref() - .and_then(|s| s.default_model.clone().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { diff --git a/crates/agent_servers/src/codex.rs b/crates/agent_servers/src/codex.rs index ec01cd4e523b5696b2f09b5e51e7137fcfb16c91..d14d2f0c9aeb499624943962437821d571bc0299 100644 --- a/crates/agent_servers/src/codex.rs +++ b/crates/agent_servers/src/codex.rs @@ -42,7 +42,7 @@ impl AgentServer for Codex { settings .as_ref() - .and_then(|s| s.default_mode.clone().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode.clone().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -63,7 +63,7 @@ impl AgentServer for Codex { settings .as_ref() - .and_then(|s| s.default_model.clone().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model.clone().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index e7625c2cc06095c9a24a2537e4e83bced26d73f3..634b31e90267e064f0d0df9b6014d279a44a7986 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -44,7 +44,7 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_mode().map(|m| acp::SessionModeId(m.into()))) + .and_then(|s| s.default_mode().map(acp::SessionModeId::new)) } fn set_default_mode(&self, mode_id: Option, fs: Arc, cx: &mut App) { @@ -80,7 +80,7 @@ impl crate::AgentServer for CustomAgentServer { settings .as_ref() - .and_then(|s| s.default_model().map(|m| acp::ModelId(m.into()))) + .and_then(|s| s.default_model().map(acp::ModelId::new)) } fn set_default_model(&self, model_id: Option, fs: Arc, cx: &mut App) { diff --git a/crates/agent_servers/src/e2e_tests.rs b/crates/agent_servers/src/e2e_tests.rs index 824b999bdaff46cf3ad3a570b62fecd596612563..9db7535b5e55d88d6856774c20365bbac46fc81e 100644 --- a/crates/agent_servers/src/e2e_tests.rs +++ b/crates/agent_servers/src/e2e_tests.rs @@ -82,26 +82,9 @@ where .update(cx, |thread, cx| { thread.send( vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "Read the file ".into(), - annotations: None, - meta: None, - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: "foo.rs".into(), - name: "foo.rs".into(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), - acp::ContentBlock::Text(acp::TextContent { - text: " and tell me what the content of the println! is".into(), - annotations: None, - meta: None, - }), + "Read the file ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("foo.rs", "foo.rs")), + " and tell me what the content of the println! is".into(), ], cx, ) @@ -429,7 +412,7 @@ macro_rules! common_e2e_tests { async fn tool_call_with_permission(cx: &mut ::gpui::TestAppContext) { $crate::e2e_tests::test_tool_call_with_permission( $server, - ::agent_client_protocol::PermissionOptionId($allow_option_id.into()), + ::agent_client_protocol::PermissionOptionId::new($allow_option_id), cx, ) .await; diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 6fb94dfb6b84826d715e9b28163e9968fc2df3b9..53f24947658be8def877eb6b3a7d4e29b541d0c0 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -432,24 +432,11 @@ mod tests { let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let tool_call = acp::ToolCall { - id: acp::ToolCallId("tool".into()), - title: "Tool call".into(), - kind: acp::ToolKind::Other, - status: acp::ToolCallStatus::InProgress, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/hello.txt".into(), - old_text: Some("hi world".into()), - new_text: "hello world".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call = acp::ToolCall::new("tool", "Tool call") + .status(acp::ToolCallStatus::InProgress) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/hello.txt", "hello world").old_text("hi world"), + )]); let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index facb86f3b87e746d35d8b91f27550e351b10e8b6..ae634e45dc17cc471d9ac621faf5b98c0a754c2b 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -225,8 +225,13 @@ impl MessageEditor { .iter() .find(|command| command.name == command_name)?; - let acp::AvailableCommandInput::Unstructured { mut hint } = - available_command.input.clone()?; + let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput { + mut hint, + .. + }) = available_command.input.clone()? + else { + return None; + }; let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize; if hint_pos > snapshot.len() { @@ -403,34 +408,28 @@ impl MessageEditor { } => { all_tracked_buffers.extend(tracked_buffers.iter().cloned()); if supports_embedded_context { - acp::ContentBlock::Resource(acp::EmbeddedResource { - annotations: None, - resource: - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - mime_type: None, - text: content.clone(), - uri: uri.to_uri().to_string(), - meta: None, - }, + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new( + content.clone(), + uri.to_uri().to_string(), ), - meta: None, - }) + ), + )) } else { - acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }) + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )) } } Mention::Image(mention_image) => { - let uri = match uri { + let mut image = acp::ImageContent::new( + mention_image.data.clone(), + mention_image.format.mime_type(), + ); + + if let Some(uri) = match uri { MentionUri::File { .. } => Some(uri.to_uri().to_string()), MentionUri::PastedImage => None, other => { @@ -440,25 +439,14 @@ impl MessageEditor { ); None } + } { + image = image.uri(uri) }; - acp::ContentBlock::Image(acp::ImageContent { - annotations: None, - data: mention_image.data.to_string(), - mime_type: mention_image.format.mime_type().into(), - uri, - meta: None, - }) + acp::ContentBlock::Image(image) } - Mention::Link => acp::ContentBlock::ResourceLink(acp::ResourceLink { - name: uri.name(), - uri: uri.to_uri().to_string(), - annotations: None, - description: None, - mime_type: None, - size: None, - title: None, - meta: None, - }), + Mention::Link => acp::ContentBlock::ResourceLink( + acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), + ), }; chunks.push(chunk); ix = crease_range.end.0; @@ -746,8 +734,7 @@ impl MessageEditor { uri, data, mime_type, - annotations: _, - meta: _, + .. }) => { let mention_uri = if let Some(uri) = uri { MentionUri::parse(&uri, path_style) @@ -773,7 +760,7 @@ impl MessageEditor { }), )); } - acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => {} + _ => {} } } @@ -1092,12 +1079,7 @@ mod tests { assert!(error_message.contains("Available commands: none")); // Now simulate Claude providing its list of available commands (which doesn't include file) - available_commands.replace(vec![acp::AvailableCommand { - name: "help".to_string(), - description: "Get help".to_string(), - input: None, - meta: None, - }]); + available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]); // Test that unsupported slash commands trigger an error when we have a list of available commands editor.update_in(cx, |editor, window, cx| { @@ -1211,20 +1193,12 @@ mod tests { let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default())); let available_commands = Rc::new(RefCell::new(vec![ - acp::AvailableCommand { - name: "quick-math".to_string(), - description: "2 + 2 = 4 - 1 = 3".to_string(), - input: None, - meta: None, - }, - acp::AvailableCommand { - name: "say-hello".to_string(), - description: "Say hello to whoever you want".to_string(), - input: Some(acp::AvailableCommandInput::Unstructured { - hint: "".to_string(), - }), - meta: None, - }, + acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"), + acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input( + acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new( + "", + )), + ), ])); let editor = workspace.update_in(&mut cx, |workspace, window, cx| { @@ -1504,12 +1478,12 @@ mod tests { editor.set_text("", window, cx); }); - prompt_capabilities.replace(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }); + prompt_capabilities.replace( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ); cx.simulate_input("Lorem "); @@ -1960,11 +1934,9 @@ mod tests { cx, ); // Enable embedded context so files are actually included - editor.prompt_capabilities.replace(acp::PromptCapabilities { - embedded_context: true, - meta: None, - ..Default::default() - }); + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)); editor }) }); @@ -2043,7 +2015,7 @@ mod tests { // Create a thread metadata to insert as summary let thread_metadata = agent::DbThreadMetadata { - id: acp::SessionId("thread-123".into()), + id: acp::SessionId::new("thread-123"), title: "Previous Conversation".into(), updated_at: chrono::Utc::now(), }; @@ -2150,14 +2122,7 @@ mod tests { .await .unwrap(); - assert_eq!( - content, - vec![acp::ContentBlock::Text(acp::TextContent { - text: "してhello world".into(), - annotations: None, - meta: None - })] - ); + assert_eq!(content, vec!["してhello world".into()]); } #[gpui::test] @@ -2236,38 +2201,24 @@ mod tests { .0; let main_rs_uri = if cfg!(windows) { - "file:///C:/project/src/main.rs".to_string() + "file:///C:/project/src/main.rs" } else { - "file:///project/src/main.rs".to_string() + "file:///project/src/main.rs" }; // When embedded context is `false` we should get a resource link pretty_assertions::assert_eq!( content, vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "What is in ".to_string(), - annotations: None, - meta: None - }), - acp::ContentBlock::ResourceLink(acp::ResourceLink { - uri: main_rs_uri.clone(), - name: "main.rs".to_string(), - annotations: None, - meta: None, - description: None, - mime_type: None, - size: None, - title: None, - }) + "What is in ".into(), + acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri)) ] ); message_editor.update(cx, |editor, _cx| { - editor.prompt_capabilities.replace(acp::PromptCapabilities { - embedded_context: true, - ..Default::default() - }) + editor + .prompt_capabilities + .replace(acp::PromptCapabilities::new().embedded_context(true)) }); let content = message_editor @@ -2280,23 +2231,12 @@ mod tests { pretty_assertions::assert_eq!( content, vec![ - acp::ContentBlock::Text(acp::TextContent { - text: "What is in ".to_string(), - annotations: None, - meta: None - }), - acp::ContentBlock::Resource(acp::EmbeddedResource { - resource: acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents { - text: file_content.to_string(), - uri: main_rs_uri, - mime_type: None, - meta: None - } - ), - annotations: None, - meta: None - }) + "What is in ".into(), + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(file_content, main_rs_uri) + ) + )) ] ); } diff --git a/crates/agent_ui/src/acp/model_selector.rs b/crates/agent_ui/src/acp/model_selector.rs index 8a0c3c9df90e73d0ef00ecd7232115729dd35347..f9710ad9b3aac29546dbe66a518a198d9b113385 100644 --- a/crates/agent_ui/src/acp/model_selector.rs +++ b/crates/agent_ui/src/acp/model_selector.rs @@ -464,7 +464,7 @@ mod tests { models .into_iter() .map(|model| acp_thread::AgentModelInfo { - id: acp::ModelId(model.to_string().into()), + id: acp::ModelId::new(model.to_string()), name: model.to_string().into(), description: None, icon: None, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 9c4717c5189eb3397ec153560156d9c77e5125ba..aedb96bb82f07723f934d0ec73aa1fd545461f00 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1476,18 +1476,8 @@ impl AcpThreadView { .iter() .any(|method| method.id.0.as_ref() == "claude-login") { - available_commands.push(acp::AvailableCommand { - name: "login".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); - available_commands.push(acp::AvailableCommand { - name: "logout".to_owned(), - description: "Authenticate".to_owned(), - input: None, - meta: None, - }); + available_commands.push(acp::AvailableCommand::new("login", "Authenticate")); + available_commands.push(acp::AvailableCommand::new("logout", "Authenticate")); } let has_commands = !available_commands.is_empty(); @@ -2562,7 +2552,7 @@ impl AcpThreadView { acp::ToolKind::Think => IconName::ToolThink, acp::ToolKind::Fetch => IconName::ToolWeb, acp::ToolKind::SwitchMode => IconName::ArrowRightLeft, - acp::ToolKind::Other => IconName::ToolHammer, + acp::ToolKind::Other | _ => IconName::ToolHammer, }) } .size(IconSize::Small) @@ -2814,7 +2804,7 @@ impl AcpThreadView { }) .gap_0p5() .children(options.iter().map(move |option| { - let option_id = SharedString::from(option.id.0.clone()); + let option_id = SharedString::from(option.option_id.0.clone()); Button::new((option_id, entry_ix), option.name.clone()) .map(|this| { let (this, action) = match option.kind { @@ -2830,7 +2820,7 @@ impl AcpThreadView { this.icon(IconName::Close).icon_color(Color::Error), Some(&RejectOnce as &dyn Action), ), - acp::PermissionOptionKind::RejectAlways => { + acp::PermissionOptionKind::RejectAlways | _ => { (this.icon(IconName::Close).icon_color(Color::Error), None) } }; @@ -2855,7 +2845,7 @@ impl AcpThreadView { .label_size(LabelSize::Small) .on_click(cx.listener({ let tool_call_id = tool_call_id.clone(); - let option_id = option.id.clone(); + let option_id = option.option_id.clone(); let option_kind = option.kind; move |this, _, window, cx| { this.authorize_tool_call( @@ -3543,7 +3533,7 @@ impl AcpThreadView { ); this.authenticate( - acp::AuthMethodId(method_id.clone()), + acp::AuthMethodId::new(method_id.clone()), window, cx, ) @@ -3837,10 +3827,6 @@ impl AcpThreadView { .text_xs() .text_color(cx.theme().colors().text_muted) .child(match entry.status { - acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element(), acp::PlanEntryStatus::InProgress => { Icon::new(IconName::TodoProgress) .size(IconSize::Small) @@ -3854,6 +3840,12 @@ impl AcpThreadView { .color(Color::Success) .into_any_element() } + acp::PlanEntryStatus::Pending | _ => { + Icon::new(IconName::TodoPending) + .size(IconSize::Small) + .color(Color::Muted) + .into_any_element() + } }) .child(MarkdownElement::new( entry.content.clone(), @@ -4427,7 +4419,7 @@ impl AcpThreadView { self.authorize_tool_call( tool_call.id.clone(), - option.id.clone(), + option.option_id.clone(), option.kind, window, cx, @@ -6243,27 +6235,18 @@ pub(crate) mod tests { async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) { init_test(cx); - let tool_call_id = acp::ToolCallId("1".into()); - let tool_call = acp::ToolCall { - id: tool_call_id.clone(), - title: "Label".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Pending, - content: vec!["hi".into()], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - }; + let tool_call_id = acp::ToolCallId::new("1"); + let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Label") + .kind(acp::ToolKind::Edit) + .content(vec!["hi".into()]); let connection = StubAgentConnection::new().with_permission_requests(HashMap::from_iter([( tool_call_id, - vec![acp::PermissionOption { - id: acp::PermissionOptionId("1".into()), - name: "Allow".into(), - kind: acp::PermissionOptionKind::AllowOnce, - meta: None, - }], + vec![acp::PermissionOption::new( + "1".into(), + "Allow", + acp::PermissionOptionKind::AllowOnce, + )], )])); connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(tool_call)]); @@ -6482,10 +6465,7 @@ pub(crate) mod tests { fn default_response() -> Self { let conn = StubAgentConnection::new(); conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: "Default response".into(), - meta: None, - }, + acp::ContentChunk::new("Default response".into()), )]); Self::new(conn) } @@ -6542,13 +6522,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6606,13 +6586,13 @@ pub(crate) mod tests { self, project, action_log, - SessionId("test".into()), - watch::Receiver::constant(acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - meta: None, - }), + SessionId::new("test"), + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), cx, ) }))) @@ -6636,10 +6616,7 @@ pub(crate) mod tests { _params: acp::PromptRequest, _cx: &mut App, ) -> Task> { - Task::ready(Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Refusal, - meta: None, - })) + Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::Refusal))) } fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { @@ -6707,24 +6684,14 @@ pub(crate) mod tests { .unwrap(); // First user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool1".into()), - title: "Edit file 1".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test1.txt".into(), - old_text: Some("old content 1".into()), - new_text: "new content 1".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool1", "Edit file 1") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test1.txt", "new content 1").old_text("old content 1"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Give me a diff", cx)) @@ -6750,24 +6717,14 @@ pub(crate) mod tests { }); // Second user message - connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(acp::ToolCall { - id: acp::ToolCallId("tool2".into()), - title: "Edit file 2".into(), - kind: acp::ToolKind::Edit, - status: acp::ToolCallStatus::Completed, - content: vec![acp::ToolCallContent::Diff { - diff: acp::Diff { - path: "/project/test2.txt".into(), - old_text: Some("old content 2".into()), - new_text: "new content 2".into(), - meta: None, - }, - }], - locations: vec![], - raw_input: None, - raw_output: None, - meta: None, - })]); + connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall( + acp::ToolCall::new("tool2", "Edit file 2") + .kind(acp::ToolKind::Edit) + .status(acp::ToolCallStatus::Completed) + .content(vec![acp::ToolCallContent::Diff( + acp::Diff::new("/project/test2.txt", "new content 2").old_text("old content 2"), + )]), + )]); thread .update(cx, |thread, cx| thread.send_raw("Another one", cx)) @@ -6841,14 +6798,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; @@ -6934,14 +6884,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = @@ -6981,14 +6924,7 @@ pub(crate) mod tests { // Send connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "New Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("New Response".into()), )]); user_message_editor.update_in(cx, |_editor, window, cx| { @@ -7076,14 +7012,7 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Response".into())), cx, ); connection.end_turn(session_id, acp::StopReason::EndTurn); @@ -7135,10 +7064,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "Message 1 resp".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 1 resp".into(), + )), cx, ); }); @@ -7172,10 +7100,7 @@ pub(crate) mod tests { // Simulate a response sent after beginning to cancel connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "onse".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("onse".into())), cx, ); }); @@ -7206,10 +7131,9 @@ pub(crate) mod tests { cx.update(|_, cx| { connection.send_update( session_id.clone(), - acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk { - content: "Message 2 response".into(), - meta: None, - }), + acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new( + "Message 2 response".into(), + )), cx, ); connection.end_turn(session_id.clone(), acp::StopReason::EndTurn); @@ -7248,14 +7172,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; @@ -7334,14 +7251,7 @@ pub(crate) mod tests { let connection = StubAgentConnection::new(); connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( - acp::ContentChunk { - content: acp::ContentBlock::Text(acp::TextContent { - text: "Response".into(), - annotations: None, - meta: None, - }), - meta: None, - }, + acp::ContentChunk::new("Response".into()), )]); let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 84c47766e96948bccfc01f3b4472b5100c4b7b64..c4d076037f637ffdf2b8d4c8bbed05349d9ea38e 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -261,7 +261,7 @@ impl ExampleContext { .expect("Unknown tool_name content in meta"); tool_uses_by_id.insert( - tool_call.id, + tool_call.tool_call_id, ToolUse { name: tool_name.to_string(), value: tool_call.raw_input.unwrap_or_default(), @@ -277,7 +277,9 @@ impl ExampleContext { ThreadEvent::ToolCallUpdate(tool_call_update) => { if let acp_thread::ToolCallUpdate::UpdateFields(update) = tool_call_update { if let Some(raw_input) = update.fields.raw_input { - if let Some(tool_use) = tool_uses_by_id.get_mut(&update.id) { + if let Some(tool_use) = + tool_uses_by_id.get_mut(&update.tool_call_id) + { tool_use.value = raw_input; } } @@ -290,7 +292,7 @@ impl ExampleContext { update.fields.status == Some(acp::ToolCallStatus::Completed); let tool_use = tool_uses_by_id - .remove(&update.id) + .remove(&update.tool_call_id) .expect("Unrecognized tool call completed"); let log_message = if succeeded { @@ -337,10 +339,7 @@ impl ExampleContext { acp::StopReason::MaxTurnRequests => { return Err(anyhow!("Exceeded maximum turn requests")); } - acp::StopReason::Refusal => { - return Err(anyhow!("Refusal")); - } - acp::StopReason::Cancelled => return Err(anyhow!("Cancelled")), + stop_reason => return Err(anyhow!("{stop_reason:?}")), }, } } diff --git a/crates/eval/src/instance.rs b/crates/eval/src/instance.rs index 99a8af053609b98efe29a179964a38137c4ba021..787d3372c8248a59e74fc67f347d5bf3b064890f 100644 --- a/crates/eval/src/instance.rs +++ b/crates/eval/src/instance.rs @@ -303,13 +303,12 @@ impl ExampleInstance { let context_server_registry = cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); let thread = if let Some(json) = &meta.existing_thread_json { - let session_id = acp::SessionId( + let session_id = acp::SessionId::new( rand::rng() .sample_iter(&distr::Alphanumeric) .take(7) .map(char::from) - .collect::() - .into(), + .collect::(), ); let db_thread = agent::DbThread::from_json(json.as_bytes()).expect("Can't read serialized thread"); @@ -640,7 +639,7 @@ impl agent::ThreadEnvironment for EvalThreadEnvironment { cx.spawn(async move |cx| { let language_registry = project.read_with(cx, |project, _cx| project.languages().clone())?; - let id = acp::TerminalId(uuid::Uuid::new_v4().to_string().into()); + let id = acp::TerminalId::new(uuid::Uuid::new_v4().to_string()); let terminal = acp_thread::create_terminal_entity(command, &[], vec![], cwd.clone(), &project, cx) .await?; From 4e8f6ddae974694f893e5a636a5d6b00cc05e775 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Wed, 3 Dec 2025 12:56:16 +0100 Subject: [PATCH 29/67] git: Fix unwrap in `git2::Index::get_path` (#44059) Fixes ZED-1VR Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/git/src/repository.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 4f11819f1097617a6b416fa8e991072d595db38a..23c5795209c1eda9acbf4fe9f48a4e3de898a89a 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -967,7 +967,15 @@ impl GitRepository for RealGitRepository { index.read(false)?; const STAGE_NORMAL: i32 = 0; - let oid = match index.get_path(path.as_std_path(), STAGE_NORMAL) { + let path = path.as_std_path(); + // `RepoPath` contains a `RelPath` which normalizes `.` into an empty path + // `get_path` unwraps on empty paths though, so undo that normalization here + let path = if path.components().next().is_none() { + ".".as_ref() + } else { + path + }; + let oid = match index.get_path(path, STAGE_NORMAL) { Some(entry) if entry.mode != GIT_MODE_SYMLINK => entry.id, _ => return Ok(None), }; From a6882391137bdcd03e93925153c61bf2d08584ef Mon Sep 17 00:00:00 2001 From: Alexander Andreev <117519751+alkasadist@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:51:18 +0300 Subject: [PATCH 30/67] python: Fix autocomplete sorting (#44050) Closes: #38727 (Python autocompletion being sorted alphabetically) Release Notes: - Improve sort order of pyright/basedpyright code completions --- crates/languages/src/python.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index bcdc7969b4f2b22f5136c733afd477f7d0cf0187..db61d5902d3f18444988caa0596f998f61636cee 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -101,9 +101,41 @@ impl FromStr for TestRunner { /// The problem with it is that Pyright adjusts the sort text based on previous resolutions (items for which we've issued `completion/resolve` call have their sortText adjusted), /// which - long story short - makes completion items list non-stable. Pyright probably relies on VSCode's implementation detail. /// see https://github.com/microsoft/pyright/blob/95ef4e103b9b2f129c9320427e51b73ea7cf78bd/packages/pyright-internal/src/languageService/completionProvider.ts#LL2873 +/// +/// upd 02.12.25: +/// Decided to ignore Pyright's sortText() completely and to manually sort all entries fn process_pyright_completions(items: &mut [lsp::CompletionItem]) { for item in items { - item.sort_text.take(); + let is_dunder = item.label.starts_with("__") && item.label.ends_with("__"); + + let visibility_priority = if is_dunder { + '3' + } else if item.label.starts_with("__") { + '2' // private non-dunder + } else if item.label.starts_with('_') { + '1' // protected + } else { + '0' // public + }; + + // Kind priority within same visibility level + let kind_priority = match item.kind { + Some(lsp::CompletionItemKind::ENUM_MEMBER) => '0', + Some(lsp::CompletionItemKind::FIELD) => '1', + Some(lsp::CompletionItemKind::PROPERTY) => '2', + Some(lsp::CompletionItemKind::VARIABLE) => '3', + Some(lsp::CompletionItemKind::CONSTANT) => '4', + Some(lsp::CompletionItemKind::METHOD) => '5', + Some(lsp::CompletionItemKind::FUNCTION) => '5', + Some(lsp::CompletionItemKind::CLASS) => '6', + Some(lsp::CompletionItemKind::MODULE) => '7', + _ => '8', + }; + + item.sort_text = Some(format!( + "{}{}{}", + visibility_priority, kind_priority, item.label + )); } } From bf878e9a953f435311572b2b28c7b7beb32262fe Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 3 Dec 2025 08:55:58 -0500 Subject: [PATCH 31/67] Remove unnecessary variable redeclaration (#44074) Release Notes: - N/A --- crates/git_ui/src/git_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index bd17788506faa62f33618d4450000af1e7b8aec9..1c9b817be2507f806eab505555163f72b2fd148a 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4014,7 +4014,7 @@ impl GitPanel { context_menu.action("Add to .gitignore", git::AddToGitignore.boxed_clone()) } - let mut context_menu = context_menu + context_menu = context_menu .separator() .action("Open Diff", Confirm.boxed_clone()) .action("Open File", SecondaryConfirm.boxed_clone()); From 95a553ea9416338fdde84b6d52be751dbc4f4c9e Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 3 Dec 2025 11:26:40 -0300 Subject: [PATCH 32/67] Do not report rejected sweep predictions to cloud (#44075) Release Notes: - N/A Co-authored-by: MrSubidubi --- crates/zeta/src/zeta.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index dba90abbc839566781d18308e53c4b0faa96e1d7..33d37d9e3aa0c5c89830d5ec86663330da1daf77 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -998,6 +998,11 @@ impl Zeta { reason: EditPredictionRejectReason, was_shown: bool, ) { + match self.edit_prediction_model { + ZetaEditPredictionModel::Zeta1 | ZetaEditPredictionModel::Zeta2 => {} + ZetaEditPredictionModel::Sweep => return, + } + self.reject_predictions_tx .unbounded_send(EditPredictionRejection { request_id: prediction_id.to_string(), From 8ca2571367031b0da84dd9c22e628b78cb83a343 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 3 Dec 2025 16:05:15 +0100 Subject: [PATCH 33/67] extension_ci: Do not trigger version bump on workflow file changes (#44077) Release Notes: - N/A Co-authored-by: Agus Zubiaga --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- extensions/workflows/bump_version.yml | 3 +++ .../src/tasks/workflows/extensions/bump_version.rs | 12 +++++++++--- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c535c27415b776e3b4210a236f39a6f6d376954..1f56fec38b0267d1fe920f8ed89af98644bdd5ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6972,7 +6972,7 @@ dependencies = [ [[package]] name = "gh-workflow" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=e5f883040530b4df36437f140084ee5cc7c1c9be#e5f883040530b4df36437f140084ee5cc7c1c9be" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" dependencies = [ "async-trait", "derive_more 2.0.1", @@ -6989,7 +6989,7 @@ dependencies = [ [[package]] name = "gh-workflow-macros" version = "0.8.0" -source = "git+https://github.com/zed-industries/gh-workflow?rev=e5f883040530b4df36437f140084ee5cc7c1c9be#e5f883040530b4df36437f140084ee5cc7c1c9be" +source = "git+https://github.com/zed-industries/gh-workflow?rev=09acfdf2bd5c1d6254abefd609c808ff73547b2c#09acfdf2bd5c1d6254abefd609c808ff73547b2c" dependencies = [ "heck 0.5.0", "quote", diff --git a/Cargo.toml b/Cargo.toml index 6cd80981ce62a245310e6e1a1d447bdc804aa32a..a6512c79093c197f5ed7a195f78bf7a170a15abe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -508,7 +508,7 @@ fork = "0.4.0" futures = "0.3" futures-batch = "0.6.1" futures-lite = "1.13" -gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "e5f883040530b4df36437f140084ee5cc7c1c9be" } +gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "09acfdf2bd5c1d6254abefd609c808ff73547b2c" } git2 = { version = "0.20.1", default-features = false } globset = "0.4" handlebars = "4.3" diff --git a/extensions/workflows/bump_version.yml b/extensions/workflows/bump_version.yml index ad231298ec3848b165d8fd07ee664cb88ba6430d..7f4318dcf54ad8c9360ae622354530b2b54c6a03 100644 --- a/extensions/workflows/bump_version.yml +++ b/extensions/workflows/bump_version.yml @@ -8,6 +8,9 @@ on: push: branches: - main + paths-ignore: + - .github/** + workflow_dispatch: {} jobs: determine_bump_type: runs-on: namespace-profile-16x32-ubuntu-2204 diff --git a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs index 44c72a11648fb1392d78437113fdf72148b9abed..1564fef448fc305897b9edcd64245255b8e0b168 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/bump_version.rs @@ -1,5 +1,6 @@ use gh_workflow::{ - Event, Expression, Input, Job, PullRequest, PullRequestType, Push, Run, Step, UsesJob, Workflow, + Event, Expression, Input, Job, PullRequest, PullRequestType, Push, Run, Step, UsesJob, + Workflow, WorkflowDispatch, }; use indexmap::IndexMap; use indoc::indoc; @@ -18,8 +19,13 @@ pub(crate) fn bump_version() -> Workflow { named::workflow() .on(Event::default() - .push(Push::default().add_branch("main")) - .pull_request(PullRequest::default().add_type(PullRequestType::Labeled))) + .push( + Push::default() + .add_branch("main") + .add_ignored_path(".github/**"), + ) + .pull_request(PullRequest::default().add_type(PullRequestType::Labeled)) + .workflow_dispatch(WorkflowDispatch::default())) .concurrency(one_workflow_per_non_main_branch_and_token("labels")) .add_job(determine_bump_type.name, determine_bump_type.job) .add_job(call_bump_version.name, call_bump_version.job) From 1e09cbfefab07184fdbf05a3d8399308a316c81a Mon Sep 17 00:00:00 2001 From: Xiaobo Liu Date: Wed, 3 Dec 2025 23:08:49 +0800 Subject: [PATCH 34/67] workspace: Scope tab tooltip to tab content only (#44076) Release Notes: - Fixed scope tab tooltip to tab content only Signed-off-by: Xiaobo Liu --- crates/workspace/src/pane.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 8182a7dd88ae2577b577ec0505638dcfcff0084c..5f0fb8ba9647f969b3bea4a83194dd600e1f84aa 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2591,6 +2591,7 @@ impl Pane { let close_side = &settings.close_position; let show_close_button = &settings.show_close_button; let indicator = render_item_indicator(item.boxed_clone(), cx); + let tab_tooltip_content = item.tab_tooltip_content(cx); let item_id = item.item_id(); let is_first_item = ix == 0; let is_last_item = ix == self.items.len() - 1; @@ -2678,12 +2679,6 @@ impl Pane { this.drag_split_direction = None; this.handle_external_paths_drop(paths, window, cx) })) - .when_some(item.tab_tooltip_content(cx), |tab, content| match content { - TabTooltipContent::Text(text) => tab.tooltip(Tooltip::text(text)), - TabTooltipContent::Custom(element_fn) => { - tab.tooltip(move |window, cx| element_fn(window, cx)) - } - }) .start_slot::(indicator) .map(|this| { let end_slot_action: &'static dyn Action; @@ -2750,7 +2745,15 @@ impl Pane { }) .flatten(), ) - .child(label), + .child(label) + .id(("pane-tab-content", ix)) + .map(|this| match tab_tooltip_content { + Some(TabTooltipContent::Text(text)) => this.tooltip(Tooltip::text(text)), + Some(TabTooltipContent::Custom(element_fn)) => { + this.tooltip(move |window, cx| element_fn(window, cx)) + } + None => this, + }), ); let single_entry_to_resolve = (self.items[ix].buffer_kind(cx) == ItemBufferKind::Singleton) From 904d90bee71fe2f4a227f87920629c396a4cbde6 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Wed, 3 Dec 2025 16:13:15 +0100 Subject: [PATCH 35/67] extension_ci: Run tests on pushes to `main` (#44079) This seems sensible to do - it already was the case prior but indirectly, lets rather be explicit about this. Release Notes: - N/A Co-authored-by: Agus Zubiaga --- .github/workflows/extension_bump.yml | 29 ------------------- extensions/workflows/run_tests.yml | 3 ++ .../src/tasks/workflows/extension_bump.rs | 6 +--- .../tasks/workflows/extensions/run_tests.rs | 6 ++-- 4 files changed, 8 insertions(+), 36 deletions(-) diff --git a/.github/workflows/extension_bump.yml b/.github/workflows/extension_bump.yml index 4781014e32f01b473b3358f2a81a3613fe5cdce9..c7582378f1c9e87254e1a0b4e202d9f56b99877b 100644 --- a/.github/workflows/extension_bump.yml +++ b/.github/workflows/extension_bump.yml @@ -25,33 +25,6 @@ on: description: The app secret for the corresponding app ID required: true jobs: - check_extension: - if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') - runs-on: namespace-profile-2x4-ubuntu-2404 - steps: - - name: steps::checkout_repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - with: - clean: false - - id: cache-zed-extension-cli - name: extension_tests::cache_zed_extension_cli - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 - with: - path: zed-extension - key: zed-extension-${{ env.ZED_EXTENSION_CLI_SHA }} - - name: extension_tests::download_zed_extension_cli - if: steps.cache-zed-extension-cli.outputs.cache-hit != 'true' - run: | - wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" - chmod +x zed-extension - shell: bash -euxo pipefail {0} - - name: extension_tests::check - run: | - mkdir -p /tmp/ext-scratch - mkdir -p /tmp/ext-output - ./zed-extension --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output - shell: bash -euxo pipefail {0} - timeout-minutes: 2 check_bump_needed: if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') runs-on: namespace-profile-2x4-ubuntu-2404 @@ -89,7 +62,6 @@ jobs: timeout-minutes: 1 bump_extension_version: needs: - - check_extension - check_bump_needed if: |- (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && @@ -144,7 +116,6 @@ jobs: timeout-minutes: 1 create_version_label: needs: - - check_extension - check_bump_needed if: (github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions') && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check_bump_needed.outputs.needs_bump == 'false' runs-on: namespace-profile-8x16-ubuntu-2204 diff --git a/extensions/workflows/run_tests.yml b/extensions/workflows/run_tests.yml index 28cd288400643052011d4032f6c12b056ac4d301..81ba76c483479ed827f0a91181557a2387b40722 100644 --- a/extensions/workflows/run_tests.yml +++ b/extensions/workflows/run_tests.yml @@ -5,6 +5,9 @@ on: pull_request: branches: - '**' + push: + branches: + - main jobs: call_extension_tests: uses: zed-industries/zed/.github/workflows/extension_tests.yml@main diff --git a/tooling/xtask/src/tasks/workflows/extension_bump.rs b/tooling/xtask/src/tasks/workflows/extension_bump.rs index 356a3c6c782330528c165ebafb54ca23252e35b4..34fcf8099031ec9d5562c76f45073a9936c285ff 100644 --- a/tooling/xtask/src/tasks/workflows/extension_bump.rs +++ b/tooling/xtask/src/tasks/workflows/extension_bump.rs @@ -23,15 +23,12 @@ pub(crate) fn extension_bump() -> Workflow { let force_bump = WorkflowInput::bool("force-bump", None); let (app_id, app_secret) = extension_workflow_secrets(); - - let test_extension = extension_tests::check_extension(); let (check_bump_needed, needs_bump, current_version) = check_bump_needed(); let needs_bump = needs_bump.as_job_output(&check_bump_needed); let current_version = current_version.as_job_output(&check_bump_needed); - let dependencies = [&test_extension, &check_bump_needed]; - + let dependencies = [&check_bump_needed]; let bump_version = bump_extension_version( &dependencies, ¤t_version, @@ -72,7 +69,6 @@ pub(crate) fn extension_bump() -> Workflow { "ZED_EXTENSION_CLI_SHA", extension_tests::ZED_EXTENSION_CLI_SHA, )) - .add_job(test_extension.name, test_extension.job) .add_job(check_bump_needed.name, check_bump_needed.job) .add_job(bump_version.name, bump_version.job) .add_job(create_label.name, create_label.job) diff --git a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs index 4e900e839d917bfa9920b12a4bd4a759fa1f31b7..885a8fd09fe0488c92162a9bccd0f70ed6c7fefd 100644 --- a/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs +++ b/tooling/xtask/src/tasks/workflows/extensions/run_tests.rs @@ -1,4 +1,4 @@ -use gh_workflow::{Event, Job, PullRequest, UsesJob, Workflow}; +use gh_workflow::{Event, Job, PullRequest, Push, UsesJob, Workflow}; use crate::tasks::workflows::{ steps::{NamedJob, named}, @@ -8,7 +8,9 @@ use crate::tasks::workflows::{ pub(crate) fn run_tests() -> Workflow { let call_extension_tests = call_extension_tests(); named::workflow() - .on(Event::default().pull_request(PullRequest::default().add_branch("**"))) + .on(Event::default() + .pull_request(PullRequest::default().add_branch("**")) + .push(Push::default().add_branch("main"))) .concurrency(one_workflow_per_non_main_branch_and_token("pr")) .add_job(call_extension_tests.name, call_extension_tests.job) } From e39dd2af67c7669485463b5c3a90d07e9d680d40 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 3 Dec 2025 10:45:45 -0500 Subject: [PATCH 36/67] Bump Zed to v0.217 (#44080) Release Notes: - N/A --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f56fec38b0267d1fe920f8ed89af98644bdd5ef..3e2f12a91c2b76a393f7f99f68bcd05933cb27f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21205,7 +21205,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.216.0" +version = "0.217.0" dependencies = [ "acp_tools", "activity_indicator", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9e6a6a0fbd10a7695270f2651418d9e2cdc31b4c..3358cc5d32bea308083ae1f6ee06268cf22d670a 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.216.0" +version = "0.217.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] From 7e177c496ccdb626f568d1c7a5e6dd352a373286 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Wed, 3 Dec 2025 16:49:40 +0100 Subject: [PATCH 37/67] markdown_preview: Fix markdown tables taking up the full width of the parent element (#43555) Closes #39152 This PR fixes an issue where we would render Markdown tables full width based on their container size. We now render tables based on their content min size, meaning you are still allowed to make the table render as it was before by making the columns `w_full`. I had to change the `div()` to `v_flex().items_start()` because this introduced a weird displaying behavior of the outside table border, because the grid container was not shrinking due to It was always taking up the full width of their container. **Before** Screenshot 2025-11-26 at 14 37 19 **After** Screenshot 2025-11-26 at 14 56 12 **Code example** ```markdown | Name | Age | Occupation | |:--------:|:-------:|:--------------:| | Alice | 28 | Engineer | | Bob | 34 | Designer | | Carol | 25 | Developer | | Syntax | Description | | ----------- | ----------- | | Header | Title | | Paragraph | Text | | City | Population (approx.) | Known For | |----------------|----------------------|------------------------------------| | New York | 8,500,000 | Statue of Liberty, Wall Street | | Los Angeles | 4,000,000 | Hollywood, film industry | | Chicago | 2,700,000 | Architecture, deep-dish pizza | | Houston | 2,300,000 | NASA, energy industry | | Miami | 470,000 | Beaches, Latin culture | | San Francisco | 800,000 | Golden Gate Bridge, Silicon Valley | | Las Vegas | 650,000 | Casinos, nightlife |
Table Caption
ID asjkfjaslkf jalksjflksajflka jlksdla k Name
1 Chris
2 Dennis
3 Sarah
4 Karen
``` cc @bennetbo Release Notes: - Markdown Preview: Markdown tables scale now based on their content size --- crates/gpui/src/taffy.rs | 7 ++++--- crates/markdown_preview/src/markdown_renderer.rs | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 11cb0872861321c3c06c3f8a5bf79fdd30eb2275..c3113ad2cb91ad8c9e29360812716114a7427052 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -8,6 +8,7 @@ use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, geometry::{Point as TaffyPoint, Rect as TaffyRect, Size as TaffySize}, + prelude::min_content, style::AvailableSpace as TaffyAvailableSpace, tree::NodeId, }; @@ -295,7 +296,7 @@ trait ToTaffy { impl ToTaffy for Style { fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Style { - use taffy::style_helpers::{fr, length, minmax, repeat}; + use taffy::style_helpers::{length, minmax, repeat}; fn to_grid_line( placement: &Range, @@ -309,8 +310,8 @@ impl ToTaffy for Style { fn to_grid_repeat( unit: &Option, ) -> Vec> { - // grid-template-columns: repeat(, minmax(0, 1fr)); - unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])]) + // grid-template-columns: repeat(, minmax(0, min-content)); + unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), min_content())])]) .unwrap_or_default() } diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index b229705692c0fade2b35b4dd9f66a27e2aba57bc..d9997b54274d53e4897b3a3810629054e5458275 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -520,7 +520,6 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - .px_2() .py_1() .border_1() - .size_full() .border_color(cx.border_color) .when(cell.is_header, |this| { this.bg(cx.title_bar_background_color) @@ -551,7 +550,6 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - let empty_cell = div() .border_1() - .size_full() .border_color(cx.border_color) .when(row_idx % 2 == 1, |this| this.bg(cx.panel_background_color)); @@ -560,7 +558,7 @@ fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) - } } - cx.with_common_p(div()) + cx.with_common_p(v_flex().items_start()) .when_some(parsed.caption.as_ref(), |this, caption| { this.children(render_markdown_text(caption, cx)) }) From c248a956e03b866f57544d45bde0efe2c1934c92 Mon Sep 17 00:00:00 2001 From: Arthur Schurhaus <95943247+artschur@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:58:51 -0300 Subject: [PATCH 38/67] markdown: Fix rendering of inline HTML tags (#43513) Added support for rendering HTML ` `tags inside Markdown content. Previously, these tags were ignored by the renderer and displayed as raw text (inside LSP hover documentation). Closes: #43166 Release Notes: - Fixed styling of `` HTML tags in Markdown popovers. Before: image After: image --- crates/markdown/src/markdown.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index dd0d726734173591cb9ed9f8cc965d06aaee7e89..3f7d8e0d29eca1fff4af2b34c0bac9f32b4d730d 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1202,6 +1202,15 @@ impl Element for MarkdownElement { builder.push_text(html, range.clone()); } MarkdownEvent::InlineHtml => { + let html = &parsed_markdown.source[range.clone()]; + if html.starts_with("") { + builder.push_text_style(self.style.inline_code.clone()); + continue; + } + if html.trim_end().starts_with("") { + builder.pop_text_style(); + continue; + } builder.push_text(&parsed_markdown.source[range.clone()], range.clone()); } MarkdownEvent::Rule => { From 621ac16e35dfb57346ff9262531e2b1d43010579 Mon Sep 17 00:00:00 2001 From: Jeff Brennan <42007840+jeffbrennan@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:32:51 -0500 Subject: [PATCH 39/67] go: Fix language injections (#43775) Closes #43730 ## Summary This modifies the existing injections.scm file for go by adding more specific prefix queries and *_content nodes to the existing `raw_string_literal` and `interpreted_string_literal` sections

This PR image image image
Current Release (0.214.7) image image image
Code ```go func test_sql() { // const assignment const _ = /* sql */ "SELECT * FROM users" const _ = /* sql */ `SELECT id, name FROM products` // var assignment var _ = /* sql */ `SELECT id, name FROM products` var _ = /* sql */ "SELECT id, name FROM products" // := assignment test := /* sql */ "SELECT * FROM users" test2 := /* sql */ `SELECT * FROM users` println(test) println(test2) // = assignment _ = /* sql */ "SELECT * FROM users WHERE id = 1" _ = /* sql */ `SELECT * FROM users WHERE id = 1` // literal elements _ = testStruct{Field: /* sql */ "SELECT * FROM users"} _ = testStruct{Field: /* sql */ `SELECT * FROM users`} testFunc(/* sql */ "SELECT * FROM users") testFunc(/* sql */ `SELECT * FROM users`) const backtickString = /* sql */ `SELECT * FROM users;` const quotedString = /* sql */ "SELECT * FROM users;" const backtickStringNoHighlight = `SELECT * FROM users;` const quotedStringNoHighlight = "SELECT * FROM users;" } func test_yaml() { // const assignment const _ = /* yaml */ ` settings: enabled: true port: 8080 ` // := assignment test := /* yaml */ ` settings: enabled: true port: 8080 ` println(test) // = assignment _ = /* yaml */ ` settings: enabled: true port: 8080 ` // literal elements in a struct _ = testStruct{Field: /* yaml */ ` settings: test: 1234 port: 8080 `} // function argument testFunc(/* yaml */ ` settings: enabled: true port: 8080 `) } func test_css() { // const assignment const _ = /* css */ "body { margin: 0; }" const _ = /* css */ `body { margin: 0; }` const cssCodes = /* css */ ` h1 { color: #333; } ` // := assignment test := /* css */ "body { margin: 0; }" println(test) // = assignment _ = /* css */ "body { margin: 0; }" _ = /* css */ `body { margin: 0; }` // literal elements _ = testStruct{Field: /* css */ "body { margin: 0; }"} _ = testStruct{Field: /* css */ `body { margin: 0; }`} testFunc(/* css */ "body { margin: 0; }") testFunc(/* css */ `body { margin: 0; }`) const backtickString = /* css */ `body { margin: 0; }` const quotedString = /* css */ "body { margin: 0; }" const backtickStringNoHighlight = `body { margin: 0; }` const quotedStringNoHighlight = "body { margin: 0; }" } ```
Release Notes: - Greatly improved the quality of comment-directed language injections in Go --- crates/languages/src/go/injections.scm | 959 +++++++++++++++++-------- 1 file changed, 658 insertions(+), 301 deletions(-) diff --git a/crates/languages/src/go/injections.scm b/crates/languages/src/go/injections.scm index 52edce417798bcc8cd9cbc38ba3443ff3fc561c6..58583f4d22c7db8016397d8e47cd817b7c240764 100644 --- a/crates/languages/src/go/injections.scm +++ b/crates/languages/src/go/injections.scm @@ -19,360 +19,717 @@ ; INJECT SQL ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*sql\\s*\\*\\/") ; /* sql */ or /*sql*/ - (#set! injection.language "sql") + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*sql\\s*\\*\\/$") + (#set! injection.language "sql") ) ; INJECT JSON ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*json\\s*\\*\\/") ; /* json */ or /*json*/ + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*json\\s*\\*\\/") ; /* json */ or /*json*/ (#set! injection.language "json") ) ; INJECT YAML ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*yaml\\s*\\*\\/") ; /* yaml */ or /*yaml*/ + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*yaml\\s*\\*\\/") ; /* yaml */ or /*yaml*/ (#set! injection.language "yaml") ) ; INJECT XML ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*xml\\s*\\*\\/") ; /* xml */ or /*xml*/ + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*xml\\s*\\*\\/") ; /* xml */ or /*xml*/ (#set! injection.language "xml") ) ; INJECT HTML ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*html\\s*\\*\\/") ; /* html */ or /*html*/ + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*html\\s*\\*\\/") ; /* html */ or /*html*/ (#set! injection.language "html") ) ; INJECT JS ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*js\\s*\\*\\/") ; /* js */ or /*js*/ + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*js\\s*\\*\\/") ; /* js */ or /*js*/ (#set! injection.language "javascript") ) + ; INJECT CSS ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*css\\s*\\*\\/") ; /* css */ or /*css*/ + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*css\\s*\\*\\/") ; /* css */ or /*css*/ (#set! injection.language "css") ) + ; INJECT LUA ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*lua\\s*\\*\\/") ; /* lua */ or /*lua*/ + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*lua\\s*\\*\\/") ; /* lua */ or /*lua*/ (#set! injection.language "lua") ) ; INJECT BASH ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*bash\\s*\\*\\/") ; /* bash */ or /*bash*/ + (composite_literal + body: (literal_value + (keyed_element + (comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )))) + + (expression_statement + (call_expression + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ))) + ] + (#match? @_comment "^\\/\\*\\s*bash\\s*\\*\\/") ; /* bash */ or /*bash*/ (#set! injection.language "bash") ) ; INJECT CSV ( - [ - ; var, const or short declaration of raw or interpreted string literal - ((comment) @comment - . - (expression_list - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a literal element (to struct field eg.) - ((comment) @comment - . - (literal_element - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content - )) - - ; when passing as a function parameter - ((comment) @comment - . - [ - (interpreted_string_literal) - (raw_string_literal) - ] @injection.content) - ] + [ + (const_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) - (#match? @comment "^\\/\\*\\s*csv\\s*\\*\\/") ; /* csv */ or /*csv*/ + (var_spec + name: (identifier) + "=" + (comment) @_comment + value: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (assignment_statement + left: (expression_list) + "=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (short_var_declaration + left: (expression_list) + ":=" + (comment) @_comment + right: (expression_list + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + ((comment) @_comment + value: (literal_element + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + )) + + (argument_list + (comment) @_comment + [ + (interpreted_string_literal (interpreted_string_literal_content) @injection.content) + (raw_string_literal (raw_string_literal_content) @injection.content) + ] + ) + ] + (#match? @_comment "^\\/\\*\\s*csv\\s*\\*\\/") ; /* csv */ or /*csv */ (#set! injection.language "csv") ) From b168679c181bb50e68c8f37e41c6c969c210d379 Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Wed, 3 Dec 2025 17:34:49 +0100 Subject: [PATCH 40/67] language: Remove old unused `HTML/ERB` language ID (#44081) The `HTML/ERB` language was renamed to `HTML+ERB` in https://github.com/zed-industries/zed/pull/40000 We can remove the old name safely now. Release Notes: - N/A --- crates/languages/src/lib.rs | 1 - crates/languages/src/tailwind.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index d27b5ece0d78b15e2207726ceb95114c05fbcbad..9df14fb162e2ed722f5ed7527e179f3aec9b0af6 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -283,7 +283,6 @@ pub fn init(languages: Arc, fs: Arc, node: NodeRuntime "CSS", "ERB", "HTML+ERB", - "HTML/ERB", "HEEX", "HTML", "JavaScript", diff --git a/crates/languages/src/tailwind.rs b/crates/languages/src/tailwind.rs index b0b9132a9ac64ef963463885811e2c23f8e7b5f9..3cf9dd05a165f04dd4be1d1b0a9cf30288db167a 100644 --- a/crates/languages/src/tailwind.rs +++ b/crates/languages/src/tailwind.rs @@ -186,7 +186,6 @@ impl LspAdapter for TailwindLspAdapter { (LanguageName::new("HEEX"), "phoenix-heex".to_string()), (LanguageName::new("ERB"), "erb".to_string()), (LanguageName::new("HTML+ERB"), "erb".to_string()), - (LanguageName::new("HTML/ERB"), "erb".to_string()), (LanguageName::new("PHP"), "php".to_string()), (LanguageName::new("Vue.js"), "vue".to_string()), ]) From 85ccd7c98b0b35381ef77c7383995e892db0689f Mon Sep 17 00:00:00 2001 From: Xipeng Jin <56369076+xipeng-jin@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:59:56 -0500 Subject: [PATCH 41/67] Fix not able to navigate to files in git commit multibuffer (#42558) Closes #40851 Release Notes: - Fixed: Commit diff multibuffers now open real project files whenever possible, restoring navigation and annotations inside those excerpts. --------- Co-authored-by: Anthony Eid --- crates/editor/src/editor.rs | 25 ++++++++++++++++++------- crates/editor/src/items.rs | 13 +++++++++---- crates/git_ui/src/commit_view.rs | 14 +++++++++++++- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6f936a211d3b5eb308b26e4351350666e616bf6c..eba10e4ea2a3663191fc3739b3b2f7f73101b7f5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -21936,10 +21936,17 @@ impl Editor { }; for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { - let editor = buffer - .read(cx) - .file() - .is_none() + let buffer_read = buffer.read(cx); + let (has_file, is_project_file) = if let Some(file) = buffer_read.file() { + (true, project::File::from_dyn(Some(file)).is_some()) + } else { + (false, false) + }; + + // If project file is none workspace.open_project_item will fail to open the excerpt + // in a pre existing workspace item if one exists, because Buffer entity_id will be None + // so we check if there's a tab match in that case first + let editor = (!has_file || !is_project_file) .then(|| { // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, // so `workspace.open_project_item` will never find them, always opening a new editor. @@ -21973,6 +21980,9 @@ impl Editor { }); editor.update(cx, |editor, cx| { + if has_file && !is_project_file { + editor.set_read_only(true); + } let autoscroll = match scroll_offset { Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), None => Autoscroll::newest(), @@ -21996,10 +22006,11 @@ impl Editor { }); } - // For now, don't allow opening excerpts in buffers that aren't backed by - // regular project files. + // Allow opening excerpts for buffers that either belong to the current project + // or represent synthetic/non-local files (e.g., git blobs). File-less buffers + // are also supported so tests and other in-memory views keep working. fn can_open_excerpts_in_file(file: Option<&Arc>) -> bool { - file.is_none_or(|file| project::File::from_dyn(Some(file)).is_some()) + file.is_none_or(|file| project::File::from_dyn(Some(file)).is_some() || !file.is_local()) } fn marked_text_ranges(&self, cx: &App) -> Option>> { diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 8111c837e2ee5c35fdfb120999c2be49b09c468c..4e1305866ee9e4219295c02bdc519b4bc857cddf 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1891,15 +1891,20 @@ fn path_for_buffer<'a>( cx: &'a App, ) -> Option> { let file = buffer.read(cx).as_singleton()?.read(cx).file()?; - path_for_file(file.as_ref(), height, include_filename, cx) + path_for_file(file, height, include_filename, cx) } fn path_for_file<'a>( - file: &'a dyn language::File, + file: &'a Arc, mut height: usize, include_filename: bool, cx: &'a App, ) -> Option> { + if project::File::from_dyn(Some(file)).is_none() { + return None; + } + + let file = file.as_ref(); // Ensure we always render at least the filename. height += 1; @@ -1946,11 +1951,11 @@ mod tests { #[gpui::test] fn test_path_for_file(cx: &mut App) { - let file = TestFile { + let file: Arc = Arc::new(TestFile { path: RelPath::empty().into(), root_name: String::new(), local_root: None, - }; + }); assert_eq!(path_for_file(&file, 0, false, cx), None); } diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 4f6633a18c031b8f231f43f8b0efc13e7fd710a7..31ac8139a63be218f652204ebe29d43e526c5a02 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -68,6 +68,7 @@ struct GitBlob { path: RepoPath, worktree_id: WorktreeId, is_deleted: bool, + display_name: Arc, } const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; @@ -157,6 +158,7 @@ impl CommitView { }); editor }); + let commit_sha = Arc::::from(commit.sha.as_ref()); let first_worktree_id = project .read(cx) @@ -180,10 +182,20 @@ impl CommitView { .or(first_worktree_id) })? .context("project has no worktrees")?; + let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha); + let file_name = file + .path + .file_name() + .map(|name| name.to_string()) + .unwrap_or_else(|| file.path.display(PathStyle::Posix).to_string()); + let display_name: Arc = + Arc::from(format!("{short_sha} - {file_name}").into_boxed_str()); + let file = Arc::new(GitBlob { path: file.path.clone(), is_deleted, worktree_id, + display_name, }) as Arc; let buffer = build_buffer(new_text, file, &language_registry, cx).await?; @@ -647,7 +659,7 @@ impl language::File for GitBlob { } fn file_name<'a>(&'a self, _: &'a App) -> &'a str { - self.path.file_name().unwrap() + self.display_name.as_ref() } fn worktree_id(&self, _: &App) -> WorktreeId { From 575ea49aade6a1224ae2f016678893d55a0ee1cf Mon Sep 17 00:00:00 2001 From: Ramon <55579979+van-sprundel@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:11:53 +0100 Subject: [PATCH 42/67] Fix yank around paragraph missing newline (#43583) Use `MotionKind::LineWise` in both `vim::normal::change::Vim.change_object` and `vim::normal::yank::Vim.yank_object` when dealing with objects that target `Mode::VisualLine`, for example, paragraphs. This fixes an issue where yanking and changing paragraphs would not include the trailing newline character. Closes #28804 Release Notes: - Fixed linewise text object operations (`yap`, `cap`, etc.) omitting trailing blank line in vim mode --------- Co-authored-by: dino --- crates/vim/src/normal/change.rs | 6 +- crates/vim/src/normal/yank.rs | 6 +- crates/vim/src/test.rs | 73 +++++++++++++++++++ .../vim/test_data/test_change_paragraph.json | 8 ++ .../test_yank_paragraph_with_paste.json | 10 +++ 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 crates/vim/test_data/test_change_paragraph.json create mode 100644 crates/vim/test_data/test_yank_paragraph_with_paste.json diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 4735c64792f3639b2c0d6581e6179484e842f386..b0b0bddae19b27fa382d4c84c3fdd4df8ba83a43 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -121,7 +121,11 @@ impl Vim { }); }); if objects_found { - vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx); + let kind = match object.target_visual_mode(vim.mode, around) { + Mode::VisualLine => MotionKind::Linewise, + _ => MotionKind::Exclusive, + }; + vim.copy_selections_content(editor, kind, window, cx); editor.insert("", window, cx); editor.refresh_edit_prediction(true, false, window, cx); } diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index d5a45fca544d61735f62a8f46e849db2c009847f..71ed0d44384a5ed8644f486aa16cdd704e9ce944 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -81,7 +81,11 @@ impl Vim { start_positions.insert(selection.id, start_position); }); }); - vim.yank_selections_content(editor, MotionKind::Exclusive, window, cx); + let kind = match object.target_visual_mode(vim.mode, around) { + Mode::VisualLine => MotionKind::Linewise, + _ => MotionKind::Exclusive, + }; + vim.yank_selections_content(editor, kind, window, cx); editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { s.move_with(|_, selection| { let (head, goal) = start_positions.remove(&selection.id).unwrap(); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 5932a740945becae9d15025d358a52d5a4e279dd..4294b5e1dbdf1a287909bd3ab5770dfcd718f98d 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -2253,6 +2253,79 @@ async fn test_paragraph_multi_delete(cx: &mut gpui::TestAppContext) { cx.shared_state().await.assert_eq(indoc! {"ˇ"}); } +#[perf] +#[gpui::test] +async fn test_yank_paragraph_with_paste(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + " + first paragraph + ˇstill first + + second paragraph + still second + + third paragraph + " + }) + .await; + + cx.simulate_shared_keystrokes("y a p").await; + cx.shared_clipboard() + .await + .assert_eq("first paragraph\nstill first\n\n"); + + cx.simulate_shared_keystrokes("j j p").await; + cx.shared_state().await.assert_eq(indoc! { + " + first paragraph + still first + + ˇfirst paragraph + still first + + second paragraph + still second + + third paragraph + " + }); +} + +#[perf] +#[gpui::test] +async fn test_change_paragraph(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + " + first paragraph + ˇstill first + + second paragraph + still second + + third paragraph + " + }) + .await; + + cx.simulate_shared_keystrokes("c a p").await; + cx.shared_clipboard() + .await + .assert_eq("first paragraph\nstill first\n\n"); + + cx.simulate_shared_keystrokes("escape").await; + cx.shared_state().await.assert_eq(indoc! { + " + ˇ + second paragraph + still second + + third paragraph + " + }); +} + #[perf] #[gpui::test] async fn test_multi_cursor_replay(cx: &mut gpui::TestAppContext) { diff --git a/crates/vim/test_data/test_change_paragraph.json b/crates/vim/test_data/test_change_paragraph.json new file mode 100644 index 0000000000000000000000000000000000000000..6d235d9f367d5c375df59f3567b2ac1435f6a0a7 --- /dev/null +++ b/crates/vim/test_data/test_change_paragraph.json @@ -0,0 +1,8 @@ +{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}} +{"Key":"c"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Insert"}} +{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}} +{"Key":"escape"} +{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_yank_paragraph_with_paste.json b/crates/vim/test_data/test_yank_paragraph_with_paste.json new file mode 100644 index 0000000000000000000000000000000000000000..d73d1f6d3b36e7b1df17559dd525238f13606976 --- /dev/null +++ b/crates/vim/test_data/test_yank_paragraph_with_paste.json @@ -0,0 +1,10 @@ +{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}} +{"Key":"y"} +{"Key":"a"} +{"Key":"p"} +{"Get":{"state":"ˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} +{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}} +{"Key":"j"} +{"Key":"j"} +{"Key":"p"} +{"Get":{"state":"first paragraph\nstill first\n\nˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}} From 6b46a71dd0f0cdd2238e954566a7728d23e3d74a Mon Sep 17 00:00:00 2001 From: Dino Date: Wed, 3 Dec 2025 17:12:04 +0000 Subject: [PATCH 43/67] tab_switcher: Fix bug where selected index after closing tab did not match pane's active item (#44006) Whenever an item is removed using the Tab Switcher, the list of matches is automatically updated, which can lead to the order of the elements being updated and changing in comparison to what the user was previously seeing. Unfortunately this can lead to a situation where the selected index, since it wasn't being updated, would end up in a different item than the one that was actually active in the pane. This Pull Request updates the handling of the `PaneEvent::RemovedItem` event so that the `TabSwitcherDelegate.selected_index` field is automatically updated to match the pane's new active item. Seeing as this is being updated, the `test_close_preserves_selected_position` test is also removed, as it no longer makes sense with the current implementation. I believe a better user experience would be to actually not update the order of the matches, simply removing the ones that no longer exist, and keep the selected index position, but will tackle that in a different Pull Request. Closes #44005 Release Notes: - Fixed a bug with the tab switcher where, after closing a tab, the selected entry would not match the pane's active item --- crates/tab_switcher/src/tab_switcher.rs | 40 +++++++++- crates/tab_switcher/src/tab_switcher_tests.rs | 79 ++++++------------- 2 files changed, 61 insertions(+), 58 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 8ffa33183126cd5578ed7305c3ece3f0821e8d5c..2b98f6c7e329e7f98edb6b6e994de444a8b835da 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -347,11 +347,23 @@ impl TabSwitcherDelegate { }; cx.subscribe_in(&pane, window, |tab_switcher, _, event, window, cx| { match event { - PaneEvent::AddItem { .. } - | PaneEvent::RemovedItem { .. } - | PaneEvent::Remove { .. } => tab_switcher.picker.update(cx, |picker, cx| { + PaneEvent::AddItem { .. } | PaneEvent::Remove { .. } => { + tab_switcher.picker.update(cx, |picker, cx| { + let query = picker.query(cx); + picker.delegate.update_matches(query, window, cx); + cx.notify(); + }) + } + PaneEvent::RemovedItem { .. } => tab_switcher.picker.update(cx, |picker, cx| { let query = picker.query(cx); picker.delegate.update_matches(query, window, cx); + + // When the Tab Switcher is being used and an item is + // removed, there's a chance that the new selected index + // will not match the actual tab that is now being displayed + // by the pane, as such, the selected index needs to be + // updated to match the pane's state. + picker.delegate.sync_selected_index(cx); cx.notify(); }), _ => {} @@ -540,11 +552,33 @@ impl TabSwitcherDelegate { let Some(pane) = tab_match.pane.upgrade() else { return; }; + pane.update(cx, |pane, cx| { pane.close_item_by_id(tab_match.item.item_id(), SaveIntent::Close, window, cx) .detach_and_log_err(cx); }); } + + /// Updates the selected index to ensure it matches the pane's active item, + /// as the pane's active item can be indirectly updated and this method + /// ensures that the picker can react to those changes. + fn sync_selected_index(&mut self, cx: &mut Context>) { + let Ok(Some(item)) = self.pane.read_with(cx, |pane, _cx| pane.active_item()) else { + return; + }; + + let item_id = item.item_id(); + let Some((index, _tab_match)) = self + .matches + .iter() + .enumerate() + .find(|(_index, tab_match)| tab_match.item.item_id() == item_id) + else { + return; + }; + + self.selected_index = index; + } } impl PickerDelegate for TabSwitcherDelegate { diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index 52c96225655d2717879a27f6e7f9bbbe9bc4e7cb..85177f29ed8f39527cdedb991db756bd5f8d08d5 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -5,7 +5,7 @@ use menu::SelectPrevious; use project::{Project, ProjectPath}; use serde_json::json; use util::{path, rel_path::rel_path}; -use workspace::{AppState, Workspace}; +use workspace::{ActivatePreviousItem, AppState, Workspace}; #[ctor::ctor] fn init_logger() { @@ -197,6 +197,8 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { json!({ "1.txt": "First file", "2.txt": "Second file", + "3.txt": "Third file", + "4.txt": "Fourth file", }), ) .await; @@ -206,80 +208,47 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); let tab_1 = open_buffer("1.txt", &workspace, cx).await; + let tab_3 = open_buffer("3.txt", &workspace, cx).await; let tab_2 = open_buffer("2.txt", &workspace, cx).await; + let tab_4 = open_buffer("4.txt", &workspace, cx).await; + + // After opening all buffers, let's navigate to the previous item two times, finishing with: + // + // 1.txt | [3.txt] | 2.txt | 4.txt + // + // With 3.txt being the active item in the pane. + cx.dispatch_action(ActivatePreviousItem); + cx.dispatch_action(ActivatePreviousItem); + cx.run_until_parked(); cx.simulate_modifiers_change(Modifiers::control()); let tab_switcher = open_tab_switcher(false, &workspace, cx); tab_switcher.update(cx, |tab_switcher, _| { - assert_eq!(tab_switcher.delegate.matches.len(), 2); - assert_match_at_position(tab_switcher, 0, tab_2.boxed_clone()); - assert_match_selection(tab_switcher, 1, tab_1.boxed_clone()); + assert_eq!(tab_switcher.delegate.matches.len(), 4); + assert_match_at_position(tab_switcher, 0, tab_3.boxed_clone()); + assert_match_selection(tab_switcher, 1, tab_2.boxed_clone()); + assert_match_at_position(tab_switcher, 2, tab_4.boxed_clone()); + assert_match_at_position(tab_switcher, 3, tab_1.boxed_clone()); }); cx.simulate_modifiers_change(Modifiers::control()); cx.dispatch_action(CloseSelectedItem); tab_switcher.update(cx, |tab_switcher, _| { - assert_eq!(tab_switcher.delegate.matches.len(), 1); - assert_match_selection(tab_switcher, 0, tab_2); + assert_eq!(tab_switcher.delegate.matches.len(), 3); + assert_match_selection(tab_switcher, 0, tab_3); + assert_match_at_position(tab_switcher, 1, tab_4); + assert_match_at_position(tab_switcher, 2, tab_1); }); // Still switches tab on modifiers release cx.simulate_modifiers_change(Modifiers::none()); cx.read(|cx| { let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); - assert_eq!(active_editor.read(cx).title(cx), "2.txt"); + assert_eq!(active_editor.read(cx).title(cx), "3.txt"); }); assert_tab_switcher_is_closed(workspace, cx); } -#[gpui::test] -async fn test_close_preserves_selected_position(cx: &mut gpui::TestAppContext) { - let app_state = init_test(cx); - app_state - .fs - .as_fake() - .insert_tree( - path!("/root"), - json!({ - "1.txt": "First file", - "2.txt": "Second file", - "3.txt": "Third file", - }), - ) - .await; - - let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); - - let tab_1 = open_buffer("1.txt", &workspace, cx).await; - let tab_2 = open_buffer("2.txt", &workspace, cx).await; - let tab_3 = open_buffer("3.txt", &workspace, cx).await; - - let tab_switcher = open_tab_switcher(false, &workspace, cx); - tab_switcher.update(cx, |tab_switcher, _| { - assert_eq!(tab_switcher.delegate.matches.len(), 3); - assert_match_at_position(tab_switcher, 0, tab_3.boxed_clone()); - assert_match_selection(tab_switcher, 1, tab_2.boxed_clone()); - assert_match_at_position(tab_switcher, 2, tab_1.boxed_clone()); - }); - - // Verify that if the selected tab was closed, tab at the same position is selected. - cx.dispatch_action(CloseSelectedItem); - tab_switcher.update(cx, |tab_switcher, _| { - assert_eq!(tab_switcher.delegate.matches.len(), 2); - assert_match_at_position(tab_switcher, 0, tab_3.boxed_clone()); - assert_match_selection(tab_switcher, 1, tab_1.boxed_clone()); - }); - - // But if the position is no longer valid, fall back to the position above. - cx.dispatch_action(CloseSelectedItem); - tab_switcher.update(cx, |tab_switcher, _| { - assert_eq!(tab_switcher.delegate.matches.len(), 1); - assert_match_selection(tab_switcher, 0, tab_3.boxed_clone()); - }); -} - fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); From 0818cedded624e770484a8fdd7063aabdcdba667 Mon Sep 17 00:00:00 2001 From: Mayank Verma Date: Wed, 3 Dec 2025 22:55:31 +0530 Subject: [PATCH 44/67] editor: Fix blame hover not working when inline git blame is disabled (#42992) Closes #42936 Release Notes: - Fixed editor blame hover not working when inline git blame is disabled Here's the before/after: https://github.com/user-attachments/assets/a3875011-4a27-45b3-b638-3e146c06f1fe --- crates/editor/src/editor.rs | 3 ++ crates/editor/src/element.rs | 66 +++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index eba10e4ea2a3663191fc3739b3b2f7f73101b7f5..ae114b14ce04a405ddca95c0bda9cbaf28ccdadf 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6810,6 +6810,9 @@ impl Editor { return; }; + if self.blame.is_none() { + self.start_git_blame(true, window, cx); + } let Some(blame) = self.blame.as_ref() else { return; }; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 89f9a6793d81e3de9ba27c97091fe446061c31ff..3319af92eb04015bd3bd01760235e3dba0047975 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1227,7 +1227,13 @@ impl EditorElement { editor.hide_blame_popover(false, cx); } } else { - editor.hide_blame_popover(false, cx); + let keyboard_grace = editor + .inline_blame_popover + .as_ref() + .is_some_and(|state| state.keyboard_grace); + if !keyboard_grace { + editor.hide_blame_popover(false, cx); + } } let breakpoint_indicator = if gutter_hovered { @@ -2511,7 +2517,6 @@ impl EditorElement { scroll_position: gpui::Point, scroll_pixel_position: gpui::Point, line_height: Pixels, - text_hitbox: &Hitbox, window: &mut Window, cx: &mut App, ) -> Option { @@ -2580,16 +2585,6 @@ impl EditorElement { let size = element.layout_as_root(AvailableSpace::min_size(), window, cx); let bounds = Bounds::new(absolute_offset, size); - self.layout_blame_entry_popover( - entry.clone(), - blame, - line_height, - text_hitbox, - row_info.buffer_id?, - window, - cx, - ); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), window, cx); Some(InlineBlameLayout { @@ -2600,16 +2595,48 @@ impl EditorElement { }) } - fn layout_blame_entry_popover( + fn layout_blame_popover( &self, - blame_entry: BlameEntry, - blame: Entity, - line_height: Pixels, + editor_snapshot: &EditorSnapshot, text_hitbox: &Hitbox, - buffer: BufferId, + line_height: Pixels, window: &mut Window, cx: &mut App, ) { + if !self.editor.read(cx).inline_blame_popover.is_some() { + return; + } + + let Some(blame) = self.editor.read(cx).blame.clone() else { + return; + }; + let cursor_point = self + .editor + .read(cx) + .selections + .newest::(&editor_snapshot.display_snapshot) + .head(); + + let Some((buffer, buffer_point, _)) = editor_snapshot + .buffer_snapshot() + .point_to_buffer_point(cursor_point) + else { + return; + }; + + let row_info = RowInfo { + buffer_id: Some(buffer.remote_id()), + buffer_row: Some(buffer_point.row), + ..Default::default() + }; + + let Some((buffer_id, blame_entry)) = blame + .update(cx, |blame, cx| blame.blame_for_rows(&[row_info], cx).next()) + .flatten() + else { + return; + }; + let Some((popover_state, target_point)) = self.editor.read_with(cx, |editor, _| { editor .inline_blame_popover @@ -2631,7 +2658,7 @@ impl EditorElement { popover_state.markdown, workspace, &blame, - buffer, + buffer_id, window, cx, ) @@ -9813,7 +9840,6 @@ impl Element for EditorElement { scroll_position, scroll_pixel_position, line_height, - &text_hitbox, window, cx, ) { @@ -10011,6 +10037,8 @@ impl Element for EditorElement { window, cx, ); + + self.layout_blame_popover(&snapshot, &hitbox, line_height, window, cx); } let mouse_context_menu = self.layout_mouse_context_menu( From 493cfadb42e9d1df1a73c970de3f26e227feb93e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 3 Dec 2025 11:40:47 -0700 Subject: [PATCH 45/67] Revert "http_client: Add integrity checks for GitHub binaries using digest checks (#43737)" (#44086) This reverts commit 05764e8af797b5abb8076bc78ce32d4130505e93. Internally we've seen a much higher incidence of macOS code-signing failing on the download rust analyzer than we did before this change. It's unclear why this would be a problem, but we want to try reverting to see if that fixes it. Release Notes: - Reverted a change that seemed to cause problems with code-signing on rust-analyzer --- crates/http_client/src/github_download.rs | 61 +--------------------- crates/languages/src/c.rs | 55 ++++++++++++++------ crates/languages/src/rust.rs | 62 +++++++++++++++++------ 3 files changed, 86 insertions(+), 92 deletions(-) diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs index 3c16d5e692786282c32217108277faf2b42cf220..02dee08b215e547d632caaf5f94b0872aa6aa20d 100644 --- a/crates/http_client/src/github_download.rs +++ b/crates/http_client/src/github_download.rs @@ -1,4 +1,4 @@ -use std::{future::Future, path::Path, pin::Pin, task::Poll}; +use std::{path::Path, pin::Pin, task::Poll}; use anyhow::{Context, Result}; use async_compression::futures::bufread::GzipDecoder; @@ -85,65 +85,6 @@ pub async fn download_server_binary( Ok(()) } -pub async fn fetch_github_binary_with_digest_check( - binary_path: &Path, - metadata_path: &Path, - expected_digest: Option, - url: &str, - asset_kind: AssetKind, - download_destination: &Path, - http_client: &dyn HttpClient, - validity_check: ValidityCheck, -) -> Result<()> -where - ValidityCheck: FnOnce() -> ValidityCheckFuture, - ValidityCheckFuture: Future>, -{ - let metadata = GithubBinaryMetadata::read_from_file(metadata_path) - .await - .ok(); - - if let Some(metadata) = metadata { - let validity_check_result = validity_check().await; - - if let (Some(actual_digest), Some(expected_digest_ref)) = - (&metadata.digest, &expected_digest) - { - if actual_digest == expected_digest_ref { - if validity_check_result.is_ok() { - return Ok(()); - } - } else { - log::info!( - "SHA-256 mismatch for {binary_path:?} asset, downloading new asset. Expected: {expected_digest_ref}, Got: {actual_digest}" - ); - } - } else if validity_check_result.is_ok() { - return Ok(()); - } - } - - download_server_binary( - http_client, - url, - expected_digest.as_deref(), - download_destination, - asset_kind, - ) - .await?; - - GithubBinaryMetadata::write_to_file( - &GithubBinaryMetadata { - metadata_version: 1, - digest: expected_digest, - }, - metadata_path, - ) - .await?; - - Ok(()) -} - async fn stream_response_archive( response: impl AsyncRead + Unpin, url: &str, diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index eb33bca0222abb0e03987081470549619c8e976d..8fe2bae693d702346a1ecc96334d35b89d179b3b 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use futures::StreamExt; use gpui::{App, AsyncApp}; use http_client::github::{AssetKind, GitHubLspBinaryVersion, latest_github_release}; -use http_client::github_download::fetch_github_binary_with_digest_check; +use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; pub use language::*; use lsp::{InitializeParams, LanguageServerBinary, LanguageServerName}; use project::lsp_store::clangd_ext; @@ -85,32 +85,55 @@ impl LspInstaller for CLspAdapter { }; let metadata_path = version_dir.join("metadata"); - - let binary_path_for_check = binary_path.clone(); - fetch_github_binary_with_digest_check( - &binary_path, - &metadata_path, - expected_digest, - &url, - AssetKind::Zip, - &container_dir, - &*delegate.http_client(), - || async move { + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { delegate .try_exec(LanguageServerBinary { - path: binary_path_for_check, + path: binary_path.clone(), arguments: vec!["--version".into()], env: None, }) .await .inspect_err(|err| { - log::warn!("Unable to run clangd asset, redownloading: {err:#}") + log::warn!("Unable to run {binary_path:?} asset, redownloading: {err:#}",) }) - }, + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {binary_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &container_dir, + AssetKind::Zip, ) .await?; - remove_matching(&container_dir, |entry| entry != version_dir).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; Ok(binary) } diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 41f4969b7696696b9e66f320dc9ba567898f4b11..31d7448285969fbce005b9b7134f56c7d8362f73 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -5,7 +5,7 @@ use futures::StreamExt; use gpui::{App, AppContext, AsyncApp, SharedString, Task}; use http_client::github::AssetKind; use http_client::github::{GitHubLspBinaryVersion, latest_github_release}; -use http_client::github_download::fetch_github_binary_with_digest_check; +use http_client::github_download::{GithubBinaryMetadata, download_server_binary}; pub use language::*; use lsp::{InitializeParams, LanguageServerBinary}; use project::lsp_store::rust_analyzer_ext::CARGO_DIAGNOSTICS_SOURCE_NAME; @@ -574,34 +574,64 @@ impl LspInstaller for RustLspAdapter { AssetKind::Zip => destination_path.clone().join("rust-analyzer.exe"), // zip contains a .exe }; - let metadata_path = destination_path.with_extension("metadata"); + let binary = LanguageServerBinary { + path: server_path.clone(), + env: None, + arguments: Default::default(), + }; - let server_path_for_check = server_path.clone(); - fetch_github_binary_with_digest_check( - &server_path, - &metadata_path, - expected_digest, - &url, - Self::GITHUB_ASSET_KIND, - &destination_path, - &*delegate.http_client(), - || async move { + let metadata_path = destination_path.with_extension("metadata"); + let metadata = GithubBinaryMetadata::read_from_file(&metadata_path) + .await + .ok(); + if let Some(metadata) = metadata { + let validity_check = async || { delegate .try_exec(LanguageServerBinary { - path: server_path_for_check, + path: server_path.clone(), arguments: vec!["--version".into()], env: None, }) .await .inspect_err(|err| { - log::warn!("Unable to run rust-analyzer asset, redownloading: {err:#}") + log::warn!("Unable to run {server_path:?} asset, redownloading: {err:#}",) }) - }, + }; + if let (Some(actual_digest), Some(expected_digest)) = + (&metadata.digest, &expected_digest) + { + if actual_digest == expected_digest { + if validity_check().await.is_ok() { + return Ok(binary); + } + } else { + log::info!( + "SHA-256 mismatch for {destination_path:?} asset, downloading new asset. Expected: {expected_digest}, Got: {actual_digest}" + ); + } + } else if validity_check().await.is_ok() { + return Ok(binary); + } + } + + download_server_binary( + &*delegate.http_client(), + &url, + expected_digest.as_deref(), + &destination_path, + Self::GITHUB_ASSET_KIND, ) .await?; - make_file_executable(&server_path).await?; remove_matching(&container_dir, |path| path != destination_path).await; + GithubBinaryMetadata::write_to_file( + &GithubBinaryMetadata { + metadata_version: 1, + digest: expected_digest, + }, + &metadata_path, + ) + .await?; Ok(LanguageServerBinary { path: server_path, From a51e975b817336d5eaa13e549bbcf9f1194ec1a6 Mon Sep 17 00:00:00 2001 From: John Tur Date: Wed, 3 Dec 2025 13:47:43 -0500 Subject: [PATCH 46/67] Improve support for multiple registrations of `textDocument/diagnostic` (#43703) Closes https://github.com/zed-industries/zed/issues/41935 The registration ID responsible for generating each diagnostic is now tracked. This allows us to replace only the diagnostics from the same registration ID when a pull diagnostics report is applied. Additionally, various deficiencies in our support for pull diagnostics have been fixed: - Document pulls are issued for all open buffers, not just the edited one. A shorter debounce is used for the edited buffer. Workspace diagnostics are also now ignored for open buffers. - Tracking of `lastResultId` is improved. - Stored pull diagnostics are discarded when the corresponding buffer is closed. Release Notes: - Improved compatibility with language servers that use the "pull diagnostics" feature of Language Server Protocol. --------- Co-authored-by: Kirill Bulatov Co-authored-by: Kirill Bulatov --- crates/collab/src/tests/editor_tests.rs | 26 +- crates/editor/src/editor.rs | 128 ++++-- crates/editor/src/editor_tests.rs | 12 +- crates/language/src/buffer.rs | 3 + crates/language/src/proto.rs | 7 + crates/multi_buffer/src/multi_buffer.rs | 5 +- crates/project/src/lsp_command.rs | 76 +++- crates/project/src/lsp_store.rs | 499 ++++++++++++++------- crates/project/src/lsp_store/clangd_ext.rs | 1 + crates/project/src/project.rs | 6 +- crates/project/src/project_tests.rs | 18 +- crates/proto/proto/buffer.proto | 1 + crates/proto/proto/lsp.proto | 1 + 13 files changed, 522 insertions(+), 261 deletions(-) diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index e5d3661aaf1aa0c74a4204e0989018121f5eb64a..785a6457c8fdb57f84a8e7b5a8487f0ceae3d025 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -25,6 +25,7 @@ use gpui::{ use indoc::indoc; use language::FakeLspAdapter; use lsp::LSP_REQUEST_TIMEOUT; +use pretty_assertions::assert_eq; use project::{ ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT, lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro}, @@ -3192,13 +3193,12 @@ async fn test_lsp_pull_diagnostics( .collect::>(); let expected_messages = [ expected_pull_diagnostic_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3258,14 +3258,15 @@ async fn test_lsp_pull_diagnostics( .diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) .collect::>(); let expected_messages = [ - expected_workspace_pull_diagnostics_lib_message, - // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. - // expected_push_diagnostic_lib_message, + // Despite workspace diagnostics provided, + // the currently open file's diagnostics should be preferred, as LSP suggests. + expected_pull_diagnostic_lib_message, + expected_push_diagnostic_lib_message, ]; assert_eq!( all_diagnostics.len(), - 1, - "Expected pull diagnostics, but got: {all_diagnostics:?}" + 2, + "Expected pull and push diagnostics, but got: {all_diagnostics:?}" ); for diagnostic in all_diagnostics { assert!( @@ -3378,8 +3379,9 @@ async fn test_lsp_pull_diagnostics( "Another workspace diagnostics pull should happen after the diagnostics refresh server request" ); { - assert!( - diagnostics_pulls_result_ids.lock().await.len() == diagnostic_pulls_result_ids, + assert_eq!( + diagnostics_pulls_result_ids.lock().await.len(), + diagnostic_pulls_result_ids, "Pulls should not happen hence no extra ids should appear" ); assert!( @@ -3397,7 +3399,7 @@ async fn test_lsp_pull_diagnostics( expected_pull_diagnostic_lib_message, expected_push_diagnostic_lib_message, ]; - assert_eq!(all_diagnostics.len(), 1); + assert_eq!(all_diagnostics.len(), 2); for diagnostic in &all_diagnostics { assert!( expected_messages.contains(&diagnostic.diagnostic.message.as_str()), diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ae114b14ce04a405ddca95c0bda9cbaf28ccdadf..f6489c8ffece51d581e3fb73d3f683ff1283c433 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1172,6 +1172,7 @@ pub struct Editor { gutter_breakpoint_indicator: (Option, Option>), hovered_diff_hunk_row: Option, pull_diagnostics_task: Task<()>, + pull_diagnostics_background_task: Task<()>, in_project_search: bool, previous_search_ranges: Option]>>, breadcrumb_header: Option, @@ -2316,6 +2317,7 @@ impl Editor { .unwrap_or_default(), tasks_update_task: None, pull_diagnostics_task: Task::ready(()), + pull_diagnostics_background_task: Task::ready(()), colors: None, refresh_colors_task: Task::ready(()), inlay_hints: None, @@ -2492,7 +2494,6 @@ impl Editor { if let Some(buffer) = multi_buffer.read(cx).as_singleton() { editor.register_buffer(buffer.read(cx).remote_id(), cx); } - editor.update_lsp_data(None, window, cx); editor.report_editor_event(ReportEditorEvent::EditorOpened, None, cx); } @@ -18400,54 +18401,101 @@ impl Editor { return None; } let project = self.project()?.downgrade(); - let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); - let mut buffers = self.buffer.read(cx).all_buffers(); - buffers.retain(|buffer| { - let buffer_id_to_retain = buffer.read(cx).remote_id(); - buffer_id.is_none_or(|buffer_id| buffer_id == buffer_id_to_retain) - && self.registered_buffers.contains_key(&buffer_id_to_retain) - }); - if buffers.is_empty() { + + let mut edited_buffer_ids = HashSet::default(); + let mut edited_worktree_ids = HashSet::default(); + let edited_buffers = match buffer_id { + Some(buffer_id) => { + let buffer = self.buffer().read(cx).buffer(buffer_id)?; + let worktree_id = buffer.read(cx).file().map(|f| f.worktree_id(cx))?; + edited_buffer_ids.insert(buffer.read(cx).remote_id()); + edited_worktree_ids.insert(worktree_id); + vec![buffer] + } + None => self + .buffer() + .read(cx) + .all_buffers() + .into_iter() + .filter(|buffer| { + let buffer = buffer.read(cx); + match buffer.file().map(|f| f.worktree_id(cx)) { + Some(worktree_id) => { + edited_buffer_ids.insert(buffer.remote_id()); + edited_worktree_ids.insert(worktree_id); + true + } + None => false, + } + }) + .collect::>(), + }; + + if edited_buffers.is_empty() { self.pull_diagnostics_task = Task::ready(()); + self.pull_diagnostics_background_task = Task::ready(()); return None; } - self.pull_diagnostics_task = cx.spawn_in(window, async move |editor, cx| { - cx.background_executor().timer(debounce).await; + let mut already_used_buffers = HashSet::default(); + let related_open_buffers = self + .workspace + .as_ref() + .and_then(|(workspace, _)| workspace.upgrade()) + .into_iter() + .flat_map(|workspace| workspace.read(cx).panes()) + .flat_map(|pane| pane.read(cx).items_of_type::()) + .filter(|editor| editor != &cx.entity()) + .flat_map(|editor| editor.read(cx).buffer().read(cx).all_buffers()) + .filter(|buffer| { + let buffer = buffer.read(cx); + let buffer_id = buffer.remote_id(); + if already_used_buffers.insert(buffer_id) { + if let Some(worktree_id) = buffer.file().map(|f| f.worktree_id(cx)) { + return !edited_buffer_ids.contains(&buffer_id) + && !edited_worktree_ids.contains(&worktree_id); + } + } + false + }) + .collect::>(); + + let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); + let make_spawn = |buffers: Vec>, delay: Duration| { + if buffers.is_empty() { + return Task::ready(()); + } + let project_weak = project.clone(); + cx.spawn_in(window, async move |_, cx| { + cx.background_executor().timer(delay).await; - let Ok(mut pull_diagnostics_tasks) = cx.update(|_, cx| { - buffers - .into_iter() - .filter_map(|buffer| { - project - .update(cx, |project, cx| { - project.lsp_store().update(cx, |lsp_store, cx| { - lsp_store.pull_diagnostics_for_buffer(buffer, cx) + let Ok(mut pull_diagnostics_tasks) = cx.update(|_, cx| { + buffers + .into_iter() + .filter_map(|buffer| { + project_weak + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }) }) - }) - .ok() - }) - .collect::>() - }) else { - return; - }; + .ok() + }) + .collect::>() + }) else { + return; + }; - while let Some(pull_task) = pull_diagnostics_tasks.next().await { - match pull_task { - Ok(()) => { - if editor - .update_in(cx, |editor, window, cx| { - editor.update_diagnostics_state(window, cx); - }) - .is_err() - { - return; - } + while let Some(pull_task) = pull_diagnostics_tasks.next().await { + if let Err(e) = pull_task { + log::error!("Failed to update project diagnostics: {e:#}"); } - Err(e) => log::error!("Failed to update project diagnostics: {e:#}"), } - } - }); + }) + }; + + self.pull_diagnostics_task = make_spawn(edited_buffers, debounce); + self.pull_diagnostics_background_task = make_spawn(related_open_buffers, debounce * 2); Some(()) } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 61d316e3915a740cb35b24a3afa445a34a608336..d95f0f78bf8acea8703bb7780ca842f037850d64 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -26589,7 +26589,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { } }); - let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { + let ensure_result_id = |expected: Option, cx: &mut TestAppContext| { project.update(cx, |project, cx| { let buffer_id = editor .read(cx) @@ -26602,7 +26602,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { let buffer_result_id = project .lsp_store() .read(cx) - .result_id(server_id, buffer_id, cx); + .result_id_for_buffer_pull(server_id, buffer_id, &None, cx); assert_eq!(expected, buffer_result_id); }); }; @@ -26619,7 +26619,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { .next() .await .expect("should have sent the first diagnostics pull request"); - ensure_result_id(Some("1".to_string()), cx); + ensure_result_id(Some(SharedString::new("1")), cx); // Editing should trigger diagnostics editor.update_in(cx, |editor, window, cx| { @@ -26632,7 +26632,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { 2, "Editing should trigger diagnostic request" ); - ensure_result_id(Some("2".to_string()), cx); + ensure_result_id(Some(SharedString::new("2")), cx); // Moving cursor should not trigger diagnostic request editor.update_in(cx, |editor, window, cx| { @@ -26647,7 +26647,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { 2, "Cursor movement should not trigger diagnostic request" ); - ensure_result_id(Some("2".to_string()), cx); + ensure_result_id(Some(SharedString::new("2")), cx); // Multiple rapid edits should be debounced for _ in 0..5 { editor.update_in(cx, |editor, window, cx| { @@ -26662,7 +26662,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { final_requests <= 4, "Multiple rapid edits should be debounced (got {final_requests} requests)", ); - ensure_result_id(Some(final_requests.to_string()), cx); + ensure_result_id(Some(SharedString::new(final_requests.to_string())), cx); } #[gpui::test] diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index c6eb3ff66b08b03f39466af4a8b65805003a8bd3..a46f7cc35912d4c6da42ba69f7aee6d25caca2e7 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -237,6 +237,8 @@ struct SelectionSet { pub struct Diagnostic { /// The name of the service that produced this diagnostic. pub source: Option, + /// The ID provided by the dynamic registration that produced this diagnostic. + pub registration_id: Option, /// A machine-readable code that identifies this diagnostic. pub code: Option, pub code_description: Option, @@ -5390,6 +5392,7 @@ impl Default for Diagnostic { is_unnecessary: false, underline: true, data: None, + registration_id: None, } } } diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index 5c8200b84002c104ce1e2c3d1a42aff5876bd1ee..242cce1c64d1d45b71d615e444409298ec2205db 100644 --- a/crates/language/src/proto.rs +++ b/crates/language/src/proto.rs @@ -3,6 +3,7 @@ use crate::{CursorShape, Diagnostic, DiagnosticSourceKind, diagnostic_set::DiagnosticEntry}; use anyhow::{Context as _, Result}; use clock::ReplicaId; +use gpui::SharedString; use lsp::{DiagnosticSeverity, LanguageServerId}; use rpc::proto; use serde_json::Value; @@ -239,6 +240,11 @@ pub fn serialize_diagnostics<'a>( is_disk_based: entry.diagnostic.is_disk_based, is_unnecessary: entry.diagnostic.is_unnecessary, data: entry.diagnostic.data.as_ref().map(|data| data.to_string()), + registration_id: entry + .diagnostic + .registration_id + .as_ref() + .map(ToString::to_string), }) .collect() } @@ -457,6 +463,7 @@ pub fn deserialize_diagnostics( is_disk_based: diagnostic.is_disk_based, is_unnecessary: diagnostic.is_unnecessary, underline: diagnostic.underline, + registration_id: diagnostic.registration_id.map(SharedString::from), source_kind: match proto::diagnostic::SourceKind::from_i32( diagnostic.source_kind, )? { diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 5fac7dd4587132cd532073e571991018e643faa6..02adb79e70452a524152d62a71138b75561f9f33 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -2283,6 +2283,7 @@ impl MultiBuffer { cx: &mut Context, ) { use language::BufferEvent; + let buffer_id = buffer.read(cx).remote_id(); cx.emit(match event { BufferEvent::Edited => Event::Edited { edited_buffer: Some(buffer), @@ -2291,8 +2292,8 @@ impl MultiBuffer { BufferEvent::Saved => Event::Saved, BufferEvent::FileHandleChanged => Event::FileHandleChanged, BufferEvent::Reloaded => Event::Reloaded, - BufferEvent::LanguageChanged => Event::LanguageChanged(buffer.read(cx).remote_id()), - BufferEvent::Reparsed => Event::Reparsed(buffer.read(cx).remote_id()), + BufferEvent::LanguageChanged => Event::LanguageChanged(buffer_id), + BufferEvent::Reparsed => Event::Reparsed(buffer_id), BufferEvent::DiagnosticsUpdated => Event::DiagnosticsUpdated, BufferEvent::CapabilityChanged => { self.capability = buffer.read(cx).capability(); diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index adea507f00eda72e715fe535da7016af44a4f723..05ee70bf66fe9e56a27c5a84044c49600590f469 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -14,7 +14,7 @@ use client::proto::{self, PeerId}; use clock::Global; use collections::{HashMap, HashSet}; use futures::future; -use gpui::{App, AsyncApp, Entity, Task}; +use gpui::{App, AsyncApp, Entity, SharedString, Task}; use language::{ Anchor, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CharKind, CharScopeContext, OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped, @@ -26,8 +26,8 @@ use language::{ use lsp::{ AdapterServerCapabilities, CodeActionKind, CodeActionOptions, CodeDescription, CompletionContext, CompletionListItemDefaultsEditRange, CompletionTriggerKind, - DiagnosticServerCapabilities, DocumentHighlightKind, LanguageServer, LanguageServerId, - LinkedEditingRangeServerCapabilities, OneOf, RenameOptions, ServerCapabilities, + DocumentHighlightKind, LanguageServer, LanguageServerId, LinkedEditingRangeServerCapabilities, + OneOf, RenameOptions, ServerCapabilities, }; use serde_json::Value; use signature_help::{lsp_to_proto_signature, proto_to_lsp_signature}; @@ -265,8 +265,9 @@ pub(crate) struct LinkedEditingRange { pub(crate) struct GetDocumentDiagnostics { /// We cannot blindly rely on server's capabilities.diagnostic_provider, as they're a singular field, whereas /// a server can register multiple diagnostic providers post-mortem. - pub dynamic_caps: DiagnosticServerCapabilities, - pub previous_result_id: Option, + pub registration_id: Option, + pub identifier: Option, + pub previous_result_id: Option, } #[async_trait(?Send)] @@ -3755,15 +3756,16 @@ impl GetDocumentDiagnostics { .into_iter() .filter_map(|diagnostics| { Some(LspPullDiagnostics::Response { + registration_id: diagnostics.registration_id.map(SharedString::from), server_id: LanguageServerId::from_proto(diagnostics.server_id), uri: lsp::Uri::from_str(diagnostics.uri.as_str()).log_err()?, diagnostics: if diagnostics.changed { PulledDiagnostics::Unchanged { - result_id: diagnostics.result_id?, + result_id: SharedString::new(diagnostics.result_id?), } } else { PulledDiagnostics::Changed { - result_id: diagnostics.result_id, + result_id: diagnostics.result_id.map(SharedString::new), diagnostics: diagnostics .diagnostics .into_iter() @@ -3927,6 +3929,7 @@ impl GetDocumentDiagnostics { pub fn deserialize_workspace_diagnostics_report( report: lsp::WorkspaceDiagnosticReportResult, server_id: LanguageServerId, + registration_id: Option, ) -> Vec { let mut pulled_diagnostics = HashMap::default(); match report { @@ -3938,6 +3941,7 @@ impl GetDocumentDiagnostics { &mut pulled_diagnostics, server_id, report, + registration_id.clone(), ) } lsp::WorkspaceDocumentDiagnosticReport::Unchanged(report) => { @@ -3945,6 +3949,7 @@ impl GetDocumentDiagnostics { &mut pulled_diagnostics, server_id, report, + registration_id.clone(), ) } } @@ -3960,6 +3965,7 @@ impl GetDocumentDiagnostics { &mut pulled_diagnostics, server_id, report, + registration_id.clone(), ) } lsp::WorkspaceDocumentDiagnosticReport::Unchanged(report) => { @@ -3967,6 +3973,7 @@ impl GetDocumentDiagnostics { &mut pulled_diagnostics, server_id, report, + registration_id.clone(), ) } } @@ -3987,6 +3994,7 @@ fn process_full_workspace_diagnostics_report( diagnostics: &mut HashMap, server_id: LanguageServerId, report: lsp::WorkspaceFullDocumentDiagnosticReport, + registration_id: Option, ) { let mut new_diagnostics = HashMap::default(); process_full_diagnostics_report( @@ -3994,6 +4002,7 @@ fn process_full_workspace_diagnostics_report( server_id, report.uri, report.full_document_diagnostic_report, + registration_id, ); diagnostics.extend(new_diagnostics.into_iter().map(|(uri, diagnostics)| { ( @@ -4010,6 +4019,7 @@ fn process_unchanged_workspace_diagnostics_report( diagnostics: &mut HashMap, server_id: LanguageServerId, report: lsp::WorkspaceUnchangedDocumentDiagnosticReport, + registration_id: Option, ) { let mut new_diagnostics = HashMap::default(); process_unchanged_diagnostics_report( @@ -4017,6 +4027,7 @@ fn process_unchanged_workspace_diagnostics_report( server_id, report.uri, report.unchanged_document_diagnostic_report, + registration_id, ); diagnostics.extend(new_diagnostics.into_iter().map(|(uri, diagnostics)| { ( @@ -4050,19 +4061,12 @@ impl LspCommand for GetDocumentDiagnostics { _: &Arc, _: &App, ) -> Result { - let identifier = match &self.dynamic_caps { - lsp::DiagnosticServerCapabilities::Options(options) => options.identifier.clone(), - lsp::DiagnosticServerCapabilities::RegistrationOptions(options) => { - options.diagnostic_options.identifier.clone() - } - }; - Ok(lsp::DocumentDiagnosticParams { text_document: lsp::TextDocumentIdentifier { uri: file_path_to_lsp_url(path)?, }, - identifier, - previous_result_id: self.previous_result_id.clone(), + identifier: self.identifier.clone(), + previous_result_id: self.previous_result_id.clone().map(|id| id.to_string()), partial_result_params: Default::default(), work_done_progress_params: Default::default(), }) @@ -4097,6 +4101,7 @@ impl LspCommand for GetDocumentDiagnostics { &mut pulled_diagnostics, server_id, related_documents, + self.registration_id.clone(), ); } process_full_diagnostics_report( @@ -4104,6 +4109,7 @@ impl LspCommand for GetDocumentDiagnostics { server_id, url, report.full_document_diagnostic_report, + self.registration_id, ); } lsp::DocumentDiagnosticReport::Unchanged(report) => { @@ -4112,6 +4118,7 @@ impl LspCommand for GetDocumentDiagnostics { &mut pulled_diagnostics, server_id, related_documents, + self.registration_id.clone(), ); } process_unchanged_diagnostics_report( @@ -4119,6 +4126,7 @@ impl LspCommand for GetDocumentDiagnostics { server_id, url, report.unchanged_document_diagnostic_report, + self.registration_id, ); } }, @@ -4128,6 +4136,7 @@ impl LspCommand for GetDocumentDiagnostics { &mut pulled_diagnostics, server_id, related_documents, + self.registration_id, ); } } @@ -4170,6 +4179,7 @@ impl LspCommand for GetDocumentDiagnostics { server_id, uri, diagnostics, + registration_id, } => { let mut changed = false; let (diagnostics, result_id) = match diagnostics { @@ -4184,7 +4194,7 @@ impl LspCommand for GetDocumentDiagnostics { }; Some(proto::PulledDiagnostics { changed, - result_id, + result_id: result_id.map(|id| id.to_string()), uri: uri.to_string(), server_id: server_id.to_proto(), diagnostics: diagnostics @@ -4195,6 +4205,7 @@ impl LspCommand for GetDocumentDiagnostics { .log_err() }) .collect(), + registration_id: registration_id.as_ref().map(ToString::to_string), }) } }) @@ -4365,14 +4376,25 @@ fn process_related_documents( diagnostics: &mut HashMap, server_id: LanguageServerId, documents: impl IntoIterator, + registration_id: Option, ) { for (url, report_kind) in documents { match report_kind { - lsp::DocumentDiagnosticReportKind::Full(report) => { - process_full_diagnostics_report(diagnostics, server_id, url, report) - } + lsp::DocumentDiagnosticReportKind::Full(report) => process_full_diagnostics_report( + diagnostics, + server_id, + url, + report, + registration_id.clone(), + ), lsp::DocumentDiagnosticReportKind::Unchanged(report) => { - process_unchanged_diagnostics_report(diagnostics, server_id, url, report) + process_unchanged_diagnostics_report( + diagnostics, + server_id, + url, + report, + registration_id.clone(), + ) } } } @@ -4383,8 +4405,9 @@ fn process_unchanged_diagnostics_report( server_id: LanguageServerId, uri: lsp::Uri, report: lsp::UnchangedDocumentDiagnosticReport, + registration_id: Option, ) { - let result_id = report.result_id; + let result_id = SharedString::new(report.result_id); match diagnostics.entry(uri.clone()) { hash_map::Entry::Occupied(mut o) => match o.get_mut() { LspPullDiagnostics::Default => { @@ -4392,12 +4415,14 @@ fn process_unchanged_diagnostics_report( server_id, uri, diagnostics: PulledDiagnostics::Unchanged { result_id }, + registration_id, }); } LspPullDiagnostics::Response { server_id: existing_server_id, uri: existing_uri, diagnostics: existing_diagnostics, + .. } => { if server_id != *existing_server_id || &uri != existing_uri { debug_panic!( @@ -4417,6 +4442,7 @@ fn process_unchanged_diagnostics_report( server_id, uri, diagnostics: PulledDiagnostics::Unchanged { result_id }, + registration_id, }); } } @@ -4427,8 +4453,9 @@ fn process_full_diagnostics_report( server_id: LanguageServerId, uri: lsp::Uri, report: lsp::FullDocumentDiagnosticReport, + registration_id: Option, ) { - let result_id = report.result_id; + let result_id = report.result_id.map(SharedString::new); match diagnostics.entry(uri.clone()) { hash_map::Entry::Occupied(mut o) => match o.get_mut() { LspPullDiagnostics::Default => { @@ -4439,12 +4466,14 @@ fn process_full_diagnostics_report( result_id, diagnostics: report.items, }, + registration_id, }); } LspPullDiagnostics::Response { server_id: existing_server_id, uri: existing_uri, diagnostics: existing_diagnostics, + .. } => { if server_id != *existing_server_id || &uri != existing_uri { debug_panic!( @@ -4478,6 +4507,7 @@ fn process_full_diagnostics_report( result_id, diagnostics: report.items, }, + registration_id, }); } } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index bd8b512bbca6b0725f4d9a7ae4ce07d6681d48db..59b7a6932d4733a78959e9e4f481a63589811a52 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -116,6 +116,7 @@ use std::{ atomic::{self, AtomicUsize}, }, time::{Duration, Instant}, + vec, }; use sum_tree::Dimensions; use text::{Anchor, BufferId, LineEnding, OffsetRangeExt, ToPoint as _}; @@ -229,7 +230,8 @@ struct LanguageServerSeed { #[derive(Debug)] pub struct DocumentDiagnosticsUpdate<'a, D> { pub diagnostics: D, - pub result_id: Option, + pub result_id: Option, + pub registration_id: Option, pub server_id: LanguageServerId, pub disk_based_sources: Cow<'a, [String]>, } @@ -283,7 +285,14 @@ pub struct LocalLspStore { lsp_tree: LanguageServerTree, registered_buffers: HashMap, buffers_opened_in_servers: HashMap>, - buffer_pull_diagnostics_result_ids: HashMap>>, + buffer_pull_diagnostics_result_ids: HashMap< + LanguageServerId, + HashMap, HashMap>>, + >, + workspace_pull_diagnostics_result_ids: HashMap< + LanguageServerId, + HashMap, HashMap>>, + >, } impl LocalLspStore { @@ -685,6 +694,7 @@ impl LocalLspStore { disk_based_sources: Cow::Borrowed( &adapter.disk_based_diagnostic_sources, ), + registration_id: None, }], |_, diagnostic, cx| match diagnostic.source_kind { DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => { @@ -2256,8 +2266,9 @@ impl LocalLspStore { server_id, None, None, - diagnostics, + None, Vec::new(), + diagnostics, cx, ) .log_err(); @@ -2335,7 +2346,8 @@ impl LocalLspStore { &mut self, buffer: &Entity, server_id: LanguageServerId, - result_id: Option, + registration_id: Option>, + result_id: Option, version: Option, new_diagnostics: Vec>>, reused_diagnostics: Vec>>, @@ -2408,11 +2420,15 @@ impl LocalLspStore { let set = DiagnosticSet::new(sanitized_diagnostics, &snapshot); buffer.update(cx, |buffer, cx| { - if let Some(abs_path) = File::from_dyn(buffer.file()).map(|f| f.abs_path(cx)) { - self.buffer_pull_diagnostics_result_ids - .entry(server_id) - .or_default() - .insert(abs_path, result_id); + if let Some(registration_id) = registration_id { + if let Some(abs_path) = File::from_dyn(buffer.file()).map(|f| f.abs_path(cx)) { + self.buffer_pull_diagnostics_result_ids + .entry(server_id) + .or_default() + .entry(registration_id) + .or_default() + .insert(abs_path, result_id); + } } buffer.update_diagnostics(server_id, set, cx) @@ -3266,6 +3282,8 @@ impl LocalLspStore { self.language_servers.remove(server_id_to_remove); self.buffer_pull_diagnostics_result_ids .remove(server_id_to_remove); + self.workspace_pull_diagnostics_result_ids + .remove(server_id_to_remove); for buffer_servers in self.buffers_opened_in_servers.values_mut() { buffer_servers.remove(server_id_to_remove); } @@ -3952,6 +3970,7 @@ impl LspStore { registered_buffers: HashMap::default(), buffers_opened_in_servers: HashMap::default(), buffer_pull_diagnostics_result_ids: HashMap::default(), + workspace_pull_diagnostics_result_ids: HashMap::default(), watched_manifest_filenames: ManifestProvidersStore::global(cx) .manifest_file_names(), }), @@ -4225,9 +4244,50 @@ impl LspStore { lsp_store.lsp_data.remove(&buffer_id); let local = lsp_store.as_local_mut().unwrap(); local.registered_buffers.remove(&buffer_id); + local.buffers_opened_in_servers.remove(&buffer_id); if let Some(file) = File::from_dyn(buffer.read(cx).file()).cloned() { local.unregister_old_buffer_from_language_servers(buffer, &file, cx); + + let buffer_abs_path = file.abs_path(cx); + for (_, buffer_pull_diagnostics_result_ids) in + &mut local.buffer_pull_diagnostics_result_ids + { + buffer_pull_diagnostics_result_ids.retain( + |_, buffer_result_ids| { + buffer_result_ids.remove(&buffer_abs_path); + !buffer_result_ids.is_empty() + }, + ); + } + + let diagnostic_updates = local + .language_servers + .keys() + .cloned() + .map(|server_id| DocumentDiagnosticsUpdate { + diagnostics: DocumentDiagnostics { + document_abs_path: buffer_abs_path.clone(), + version: None, + diagnostics: Vec::new(), + }, + result_id: None, + registration_id: None, + server_id: server_id, + disk_based_sources: Cow::Borrowed(&[]), + }) + .collect::>(); + + lsp_store + .merge_diagnostic_entries( + diagnostic_updates, + |_, diagnostic, _| { + diagnostic.source_kind != DiagnosticSourceKind::Pulled + }, + cx, + ) + .context("Clearing diagnostics for the closed buffer") + .log_err(); } } }) @@ -6700,9 +6760,11 @@ impl LspStore { }; assert!(any_server_has_diagnostics_provider); + let identifier = buffer_diagnostic_identifier(&dynamic_caps); let request = GetDocumentDiagnostics { previous_result_id: None, - dynamic_caps, + identifier, + registration_id: None, }; let request_task = client.request_lsp( upstream_project_id, @@ -6735,19 +6797,27 @@ impl LspStore { .language_server_dynamic_registrations .get(&server_id) .into_iter() - .flat_map(|registrations| registrations.diagnostics.values().cloned()) + .flat_map(|registrations| registrations.diagnostics.clone()) .collect::>(); Some( providers_with_identifiers .into_iter() - .map(|dynamic_caps| { - let result_id = self.result_id(server_id, buffer_id, cx); + .map(|(registration_id, dynamic_caps)| { + let identifier = buffer_diagnostic_identifier(&dynamic_caps); + let registration_id = registration_id.map(SharedString::from); + let result_id = self.result_id_for_buffer_pull( + server_id, + buffer_id, + ®istration_id, + cx, + ); self.request_lsp( buffer.clone(), LanguageServerToQuery::Other(server_id), GetDocumentDiagnostics { previous_result_id: result_id, - dynamic_caps, + registration_id, + identifier, }, cx, ) @@ -7112,8 +7182,7 @@ impl LspStore { return; } - let mut unchanged_buffers = HashSet::default(); - let mut changed_buffers = HashSet::default(); + let mut unchanged_buffers = HashMap::default(); let server_diagnostics_updates = diagnostics .into_iter() .filter_map(|diagnostics_set| match diagnostics_set { @@ -7121,24 +7190,25 @@ impl LspStore { server_id, uri, diagnostics, - } => Some((server_id, uri, diagnostics)), + registration_id, + } => Some((server_id, uri, diagnostics, registration_id)), LspPullDiagnostics::Default => None, }) .fold( HashMap::default(), - |mut acc, (server_id, uri, diagnostics)| { + |mut acc, (server_id, uri, diagnostics, new_registration_id)| { let (result_id, diagnostics) = match diagnostics { PulledDiagnostics::Unchanged { result_id } => { - unchanged_buffers.insert(uri.clone()); + unchanged_buffers + .entry(new_registration_id.clone()) + .or_insert_with(HashSet::default) + .insert(uri.clone()); (Some(result_id), Vec::new()) } PulledDiagnostics::Changed { result_id, diagnostics, - } => { - changed_buffers.insert(uri.clone()); - (result_id, diagnostics) - } + } => (result_id, diagnostics), }; let disk_based_sources = Cow::Owned( lsp_store @@ -7148,8 +7218,11 @@ impl LspStore { .unwrap_or(&[]) .to_vec(), ); - acc.entry(server_id).or_insert_with(Vec::new).push( - DocumentDiagnosticsUpdate { + acc.entry(server_id) + .or_insert_with(HashMap::default) + .entry(new_registration_id.clone()) + .or_insert_with(Vec::new) + .push(DocumentDiagnosticsUpdate { server_id, diagnostics: lsp::PublishDiagnosticsParams { uri, @@ -7158,37 +7231,35 @@ impl LspStore { }, result_id, disk_based_sources, - }, - ); + registration_id: new_registration_id, + }); acc }, ); for diagnostic_updates in server_diagnostics_updates.into_values() { - lsp_store - .merge_lsp_diagnostics( - DiagnosticSourceKind::Pulled, - diagnostic_updates, - |buffer, old_diagnostic, cx| { - File::from_dyn(buffer.file()) - .and_then(|file| { - let abs_path = file.as_local()?.abs_path(cx); - lsp::Uri::from_file_path(abs_path).ok() - }) - .is_none_or(|buffer_uri| { - unchanged_buffers.contains(&buffer_uri) - || match old_diagnostic.source_kind { - DiagnosticSourceKind::Pulled => { - !changed_buffers.contains(&buffer_uri) - } - DiagnosticSourceKind::Other - | DiagnosticSourceKind::Pushed => true, - } - }) - }, - cx, - ) - .log_err(); + for (registration_id, diagnostic_updates) in diagnostic_updates { + lsp_store + .merge_lsp_diagnostics( + DiagnosticSourceKind::Pulled, + diagnostic_updates, + |document_uri, old_diagnostic, _| match old_diagnostic.source_kind { + DiagnosticSourceKind::Pulled => { + old_diagnostic.registration_id != registration_id + || unchanged_buffers + .get(&old_diagnostic.registration_id) + .is_some_and(|unchanged_buffers| { + unchanged_buffers.contains(&document_uri) + }) + } + DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => { + true + } + }, + cx, + ) + .log_err(); + } } }) }) @@ -8195,7 +8266,7 @@ impl LspStore { &mut self, server_id: LanguageServerId, abs_path: PathBuf, - result_id: Option, + result_id: Option, version: Option, diagnostics: Vec>>, cx: &mut Context, @@ -8210,6 +8281,7 @@ impl LspStore { result_id, server_id, disk_based_sources: Cow::Borrowed(&[]), + registration_id: None, }], |_, _, _| false, cx, @@ -8220,7 +8292,7 @@ impl LspStore { pub fn merge_diagnostic_entries<'a>( &mut self, diagnostic_updates: Vec>, - merge: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone, + merge: impl Fn(&lsp::Uri, &Diagnostic, &App) -> bool + Clone, cx: &mut Context, ) -> anyhow::Result<()> { let mut diagnostics_summary = None::; @@ -8241,13 +8313,15 @@ impl LspStore { path: relative_path, }; + let document_uri = lsp::Uri::from_file_path(abs_path) + .map_err(|()| anyhow!("Failed to convert buffer path {abs_path:?} to lsp Uri"))?; if let Some(buffer_handle) = self.buffer_store.read(cx).get_by_path(&project_path) { let snapshot = buffer_handle.read(cx).snapshot(); let buffer = buffer_handle.read(cx); let reused_diagnostics = buffer .buffer_diagnostics(Some(server_id)) .iter() - .filter(|v| merge(buffer, &v.diagnostic, cx)) + .filter(|v| merge(&document_uri, &v.diagnostic, cx)) .map(|v| { let start = Unclipped(v.range.start.to_point_utf16(&snapshot)); let end = Unclipped(v.range.end.to_point_utf16(&snapshot)); @@ -8263,6 +8337,7 @@ impl LspStore { .update_buffer_diagnostics( &buffer_handle, server_id, + Some(update.registration_id), update.result_id, update.diagnostics.version, update.diagnostics.diagnostics.clone(), @@ -8271,6 +8346,25 @@ impl LspStore { )?; update.diagnostics.diagnostics.extend(reused_diagnostics); + } else if let Some(local) = self.as_local() { + let reused_diagnostics = local + .diagnostics + .get(&worktree_id) + .and_then(|diagnostics_for_tree| diagnostics_for_tree.get(&project_path.path)) + .and_then(|diagnostics_by_server_id| { + diagnostics_by_server_id + .binary_search_by_key(&server_id, |e| e.0) + .ok() + .map(|ix| &diagnostics_by_server_id[ix].1) + }) + .into_iter() + .flatten() + .filter(|v| merge(&document_uri, &v.diagnostic, cx)); + + update + .diagnostics + .diagnostics + .extend(reused_diagnostics.cloned()); } let updated = worktree.update(cx, |worktree, cx| { @@ -8355,7 +8449,7 @@ impl LspStore { .unwrap_or_default(); let new_summary = DiagnosticSummary::new(&diagnostics); - if new_summary.is_empty() { + if diagnostics.is_empty() { if let Some(diagnostics_by_server_id) = diagnostics_for_tree.get_mut(&path_in_worktree) { if let Ok(ix) = diagnostics_by_server_id.binary_search_by_key(&server_id, |e| e.0) { @@ -9665,7 +9759,7 @@ impl LspStore { ); } lsp::ProgressParamsValue::WorkspaceDiagnostic(report) => { - let identifier = match progress_params.token { + let registration_id = match progress_params.token { lsp::NumberOrString::Number(_) => None, lsp::NumberOrString::String(token) => token .split_once(WORKSPACE_DIAGNOSTICS_TOKEN_START) @@ -9678,10 +9772,15 @@ impl LspStore { .as_local_mut() .and_then(|local| local.language_servers.get_mut(&language_server_id)) && let Some(workspace_diagnostics) = - workspace_diagnostics_refresh_tasks.get_mut(&identifier) + workspace_diagnostics_refresh_tasks.get_mut(®istration_id) { workspace_diagnostics.progress_tx.try_send(()).ok(); - self.apply_workspace_diagnostic_report(language_server_id, report, cx) + self.apply_workspace_diagnostic_report( + language_server_id, + report, + registration_id.map(SharedString::from), + cx, + ) } } } @@ -10941,7 +11040,7 @@ impl LspStore { &mut self, server_id: LanguageServerId, diagnostics: lsp::PublishDiagnosticsParams, - result_id: Option, + result_id: Option, source_kind: DiagnosticSourceKind, disk_based_sources: &[String], cx: &mut Context, @@ -10953,6 +11052,7 @@ impl LspStore { result_id, server_id, disk_based_sources: Cow::Borrowed(disk_based_sources), + registration_id: None, }], |_, _, _| false, cx, @@ -10963,7 +11063,7 @@ impl LspStore { &mut self, source_kind: DiagnosticSourceKind, lsp_diagnostics: Vec>, - merge: impl Fn(&Buffer, &Diagnostic, &App) -> bool + Clone, + merge: impl Fn(&lsp::Uri, &Diagnostic, &App) -> bool + Clone, cx: &mut Context, ) -> Result<()> { anyhow::ensure!(self.mode.is_local(), "called update_diagnostics on remote"); @@ -10978,10 +11078,12 @@ impl LspStore { update.server_id, update.diagnostics, &update.disk_based_sources, + update.registration_id.clone(), ), result_id: update.result_id, server_id: update.server_id, disk_based_sources: update.disk_based_sources, + registration_id: update.registration_id, }) }) .collect(); @@ -10996,6 +11098,7 @@ impl LspStore { server_id: LanguageServerId, mut lsp_diagnostics: lsp::PublishDiagnosticsParams, disk_based_sources: &[String], + registration_id: Option, ) -> DocumentDiagnostics { let mut diagnostics = Vec::default(); let mut primary_diagnostic_group_ids = HashMap::default(); @@ -11069,6 +11172,7 @@ impl LspStore { is_unnecessary, underline, data: diagnostic.data.clone(), + registration_id: registration_id.clone(), }, }); if let Some(infos) = &diagnostic.related_information { @@ -11096,6 +11200,7 @@ impl LspStore { is_unnecessary: false, underline, data: diagnostic.data.clone(), + registration_id: registration_id.clone(), }, }); } @@ -11845,18 +11950,22 @@ impl LspStore { } if let Some(local) = self.as_local_mut() { local.buffer_pull_diagnostics_result_ids.remove(&for_server); + local + .workspace_pull_diagnostics_result_ids + .remove(&for_server); for buffer_servers in local.buffers_opened_in_servers.values_mut() { buffer_servers.remove(&for_server); } } } - pub fn result_id( + pub fn result_id_for_buffer_pull( &self, server_id: LanguageServerId, buffer_id: BufferId, + registration_id: &Option, cx: &App, - ) -> Option { + ) -> Option { let abs_path = self .buffer_store .read(cx) @@ -11866,20 +11975,40 @@ impl LspStore { self.as_local()? .buffer_pull_diagnostics_result_ids .get(&server_id)? + .get(registration_id)? .get(&abs_path)? .clone() } - pub fn all_result_ids(&self, server_id: LanguageServerId) -> HashMap { + /// Gets all result_ids for a workspace diagnostics pull request. + /// First, it tries to find buffer's result_id retrieved via the diagnostics pull; if it fails, it falls back to the workspace disagnostics pull result_id. + /// The latter is supposed to be of lower priority as we keep on pulling diagnostics for open buffers eagerly. + pub fn result_ids_for_workspace_refresh( + &self, + server_id: LanguageServerId, + registration_id: &Option, + ) -> HashMap { let Some(local) = self.as_local() else { return HashMap::default(); }; local - .buffer_pull_diagnostics_result_ids + .workspace_pull_diagnostics_result_ids .get(&server_id) .into_iter() + .filter_map(|diagnostics| diagnostics.get(registration_id)) .flatten() - .filter_map(|(abs_path, result_id)| Some((abs_path.clone(), result_id.clone()?))) + .filter_map(|(abs_path, result_id)| { + let result_id = local + .buffer_pull_diagnostics_result_ids + .get(&server_id) + .and_then(|buffer_ids_result_ids| { + buffer_ids_result_ids.get(registration_id)?.get(abs_path) + }) + .cloned() + .flatten() + .or_else(|| result_id.clone())?; + Some((abs_path.clone(), result_id)) + }) .collect() } @@ -11924,12 +12053,16 @@ impl LspStore { &mut self, server_id: LanguageServerId, report: lsp::WorkspaceDiagnosticReportResult, + registration_id: Option, cx: &mut Context, ) { let workspace_diagnostics = - GetDocumentDiagnostics::deserialize_workspace_diagnostics_report(report, server_id); - let mut unchanged_buffers = HashSet::default(); - let mut changed_buffers = HashSet::default(); + GetDocumentDiagnostics::deserialize_workspace_diagnostics_report( + report, + server_id, + registration_id, + ); + let mut unchanged_buffers = HashMap::default(); let workspace_diagnostics_updates = workspace_diagnostics .into_iter() .filter_map( @@ -11938,25 +12071,32 @@ impl LspStore { server_id, uri, diagnostics, - } => Some((server_id, uri, diagnostics, workspace_diagnostics.version)), + registration_id, + } => Some(( + server_id, + uri, + diagnostics, + workspace_diagnostics.version, + registration_id, + )), LspPullDiagnostics::Default => None, }, ) .fold( HashMap::default(), - |mut acc, (server_id, uri, diagnostics, version)| { + |mut acc, (server_id, uri, diagnostics, version, new_registration_id)| { let (result_id, diagnostics) = match diagnostics { PulledDiagnostics::Unchanged { result_id } => { - unchanged_buffers.insert(uri.clone()); + unchanged_buffers + .entry(new_registration_id.clone()) + .or_insert_with(HashSet::default) + .insert(uri.clone()); (Some(result_id), Vec::new()) } PulledDiagnostics::Changed { result_id, diagnostics, - } => { - changed_buffers.insert(uri.clone()); - (result_id, diagnostics) - } + } => (result_id, diagnostics), }; let disk_based_sources = Cow::Owned( self.language_server_adapter_for_id(server_id) @@ -11965,47 +12105,68 @@ impl LspStore { .unwrap_or(&[]) .to_vec(), ); - acc.entry(server_id) - .or_insert_with(Vec::new) - .push(DocumentDiagnosticsUpdate { - server_id, - diagnostics: lsp::PublishDiagnosticsParams { - uri, - diagnostics, - version, - }, - result_id, - disk_based_sources, - }); + + let Some(abs_path) = uri.to_file_path().ok() else { + return acc; + }; + let Some((worktree, relative_path)) = + self.worktree_store.read(cx).find_worktree(abs_path.clone(), cx) + else { + log::warn!("skipping workspace diagnostics update, no worktree found for path {abs_path:?}"); + return acc; + }; + let worktree_id = worktree.read(cx).id(); + let project_path = ProjectPath { + worktree_id, + path: relative_path, + }; + if let Some(local_lsp_store) = self.as_local_mut() { + local_lsp_store.workspace_pull_diagnostics_result_ids.entry(server_id) + .or_default().entry(new_registration_id.clone()).or_default().insert(abs_path, result_id.clone()); + } + // The LSP spec recommends that "diagnostics from a document pull should win over diagnostics from a workspace pull." + // Since we actively pull diagnostics for documents with open buffers, we ignore contents of workspace pulls for these documents. + if self.buffer_store.read(cx).get_by_path(&project_path).is_none() { + acc.entry(server_id) + .or_insert_with(HashMap::default) + .entry(new_registration_id.clone()) + .or_insert_with(Vec::new) + .push(DocumentDiagnosticsUpdate { + server_id, + diagnostics: lsp::PublishDiagnosticsParams { + uri, + diagnostics, + version, + }, + result_id, + disk_based_sources, + registration_id: new_registration_id, + }); + } acc }, ); for diagnostic_updates in workspace_diagnostics_updates.into_values() { - self.merge_lsp_diagnostics( - DiagnosticSourceKind::Pulled, - diagnostic_updates, - |buffer, old_diagnostic, cx| { - File::from_dyn(buffer.file()) - .and_then(|file| { - let abs_path = file.as_local()?.abs_path(cx); - lsp::Uri::from_file_path(abs_path).ok() - }) - .is_none_or(|buffer_uri| { - unchanged_buffers.contains(&buffer_uri) - || match old_diagnostic.source_kind { - DiagnosticSourceKind::Pulled => { - !changed_buffers.contains(&buffer_uri) - } - DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => { - true - } - } - }) - }, - cx, - ) - .log_err(); + for (registration_id, diagnostic_updates) in diagnostic_updates { + self.merge_lsp_diagnostics( + DiagnosticSourceKind::Pulled, + diagnostic_updates, + |document_uri, old_diagnostic, _| match old_diagnostic.source_kind { + DiagnosticSourceKind::Pulled => { + old_diagnostic.registration_id != registration_id + || unchanged_buffers + .get(&old_diagnostic.registration_id) + .is_some_and(|unchanged_buffers| { + unchanged_buffers.contains(&document_uri) + }) + } + DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => true, + }, + cx, + ) + .log_err(); + } } } @@ -12284,54 +12445,41 @@ impl LspStore { .diagnostics .insert(Some(reg.id.clone()), caps.clone()); - if let LanguageServerState::Running { - workspace_diagnostics_refresh_tasks, - .. - } = state - && let Some(task) = lsp_workspace_diagnostics_refresh( - Some(reg.id.clone()), - caps.clone(), - server.clone(), - cx, - ) - { - workspace_diagnostics_refresh_tasks.insert(Some(reg.id), task); + let supports_workspace_diagnostics = + |capabilities: &DiagnosticServerCapabilities| match capabilities { + DiagnosticServerCapabilities::Options(diagnostic_options) => { + diagnostic_options.workspace_diagnostics + } + DiagnosticServerCapabilities::RegistrationOptions( + diagnostic_registration_options, + ) => { + diagnostic_registration_options + .diagnostic_options + .workspace_diagnostics + } + }; + + if supports_workspace_diagnostics(&caps) { + if let LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } = state + && let Some(task) = lsp_workspace_diagnostics_refresh( + Some(reg.id.clone()), + caps.clone(), + server.clone(), + cx, + ) + { + workspace_diagnostics_refresh_tasks.insert(Some(reg.id), task); + } } - let mut did_update_caps = false; server.update_capabilities(|capabilities| { - if capabilities.diagnostic_provider.as_ref().is_none_or( - |current_caps| { - let supports_workspace_diagnostics = - |capabilities: &DiagnosticServerCapabilities| { - match capabilities { - DiagnosticServerCapabilities::Options( - diagnostic_options, - ) => diagnostic_options.workspace_diagnostics, - DiagnosticServerCapabilities::RegistrationOptions( - diagnostic_registration_options, - ) => { - diagnostic_registration_options - .diagnostic_options - .workspace_diagnostics - } - } - }; - // We don't actually care about capabilities.diagnostic_provider, but it IS relevant for the remote peer - // to know that there's at least one provider. Otherwise, it will never ask us to issue documentdiagnostic calls on their behalf, - // as it'll think that they're not supported. - // If we did not support any workspace diagnostics up to this point but now do, let's update. - !supports_workspace_diagnostics(current_caps) - & supports_workspace_diagnostics(&caps) - }, - ) { - did_update_caps = true; - capabilities.diagnostic_provider = Some(caps); - } + capabilities.diagnostic_provider = Some(caps); }); - if did_update_caps { - notify_server_capabilities_updated(&server, cx); - } + + notify_server_capabilities_updated(&server, cx); } } "textDocument/documentColor" => { @@ -12499,7 +12647,7 @@ impl LspStore { .language_servers .get_mut(&server_id) .context("Could not obtain Language Servers state")?; - let options = local + local .language_server_dynamic_registrations .get_mut(&server_id) .with_context(|| { @@ -12512,13 +12660,12 @@ impl LspStore { )?; let mut has_any_diagnostic_providers_still = true; - if let Some(identifier) = diagnostic_identifier(&options) - && let LanguageServerState::Running { - workspace_diagnostics_refresh_tasks, - .. - } = state + if let LanguageServerState::Running { + workspace_diagnostics_refresh_tasks, + .. + } = state { - workspace_diagnostics_refresh_tasks.remove(&identifier); + workspace_diagnostics_refresh_tasks.remove(&Some(unreg.id.clone())); has_any_diagnostic_providers_still = !workspace_diagnostics_refresh_tasks.is_empty(); } @@ -12822,7 +12969,8 @@ fn lsp_workspace_diagnostics_refresh( server: Arc, cx: &mut Context<'_, LspStore>, ) -> Option { - let identifier = diagnostic_identifier(&options)?; + let identifier = workspace_diagnostic_identifier(&options)?; + let registration_id_shared = registration_id.as_ref().map(SharedString::from); let (progress_tx, mut progress_rx) = mpsc::channel(1); let (mut refresh_tx, mut refresh_rx) = mpsc::channel(1); @@ -12854,13 +13002,13 @@ fn lsp_workspace_diagnostics_refresh( let Ok(previous_result_ids) = lsp_store.update(cx, |lsp_store, _| { lsp_store - .all_result_ids(server.server_id()) + .result_ids_for_workspace_refresh(server.server_id(), ®istration_id_shared) .into_iter() .filter_map(|(abs_path, result_id)| { let uri = file_path_to_lsp_url(&abs_path).ok()?; Some(lsp::PreviousResultId { uri, - value: result_id, + value: result_id.to_string(), }) }) .collect() @@ -12868,9 +13016,9 @@ fn lsp_workspace_diagnostics_refresh( return; }; - let token = if let Some(identifier) = ®istration_id { + let token = if let Some(registration_id) = ®istration_id { format!( - "workspace/diagnostic/{}/{requests}/{WORKSPACE_DIAGNOSTICS_TOKEN_START}{identifier}", + "workspace/diagnostic/{}/{requests}/{WORKSPACE_DIAGNOSTICS_TOKEN_START}{registration_id}", server.server_id(), ) } else { @@ -12920,6 +13068,7 @@ fn lsp_workspace_diagnostics_refresh( lsp_store.apply_workspace_diagnostic_report( server.server_id(), pulled_diagnostics, + registration_id_shared.clone(), cx, ) }) @@ -12941,7 +13090,21 @@ fn lsp_workspace_diagnostics_refresh( }) } -fn diagnostic_identifier(options: &DiagnosticServerCapabilities) -> Option> { +fn buffer_diagnostic_identifier(options: &DiagnosticServerCapabilities) -> Option { + match &options { + lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => { + diagnostic_options.identifier.clone() + } + lsp::DiagnosticServerCapabilities::RegistrationOptions(registration_options) => { + let diagnostic_options = ®istration_options.diagnostic_options; + diagnostic_options.identifier.clone() + } + } +} + +fn workspace_diagnostic_identifier( + options: &DiagnosticServerCapabilities, +) -> Option> { match &options { lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => { if !diagnostic_options.workspace_diagnostics { diff --git a/crates/project/src/lsp_store/clangd_ext.rs b/crates/project/src/lsp_store/clangd_ext.rs index b02f68dd4d1271ca9a8fa97e9ef41e03fdfe9763..466d0c6e2a0a37667854490433bb97265948d83e 100644 --- a/crates/project/src/lsp_store/clangd_ext.rs +++ b/crates/project/src/lsp_store/clangd_ext.rs @@ -90,6 +90,7 @@ pub fn register_notifications( disk_based_sources: Cow::Borrowed( &adapter.disk_based_diagnostic_sources, ), + registration_id: None, }], |_, diag, _| !is_inactive_region(diag), cx, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index afc854bceb59f88a496b6fcb99e840184277c894..f1060ee2560c82c540497133c046eed67d9f8eed 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -984,6 +984,8 @@ pub enum LspPullDiagnostics { server_id: LanguageServerId, /// URI of the resource, uri: lsp::Uri, + /// The ID provided by the dynamic registration that produced diagnostics. + registration_id: Option, /// The diagnostics produced by this language server. diagnostics: PulledDiagnostics, }, @@ -994,10 +996,10 @@ pub enum PulledDiagnostics { Unchanged { /// An ID the current pulled batch for this file. /// If given, can be used to query workspace diagnostics partially. - result_id: String, + result_id: SharedString, }, Changed { - result_id: Option, + result_id: Option, diagnostics: Vec, }, } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 3117c0f5944d05a08524608a82587226a735550e..8adba2dea16391c35096c487c4eff0098d52df56 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -2750,11 +2750,13 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) { ); let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/dir", json!({ "a.rs": text })).await; + fs.insert_tree(path!("/dir"), json!({ "a.rs": text })).await; - let project = Project::test(fs, ["/dir".as_ref()], cx).await; + let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await; let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) .await .unwrap(); @@ -2763,7 +2765,7 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) { lsp_store .update_diagnostic_entries( LanguageServerId(0), - PathBuf::from("/dir/a.rs"), + PathBuf::from(path!("/dir/a.rs")), None, None, vec![ @@ -2820,17 +2822,17 @@ async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppC init_test(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_tree("/dir", json!({ "a.rs": "one two three" })) + fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) .await; - let project = Project::test(fs, ["/dir".as_ref()], cx).await; + let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store.clone()); lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostic_entries( LanguageServerId(0), - Path::new("/dir/a.rs").to_owned(), + Path::new(path!("/dir/a.rs")).to_owned(), None, None, vec![DiagnosticEntry { @@ -2849,7 +2851,7 @@ async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppC lsp_store .update_diagnostic_entries( LanguageServerId(1), - Path::new("/dir/a.rs").to_owned(), + Path::new(path!("/dir/a.rs")).to_owned(), None, None, vec![DiagnosticEntry { diff --git a/crates/proto/proto/buffer.proto b/crates/proto/proto/buffer.proto index 4580fd8e9db80e7dc54b1c997f8df108e3bf9330..486716b36a221911ddf5abe1336a1e6cc3808769 100644 --- a/crates/proto/proto/buffer.proto +++ b/crates/proto/proto/buffer.proto @@ -258,6 +258,7 @@ message Diagnostic { Anchor start = 1; Anchor end = 2; optional string source = 3; + optional string registration_id = 17; enum SourceKind { Pulled = 0; diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index fa44528e2ed6009e6f18b6b5b9702b5228f10f05..7717cacdef70914c697e1a2a0e0234cd63970267 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -949,6 +949,7 @@ message PulledDiagnostics { optional string result_id = 3; bool changed = 4; repeated LspDiagnostic diagnostics = 5; + optional string registration_id = 6; } message PullWorkspaceDiagnostics { From 4ef8433396245ba38f5a4406551def1265abbe0d Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 3 Dec 2025 14:33:40 -0500 Subject: [PATCH 47/67] Run `git2::Repository::find_remote` in the background (#44092) We were seeing this hog the main thread. Release Notes: - N/A --------- Co-authored-by: cameron --- crates/fs/src/fake_git_repo.rs | 4 +- crates/git/src/repository.rs | 53 ++++++++++++------- .../src/git_hosting_providers.rs | 4 +- crates/project/src/git_store.rs | 8 +-- crates/project/src/telemetry_snapshot.rs | 2 +- 5 files changed, 43 insertions(+), 28 deletions(-) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index c641988ab891889b8ebb63c7e9414d69d3107558..b6beb9fc6ecb470b30c6ed4edca06be479db11c0 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -152,8 +152,8 @@ impl GitRepository for FakeGitRepository { }) } - fn remote_url(&self, _name: &str) -> Option { - None + fn remote_url(&self, _name: &str) -> BoxFuture<'_, Option> { + async move { None }.boxed() } fn diff_tree(&self, _request: DiffTreeType) -> BoxFuture<'_, Result> { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 23c5795209c1eda9acbf4fe9f48a4e3de898a89a..e49b1715901f3dcc463bee0e7870d69073fa0561 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -420,7 +420,7 @@ pub trait GitRepository: Send + Sync { ) -> BoxFuture<'_, anyhow::Result<()>>; /// Returns the URL of the remote with the given name. - fn remote_url(&self, name: &str) -> Option; + fn remote_url(&self, name: &str) -> BoxFuture<'_, Option>; /// Resolve a list of refs to SHAs. fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>>; @@ -1085,10 +1085,16 @@ impl GitRepository for RealGitRepository { .boxed() } - fn remote_url(&self, name: &str) -> Option { - let repo = self.repository.lock(); - let remote = repo.find_remote(name).ok()?; - remote.url().map(|url| url.to_string()) + fn remote_url(&self, name: &str) -> BoxFuture<'_, Option> { + let repo = self.repository.clone(); + let name = name.to_owned(); + self.executor + .spawn(async move { + let repo = repo.lock(); + let remote = repo.find_remote(&name).ok()?; + remote.url().map(|url| url.to_string()) + }) + .boxed() } fn revparse_batch(&self, revs: Vec) -> BoxFuture<'_, Result>>> { @@ -1465,23 +1471,30 @@ impl GitRepository for RealGitRepository { fn blame(&self, path: RepoPath, content: Rope) -> BoxFuture<'_, Result> { let working_directory = self.working_directory(); let git_binary_path = self.any_git_binary_path.clone(); + let executor = self.executor.clone(); - let remote_url = self - .remote_url("upstream") - .or_else(|| self.remote_url("origin")); - - self.executor - .spawn(async move { - crate::blame::Blame::for_path( - &git_binary_path, - &working_directory?, - &path, - &content, - remote_url, - ) + async move { + let remote_url = if let Some(remote_url) = self.remote_url("upstream").await { + Some(remote_url) + } else if let Some(remote_url) = self.remote_url("origin").await { + Some(remote_url) + } else { + None + }; + executor + .spawn(async move { + crate::blame::Blame::for_path( + &git_binary_path, + &working_directory?, + &path, + &content, + remote_url, + ) + .await + }) .await - }) - .boxed() + } + .boxed() } fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { diff --git a/crates/git_hosting_providers/src/git_hosting_providers.rs b/crates/git_hosting_providers/src/git_hosting_providers.rs index 6940ea382a1a21dbb3e97b55d74ee2489a1691ba..98ea301ec984298df54ec8bca7e28f9474e373bd 100644 --- a/crates/git_hosting_providers/src/git_hosting_providers.rs +++ b/crates/git_hosting_providers/src/git_hosting_providers.rs @@ -33,11 +33,11 @@ pub fn init(cx: &mut App) { /// /// These require information from the Git repository to construct, so their /// registration is deferred until we have a Git repository initialized. -pub fn register_additional_providers( +pub async fn register_additional_providers( provider_registry: Arc, repository: Arc, ) { - let Some(origin_url) = repository.remote_url("origin") else { + let Some(origin_url) = repository.remote_url("origin").await else { return; }; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 58181e20e961685f34c3298add113f847c3d93c5..5bc3f4ee43493ee9d07ab2c3a1025214007a653d 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1130,6 +1130,7 @@ impl GitStore { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let origin_url = backend .remote_url(&remote) + .await .with_context(|| format!("remote \"{remote}\" not found"))?; let sha = backend.head_sha().await.context("reading HEAD SHA")?; @@ -5447,7 +5448,8 @@ impl Repository { git_hosting_providers::register_additional_providers( git_hosting_provider_registry, state.backend.clone(), - ); + ) + .await; } let state = RepositoryState::Local(state); let mut jobs = VecDeque::new(); @@ -6052,8 +6054,8 @@ async fn compute_snapshot( } // Used by edit prediction data collection - let remote_origin_url = backend.remote_url("origin"); - let remote_upstream_url = backend.remote_url("upstream"); + let remote_origin_url = backend.remote_url("origin").await; + let remote_upstream_url = backend.remote_url("upstream").await; let snapshot = RepositorySnapshot { id, diff --git a/crates/project/src/telemetry_snapshot.rs b/crates/project/src/telemetry_snapshot.rs index d12481ae5e7abdeca9e9fdd693fc9721dbeb49dd..5f9155371d74887af25d6e7481848444c6f25112 100644 --- a/crates/project/src/telemetry_snapshot.rs +++ b/crates/project/src/telemetry_snapshot.rs @@ -96,7 +96,7 @@ impl TelemetryWorktreeSnapshot { }; }; - let remote_url = backend.remote_url("origin"); + let remote_url = backend.remote_url("origin").await; let head_sha = backend.head_sha().await; let diff = backend.diff(DiffType::HeadToWorktree).await.ok(); From 92dcfdef7657a80761837837d0f8cc54ace8aae9 Mon Sep 17 00:00:00 2001 From: feeiyu <158308373+feeiyu@users.noreply.github.com> Date: Thu, 4 Dec 2025 03:34:01 +0800 Subject: [PATCH 48/67] Fix circular reference issue around PopoverMenu again (#44084) Follow up to https://github.com/zed-industries/zed/pull/42351 Release Notes: - N/A --- crates/agent_ui/src/acp/mode_selector.rs | 5 +++-- crates/extensions_ui/src/extensions_ui.rs | 18 ++++++++++-------- crates/language_tools/src/lsp_log_view.rs | 4 ++-- crates/language_tools/src/syntax_tree_view.rs | 14 +++++++++----- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/crates/agent_ui/src/acp/mode_selector.rs b/crates/agent_ui/src/acp/mode_selector.rs index 2db031cafeb8a66e43120be9766debe3c16eb2d0..1f50ce74321d393ba6c7f5083bd889bc3dc2c0e1 100644 --- a/crates/agent_ui/src/acp/mode_selector.rs +++ b/crates/agent_ui/src/acp/mode_selector.rs @@ -161,7 +161,7 @@ impl Render for ModeSelector { .map(|mode| mode.name.clone()) .unwrap_or_else(|| "Unknown".into()); - let this = cx.entity(); + let this = cx.weak_entity(); let icon = if self.menu_handle.is_deployed() { IconName::ChevronUp @@ -222,7 +222,8 @@ impl Render for ModeSelector { y: px(-2.0), }) .menu(move |window, cx| { - Some(this.update(cx, |this, cx| this.build_context_menu(window, cx))) + this.update(cx, |this, cx| this.build_context_menu(window, cx)) + .ok() }) } } diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 11a5d1797a7173a9b5d23e2eae19bf028f37d7ed..89247ae5a49a99b2a4f2261892b2656e14bb8674 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -739,7 +739,7 @@ impl ExtensionsPage { extension: &ExtensionMetadata, cx: &mut Context, ) -> ExtensionCard { - let this = cx.entity(); + let this = cx.weak_entity(); let status = Self::extension_status(&extension.id, cx); let has_dev_extension = Self::dev_extension_exists(&extension.id, cx); @@ -889,13 +889,15 @@ impl ExtensionsPage { y: px(2.0), }) .menu(move |window, cx| { - Some(Self::render_remote_extension_context_menu( - &this, - extension_id.clone(), - authors.clone(), - window, - cx, - )) + this.upgrade().map(|this| { + Self::render_remote_extension_context_menu( + &this, + extension_id.clone(), + authors.clone(), + window, + cx, + ) + }) }), ), ), diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index df24f469495a2396410408a68f7310d1546eefde..4295985b5f846cbf1ff87a1012042ee6b6608945 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -937,7 +937,7 @@ impl Render for LspLogToolbarItemView { }) .collect(); - let log_toolbar_view = cx.entity(); + let log_toolbar_view = cx.weak_entity(); let lsp_menu = PopoverMenu::new("LspLogView") .anchor(Corner::TopLeft) @@ -1021,7 +1021,7 @@ impl Render for LspLogToolbarItemView { .icon_color(Color::Muted), ) .menu(move |window, cx| { - let log_toolbar_view = log_toolbar_view.clone(); + let log_toolbar_view = log_toolbar_view.upgrade()?; let log_view = log_view.clone(); Some(ContextMenu::build(window, cx, move |this, window, _| { this.entry( diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index 3ac007c134657ff33259f961f170d5a7d732a22c..c06ecd21e7f2eb86b4114ec2671f38297fd5fa25 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -614,13 +614,14 @@ impl SyntaxTreeToolbarItemView { let active_layer = buffer_state.active_layer.clone()?; let active_buffer = buffer_state.buffer.read(cx).snapshot(); - let view = cx.entity(); + let view = cx.weak_entity(); Some( PopoverMenu::new("Syntax Tree") .trigger(Self::render_header(&active_layer)) .menu(move |window, cx| { - ContextMenu::build(window, cx, |mut menu, window, _| { + ContextMenu::build(window, cx, |mut menu, _, _| { for (layer_ix, layer) in active_buffer.syntax_layers().enumerate() { + let view = view.clone(); menu = menu.entry( format!( "{} {}", @@ -628,9 +629,12 @@ impl SyntaxTreeToolbarItemView { format_node_range(layer.node()) ), None, - window.handler_for(&view, move |view, window, cx| { - view.select_layer(layer_ix, window, cx); - }), + move |window, cx| { + view.update(cx, |view, cx| { + view.select_layer(layer_ix, window, cx); + }) + .ok(); + }, ); } menu From 290a1550aaeab663b3aecead176bd1add68c8adb Mon Sep 17 00:00:00 2001 From: Michael Benfield Date: Wed, 3 Dec 2025 12:32:25 -0800 Subject: [PATCH 49/67] ai: Add an eval for the inline assistant (#43291) Release Notes: - N/A --------- Co-authored-by: Mikayla Maki --- Cargo.lock | 14 + Cargo.toml | 2 + crates/agent/Cargo.toml | 1 + crates/agent/src/edit_agent/evals.rs | 718 +++++++++++------------- crates/agent_ui/Cargo.toml | 8 +- crates/agent_ui/src/agent_panel.rs | 5 +- crates/agent_ui/src/agent_ui.rs | 2 + crates/agent_ui/src/buffer_codegen.rs | 1 + crates/agent_ui/src/evals.rs | 89 +++ crates/agent_ui/src/inline_assistant.rs | 196 ++++++- crates/eval_utils/Cargo.toml | 18 + crates/eval_utils/LICENSE-GPL | 1 + crates/eval_utils/README.md | 3 + crates/eval_utils/src/eval_utils.rs | 128 +++++ crates/gpui/src/app.rs | 28 + crates/gpui/src/app/test_context.rs | 12 +- crates/gpui/src/window.rs | 4 +- crates/http_client/src/http_client.rs | 2 + crates/language_model/src/registry.rs | 5 + 19 files changed, 836 insertions(+), 401 deletions(-) create mode 100644 crates/agent_ui/src/evals.rs create mode 100644 crates/eval_utils/Cargo.toml create mode 120000 crates/eval_utils/LICENSE-GPL create mode 100644 crates/eval_utils/README.md create mode 100644 crates/eval_utils/src/eval_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 3e2f12a91c2b76a393f7f99f68bcd05933cb27f1..03b7339856a9adba3538152ac3874fd0dec859b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,6 +159,7 @@ dependencies = [ "derive_more 0.99.20", "editor", "env_logger 0.11.8", + "eval_utils", "fs", "futures 0.3.31", "git", @@ -327,6 +328,7 @@ dependencies = [ "buffer_diff", "chrono", "client", + "clock", "cloud_llm_client", "collections", "command_palette_hooks", @@ -334,6 +336,7 @@ dependencies = [ "context_server", "db", "editor", + "eval_utils", "extension", "extension_host", "feature_flags", @@ -342,6 +345,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", + "gpui_tokio", "html_to_markdown", "http_client", "image", @@ -369,6 +373,7 @@ dependencies = [ "proto", "rand 0.9.2", "release_channel", + "reqwest_client", "rope", "rules_library", "schemars", @@ -5775,6 +5780,15 @@ dependencies = [ "watch", ] +[[package]] +name = "eval_utils" +version = "0.1.0" +dependencies = [ + "gpui", + "serde", + "smol", +] + [[package]] name = "event-listener" version = "2.5.3" diff --git a/Cargo.toml b/Cargo.toml index a6512c79093c197f5ed7a195f78bf7a170a15abe..e81e53426fc9ee47000e14cb8141ce4e4b6d8b30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ members = [ "crates/zeta2_tools", "crates/editor", "crates/eval", + "crates/eval_utils", "crates/explorer_command_injector", "crates/extension", "crates/extension_api", @@ -288,6 +289,7 @@ deepseek = { path = "crates/deepseek" } derive_refineable = { path = "crates/refineable/derive_refineable" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } +eval_utils = { path = "crates/eval_utils" } extension = { path = "crates/extension" } extension_host = { path = "crates/extension_host" } extensions_ui = { path = "crates/extensions_ui" } diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index cacbbd6e4e4423e2560fb963ef59daddce2309dc..667033a1bb33ea0372b8a9d8b0bfb00b23f59347 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -83,6 +83,7 @@ ctor.workspace = true db = { workspace = true, "features" = ["test-support"] } editor = { workspace = true, "features" = ["test-support"] } env_logger.workspace = true +eval_utils.workspace = true fs = { workspace = true, "features" = ["test-support"] } git = { workspace = true, "features" = ["test-support"] } gpui = { workspace = true, "features" = ["test-support"] } diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index 81dce33d0394b5757be4934031f31b6f17233e9c..edf8a0f671d231b3bfbd29526c256388fd41f85a 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -4,7 +4,7 @@ use crate::{ }; use Role::*; use client::{Client, UserStore}; -use collections::HashMap; +use eval_utils::{EvalOutput, EvalOutputProcessor, OutcomeKind}; use fs::FakeFs; use futures::{FutureExt, future::LocalBoxFuture}; use gpui::{AppContext, TestAppContext, Timer}; @@ -20,16 +20,62 @@ use rand::prelude::*; use reqwest_client::ReqwestClient; use serde_json::json; use std::{ - cmp::Reverse, fmt::{self, Display}, - io::Write as _, path::Path, str::FromStr, - sync::mpsc, time::Duration, }; use util::path; +#[derive(Default, Clone, Debug)] +struct EditAgentOutputProcessor { + mismatched_tag_threshold: f32, + cumulative_tags: usize, + cumulative_mismatched_tags: usize, + eval_outputs: Vec>, +} + +fn mismatched_tag_threshold(mismatched_tag_threshold: f32) -> EditAgentOutputProcessor { + EditAgentOutputProcessor { + mismatched_tag_threshold, + cumulative_tags: 0, + cumulative_mismatched_tags: 0, + eval_outputs: Vec::new(), + } +} + +#[derive(Clone, Debug)] +struct EditEvalMetadata { + tags: usize, + mismatched_tags: usize, +} + +impl EvalOutputProcessor for EditAgentOutputProcessor { + type Metadata = EditEvalMetadata; + + fn process(&mut self, output: &EvalOutput) { + if matches!(output.outcome, OutcomeKind::Passed | OutcomeKind::Failed) { + self.cumulative_mismatched_tags += output.metadata.mismatched_tags; + self.cumulative_tags += output.metadata.tags; + self.eval_outputs.push(output.clone()); + } + } + + fn assert(&mut self) { + let mismatched_tag_ratio = + self.cumulative_mismatched_tags as f32 / self.cumulative_tags as f32; + if mismatched_tag_ratio > self.mismatched_tag_threshold { + for eval_output in &self.eval_outputs { + println!("{}", eval_output.data); + } + panic!( + "Too many mismatched tags: {:?}", + self.cumulative_mismatched_tags + ); + } + } +} + #[test] #[cfg_attr(not(feature = "unit-eval"), ignore)] fn eval_extract_handle_command_output() { @@ -55,22 +101,19 @@ fn eval_extract_handle_command_output() { include_str!("evals/fixtures/extract_handle_command_output/possible-07.diff"), ]; let edit_description = "Extract `handle_command_output` method from `run_git_blame`."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and extract a method in - the final stanza of `run_git_blame` to deal with command failures, - call it `handle_command_output` and take the std::process::Output as the only parameter. - Do not document the method and do not add any comments. + Read the `{input_file_path}` file and extract a method in + the final stanza of `run_git_blame` to deal with command failures, + call it `handle_command_output` and take the std::process::Output as the only parameter. + Do not document the method and do not add any comments. - Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. - "})], + Add it right next to `run_git_blame` and copy it verbatim from `run_git_blame`. + "})], ), message( Assistant, @@ -102,9 +145,9 @@ fn eval_extract_handle_command_output() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] @@ -122,18 +165,16 @@ fn eval_delete_run_git_blame() { let input_file_content = include_str!("evals/fixtures/delete_run_git_blame/before.rs"); let output_file_content = include_str!("evals/fixtures/delete_run_git_blame/after.rs"); let edit_description = "Delete the `run_git_blame` function."; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and delete `run_git_blame`. Just that - one function, not its usages. - "})], + Read the `{input_file_path}` file and delete `run_git_blame`. Just that + one function, not its usages. + "})], ), message( Assistant, @@ -166,8 +207,8 @@ fn eval_delete_run_git_blame() { ], Some(input_file_content.into()), EvalAssertion::assert_eq(output_file_content), - ), - ); + )) + }); } #[test] @@ -185,18 +226,16 @@ fn eval_translate_doc_comments() { let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/translate_doc_comments/before.rs"); let edit_description = "Translate all doc comments to Italian"; - eval( - 200, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the {input_file_path} file and edit it (without overwriting it), - translating all the doc comments to italian. - "})], + Read the {input_file_path} file and edit it (without overwriting it), + translating all the doc comments to italian. + "})], ), message( Assistant, @@ -229,8 +268,8 @@ fn eval_translate_doc_comments() { ], Some(input_file_content.into()), EvalAssertion::judge_diff("Doc comments were translated to Italian"), - ), - ); + )) + }); } #[test] @@ -249,33 +288,31 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { let input_file_content = include_str!("evals/fixtures/use_wasi_sdk_in_compile_parser_to_wasm/before.rs"); let edit_description = "Update compile_parser_to_wasm to use wasi-sdk instead of emscripten"; - eval( - 100, - 0.95, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(formatdoc! {" - Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. - Use `ureq` to download the SDK for the current platform and architecture. - Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. - Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) - that's inside of the archive. - Don't re-download the SDK if that executable already exists. - - Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} - - Here are the available wasi-sdk assets: - - wasi-sdk-25.0-x86_64-macos.tar.gz - - wasi-sdk-25.0-arm64-macos.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-linux.tar.gz - - wasi-sdk-25.0-arm64-linux.tar.gz - - wasi-sdk-25.0-x86_64-windows.tar.gz - "})], + Read the `{input_file_path}` file and change `compile_parser_to_wasm` to use `wasi-sdk` instead of emscripten. + Use `ureq` to download the SDK for the current platform and architecture. + Extract the archive into a sibling of `lib` inside the `tree-sitter` directory in the cache_dir. + Compile the parser to wasm using the `bin/clang` executable (or `bin/clang.exe` on windows) + that's inside of the archive. + Don't re-download the SDK if that executable already exists. + + Use these clang flags: -fPIC -shared -Os -Wl,--export=tree_sitter_{{language_name}} + + Here are the available wasi-sdk assets: + - wasi-sdk-25.0-x86_64-macos.tar.gz + - wasi-sdk-25.0-arm64-macos.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-linux.tar.gz + - wasi-sdk-25.0-arm64-linux.tar.gz + - wasi-sdk-25.0-x86_64-windows.tar.gz + "})], ), message( Assistant, @@ -352,11 +389,11 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The compile_parser_to_wasm method has been changed to use wasi-sdk - - ureq is used to download the SDK for current platform and architecture - "}), - ), - ); + - The compile_parser_to_wasm method has been changed to use wasi-sdk + - ureq is used to download the SDK for current platform and architecture + "}), + )) + }); } #[test] @@ -380,11 +417,8 @@ fn eval_disable_cursor_blinking() { include_str!("evals/fixtures/disable_cursor_blinking/possible-03.diff"), include_str!("evals/fixtures/disable_cursor_blinking/possible-04.diff"), ]; - eval( - 100, - 0.51, - 0.05, - EvalInput::from_conversation( + eval_utils::eval(100, 0.51, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Let's research how to cursor blinking works.")]), message( @@ -421,10 +455,10 @@ fn eval_disable_cursor_blinking() { message( User, [text(indoc! {" - Comment out the lines that interact with the BlinkManager. - Keep the outer `update` blocks, but comments everything that's inside (including if statements). - Don't add additional comments. - "})], + Comment out the lines that interact with the BlinkManager. + Keep the outer `update` blocks, but comments everything that's inside (including if statements). + Don't add additional comments. + "})], ), message( Assistant, @@ -440,9 +474,9 @@ fn eval_disable_cursor_blinking() { ), ], Some(input_file_content.into()), - EvalAssertion::assert_diff_any(possible_diffs), - ), - ); + EvalAssertion::assert_diff_any(possible_diffs.clone()), + )) + }); } #[test] @@ -467,20 +501,16 @@ fn eval_from_pixels_constructor() { let input_file_path = "root/canvas.rs"; let input_file_content = include_str!("evals/fixtures/from_pixels_constructor/before.rs"); let edit_description = "Implement from_pixels constructor and add tests."; - eval( - 100, - 0.95, - // For whatever reason, this eval produces more mismatched tags. - // Increasing for now, let's see if we can bring this down. - 0.25, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.95, mismatched_tag_threshold(0.25), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new `from_pixels` constructor in Canvas and - also add tests for it in the same file. - "})], + Introduce a new `from_pixels` constructor in Canvas and + also add tests for it in the same file. + "})], ), message( Assistant, @@ -545,92 +575,92 @@ fn eval_from_pixels_constructor() { "tool_4", "grep", indoc! {" - Found 6 matches: + Found 6 matches: - ## Matches in font-kit/src/loaders/core_text.rs + ## Matches in font-kit/src/loaders/core_text.rs - ### mod test › L926-936 - ``` - mod test { - use super::Font; - use crate::properties::{Stretch, Weight}; + ### mod test › L926-936 + ``` + mod test { + use super::Font; + use crate::properties::{Stretch, Weight}; - #[cfg(feature = \"source\")] - use crate::source::SystemSource; + #[cfg(feature = \"source\")] + use crate::source::SystemSource; - static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; + static TEST_FONT_POSTSCRIPT_NAME: &'static str = \"ArialMT\"; - #[cfg(feature = \"source\")] - #[test] - ``` + #[cfg(feature = \"source\")] + #[test] + ``` - 55 lines remaining in ancestor node. Read the file to see all. + 55 lines remaining in ancestor node. Read the file to see all. - ### mod test › L947-951 - ``` - } + ### mod test › L947-951 + ``` + } - #[test] - fn test_core_text_to_css_font_weight() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_weight() { + // Exact matches + ``` - ### mod test › L959-963 - ``` - } + ### mod test › L959-963 + ``` + } - #[test] - fn test_core_text_to_css_font_stretch() { - // Exact matches - ``` + #[test] + fn test_core_text_to_css_font_stretch() { + // Exact matches + ``` - ## Matches in font-kit/src/loaders/freetype.rs + ## Matches in font-kit/src/loaders/freetype.rs - ### mod test › L1238-1248 - ``` - mod test { - use crate::loaders::freetype::Font; + ### mod test › L1238-1248 + ``` + mod test { + use crate::loaders::freetype::Font; - static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; - static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; + static PCF_FONT_PATH: &str = \"resources/tests/times-roman-pcf/timR12.pcf\"; + static PCF_FONT_POSTSCRIPT_NAME: &str = \"Times-Roman\"; - #[test] - fn get_pcf_postscript_name() { - let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); - assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); - } - ``` + #[test] + fn get_pcf_postscript_name() { + let font = Font::from_path(PCF_FONT_PATH, 0).unwrap(); + assert_eq!(font.postscript_name().unwrap(), PCF_FONT_POSTSCRIPT_NAME); + } + ``` - 1 lines remaining in ancestor node. Read the file to see all. + 1 lines remaining in ancestor node. Read the file to see all. - ## Matches in font-kit/src/sources/core_text.rs + ## Matches in font-kit/src/sources/core_text.rs - ### mod test › L265-275 - ``` - mod test { - use crate::properties::{Stretch, Weight}; + ### mod test › L265-275 + ``` + mod test { + use crate::properties::{Stretch, Weight}; - #[test] - fn test_css_to_core_text_font_weight() { - // Exact matches - assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); - assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); - assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); - assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); + #[test] + fn test_css_to_core_text_font_weight() { + // Exact matches + assert_eq!(super::css_to_core_text_font_weight(Weight(100.0)), -0.7); + assert_eq!(super::css_to_core_text_font_weight(Weight(400.0)), 0.0); + assert_eq!(super::css_to_core_text_font_weight(Weight(700.0)), 0.4); + assert_eq!(super::css_to_core_text_font_weight(Weight(900.0)), 0.8); - ``` + ``` - 27 lines remaining in ancestor node. Read the file to see all. + 27 lines remaining in ancestor node. Read the file to see all. - ### mod test › L278-282 - ``` - } + ### mod test › L278-282 + ``` + } - #[test] - fn test_css_to_core_text_font_stretch() { - // Exact matches - ``` - "}, + #[test] + fn test_css_to_core_text_font_stretch() { + // Exact matches + ``` + "}, )], ), message( @@ -648,11 +678,11 @@ fn eval_from_pixels_constructor() { ], Some(input_file_content.into()), EvalAssertion::judge_diff(indoc! {" - - The diff contains a new `from_pixels` constructor - - The diff contains new tests for the `from_pixels` constructor - "}), - ), - ); + - The diff contains a new `from_pixels` constructor + - The diff contains new tests for the `from_pixels` constructor + "}), + )) + }); } #[test] @@ -670,11 +700,9 @@ fn eval_zode() { let input_file_path = "root/zode.py"; let input_content = None; let edit_description = "Create the main Zode CLI script"; - eval( - 50, - 1., - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(50, 1., mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text(include_str!("evals/fixtures/zode/prompt.md"))]), message( @@ -733,7 +761,7 @@ fn eval_zode() { ], ), ], - input_content, + input_content.clone(), EvalAssertion::new(async move |sample, _, _cx| { let invalid_starts = [' ', '`', '\n']; let mut message = String::new(); @@ -758,8 +786,8 @@ fn eval_zode() { }) } }), - ), - ); + )) + }); } #[test] @@ -777,19 +805,17 @@ fn eval_add_overwrite_test() { let input_file_path = "root/action_log.rs"; let input_file_content = include_str!("evals/fixtures/add_overwrite_test/before.rs"); let edit_description = "Add a new test for overwriting a file in action_log.rs"; - eval( - 200, - 0.5, // TODO: make this eval better - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(200, 0.5, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message( User, [text(indoc! {" - Introduce a new test in `action_log.rs` to test overwriting a file. - That is, a file already exists, but we call `buffer_created` as if the file were new. - Take inspiration from all the other tests in the file. - "})], + Introduce a new test in `action_log.rs` to test overwriting a file. + That is, a file already exists, but we call `buffer_created` as if the file were new. + Take inspiration from all the other tests in the file. + "})], ), message( Assistant, @@ -809,81 +835,81 @@ fn eval_add_overwrite_test() { "tool_1", "read_file", indoc! {" - pub struct ActionLog [L13-20] - tracked_buffers [L15] - edited_since_project_diagnostics_check [L17] - project [L19] - impl ActionLog [L22-498] - pub fn new [L24-30] - pub fn project [L32-34] - pub fn checked_project_diagnostics [L37-39] - pub fn has_edited_files_since_project_diagnostics_check [L42-44] - fn track_buffer_internal [L46-101] - fn handle_buffer_event [L103-116] - fn handle_buffer_edited [L118-123] - fn handle_buffer_file_changed [L125-158] - async fn maintain_diff [L160-264] - pub fn buffer_read [L267-269] - pub fn buffer_created [L272-276] - pub fn buffer_edited [L279-287] - pub fn will_delete_buffer [L289-304] - pub fn keep_edits_in_range [L306-364] - pub fn reject_edits_in_ranges [L366-459] - pub fn keep_all_edits [L461-473] - pub fn changed_buffers [L476-482] - pub fn stale_buffers [L485-497] - fn apply_non_conflicting_edits [L500-561] - fn diff_snapshots [L563-585] - fn point_to_row_edit [L587-614] - enum ChangeAuthor [L617-620] - User [L618] - Agent [L619] - enum TrackedBufferStatus [L623-627] - Created [L624] - Modified [L625] - Deleted [L626] - struct TrackedBuffer [L629-641] - buffer [L630] - base_text [L631] - unreviewed_changes [L632] - status [L633] - version [L634] - diff [L635] - snapshot [L636] - diff_update [L637] - _open_lsp_handle [L638] - _maintain_diff [L639] - _subscription [L640] - impl TrackedBuffer [L643-657] - fn has_changes [L644-650] - fn schedule_diff_update [L652-656] - pub struct ChangedBuffer [L659-661] - pub diff [L660] - mod tests [L664-1574] - fn init_logger [L678-682] - fn init_test [L684-691] - async fn test_keep_edits [L694-769] - async fn test_deletions [L772-854] - async fn test_overlapping_user_edits [L857-951] - async fn test_creating_files [L954-1010] - async fn test_deleting_files [L1013-1120] - async fn test_reject_edits [L1123-1255] - async fn test_reject_multiple_edits [L1258-1331] - async fn test_reject_deleted_file [L1334-1388] - async fn test_reject_created_file [L1391-1443] - async fn test_random_diffs [L1446-1535] - fn quiesce [L1510-1534] - struct HunkStatus [L1538-1542] - range [L1539] - diff_status [L1540] - old_text [L1541] - fn unreviewed_hunks [L1544-1573] - - Showing symbols 1-69 (total symbols: 69) - - Using the line numbers in this outline, you can call this tool again while specifying - the start_line and end_line fields to see the implementations of symbols in the outline. - "}, + pub struct ActionLog [L13-20] + tracked_buffers [L15] + edited_since_project_diagnostics_check [L17] + project [L19] + impl ActionLog [L22-498] + pub fn new [L24-30] + pub fn project [L32-34] + pub fn checked_project_diagnostics [L37-39] + pub fn has_edited_files_since_project_diagnostics_check [L42-44] + fn track_buffer_internal [L46-101] + fn handle_buffer_event [L103-116] + fn handle_buffer_edited [L118-123] + fn handle_buffer_file_changed [L125-158] + async fn maintain_diff [L160-264] + pub fn buffer_read [L267-269] + pub fn buffer_created [L272-276] + pub fn buffer_edited [L279-287] + pub fn will_delete_buffer [L289-304] + pub fn keep_edits_in_range [L306-364] + pub fn reject_edits_in_ranges [L366-459] + pub fn keep_all_edits [L461-473] + pub fn changed_buffers [L476-482] + pub fn stale_buffers [L485-497] + fn apply_non_conflicting_edits [L500-561] + fn diff_snapshots [L563-585] + fn point_to_row_edit [L587-614] + enum ChangeAuthor [L617-620] + User [L618] + Agent [L619] + enum TrackedBufferStatus [L623-627] + Created [L624] + Modified [L625] + Deleted [L626] + struct TrackedBuffer [L629-641] + buffer [L630] + base_text [L631] + unreviewed_changes [L632] + status [L633] + version [L634] + diff [L635] + snapshot [L636] + diff_update [L637] + _open_lsp_handle [L638] + _maintain_diff [L639] + _subscription [L640] + impl TrackedBuffer [L643-657] + fn has_changes [L644-650] + fn schedule_diff_update [L652-656] + pub struct ChangedBuffer [L659-661] + pub diff [L660] + mod tests [L664-1574] + fn init_logger [L678-682] + fn init_test [L684-691] + async fn test_keep_edits [L694-769] + async fn test_deletions [L772-854] + async fn test_overlapping_user_edits [L857-951] + async fn test_creating_files [L954-1010] + async fn test_deleting_files [L1013-1120] + async fn test_reject_edits [L1123-1255] + async fn test_reject_multiple_edits [L1258-1331] + async fn test_reject_deleted_file [L1334-1388] + async fn test_reject_created_file [L1391-1443] + async fn test_random_diffs [L1446-1535] + fn quiesce [L1510-1534] + struct HunkStatus [L1538-1542] + range [L1539] + diff_status [L1540] + old_text [L1541] + fn unreviewed_hunks [L1544-1573] + + Showing symbols 1-69 (total symbols: 69) + + Using the line numbers in this outline, you can call this tool again while specifying + the start_line and end_line fields to see the implementations of symbols in the outline. + "}, )], ), message( @@ -963,17 +989,17 @@ fn eval_add_overwrite_test() { Assistant, [ text(indoc! {" - Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: + Based on my understanding of the existing tests and code, I'll now create a new test for overwriting a file. In this test, we'll: - 1. Create a file - 2. Open a buffer for it - 3. Track it in the action log - 4. Modify the buffer - 5. Call `buffer_created` again as if the file were new - 6. Check that the changes are properly tracked + 1. Create a file + 2. Open a buffer for it + 3. Track it in the action log + 4. Modify the buffer + 5. Call `buffer_created` again as if the file were new + 6. Check that the changes are properly tracked - Let's write the test: - "}), + Let's write the test: + "}), tool_use( "tool_5", "edit_file", @@ -990,8 +1016,8 @@ fn eval_add_overwrite_test() { EvalAssertion::judge_diff( "A new test for overwritten files was created, without changing any previous test", ), - ), - ); + )) + }); } #[test] @@ -1016,20 +1042,18 @@ fn eval_create_empty_file() { let input_file_content = None; let expected_output_content = String::new(); - eval( - 100, - 0.99, - 0.05, - EvalInput::from_conversation( + + eval_utils::eval(100, 0.99, mismatched_tag_threshold(0.05), move || { + run_eval(EvalInput::from_conversation( vec![ message(User, [text("Create a second empty todo file ")]), message( Assistant, [ text(formatdoc! {" - I'll help you create a second empty todo file. - First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. - "}), + I'll help you create a second empty todo file. + First, let me examine the project structure to see if there's already a todo file, which will help me determine the appropriate name and location for the second one. + "}), tool_use( "toolu_01GAF8TtsgpjKxCr8fgQLDgR", "list_directory", @@ -1051,8 +1075,8 @@ fn eval_create_empty_file() { Assistant, [ text(formatdoc! {" - I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: - "}), + I can see there's already a `TODO` file in the `root` directory. Let me create a second empty todo file called `TODO3` in the same directory: + "}), tool_use( "toolu_01Tb3iQ9griqSYMmVuykQPWU", "edit_file", @@ -1065,12 +1089,12 @@ fn eval_create_empty_file() { ], ), ], - input_file_content, + input_file_content.clone(), // Bad behavior is to write something like // "I'll create an empty TODO3 file as requested." - EvalAssertion::assert_eq(expected_output_content), - ), - ); + EvalAssertion::assert_eq(expected_output_content.clone()), + )) + }); } fn message( @@ -1312,115 +1336,44 @@ impl EvalAssertion { } } -fn eval( - iterations: usize, - expected_pass_ratio: f32, - mismatched_tag_threshold: f32, - mut eval: EvalInput, -) { - let mut evaluated_count = 0; - let mut failed_count = 0; - report_progress(evaluated_count, failed_count, iterations); - - let (tx, rx) = mpsc::channel(); - - // Cache the last message in the conversation, and run one instance of the eval so that - // all the next ones are cached. - eval.conversation.last_mut().unwrap().cache = true; - run_eval(eval.clone(), tx.clone()); - - let executor = gpui::background_executor(); - let semaphore = Arc::new(smol::lock::Semaphore::new(32)); - for _ in 1..iterations { - let eval = eval.clone(); - let tx = tx.clone(); - let semaphore = semaphore.clone(); - executor - .spawn(async move { - let _guard = semaphore.acquire().await; - run_eval(eval, tx) - }) - .detach(); - } - drop(tx); - - let mut failed_evals = HashMap::default(); - let mut errored_evals = HashMap::default(); - let mut eval_outputs = Vec::new(); - let mut cumulative_parser_metrics = EditParserMetrics::default(); - while let Ok(output) = rx.recv() { - match output { - Ok(output) => { - cumulative_parser_metrics += output.sample.edit_output.parser_metrics.clone(); - eval_outputs.push(output.clone()); - if output.assertion.score < 80 { - failed_count += 1; - failed_evals - .entry(output.sample.text_after.clone()) - .or_insert(Vec::new()) - .push(output); - } - } - Err(error) => { - failed_count += 1; - *errored_evals.entry(format!("{:?}", error)).or_insert(0) += 1; - } - } - - evaluated_count += 1; - report_progress(evaluated_count, failed_count, iterations); - } - - let actual_pass_ratio = (iterations - failed_count) as f32 / iterations as f32; - println!("Actual pass ratio: {}\n", actual_pass_ratio); - if actual_pass_ratio < expected_pass_ratio { - let mut errored_evals = errored_evals.into_iter().collect::>(); - errored_evals.sort_by_key(|(_, count)| Reverse(*count)); - for (error, count) in errored_evals { - println!("Eval errored {} times. Error: {}", count, error); - } - - let mut failed_evals = failed_evals.into_iter().collect::>(); - failed_evals.sort_by_key(|(_, evals)| Reverse(evals.len())); - for (_buffer_output, failed_evals) in failed_evals { - let eval_output = failed_evals.first().unwrap(); - println!("Eval failed {} times", failed_evals.len()); - println!("{}", eval_output); - } - - panic!( - "Actual pass ratio: {}\nExpected pass ratio: {}", - actual_pass_ratio, expected_pass_ratio - ); - } - - let mismatched_tag_ratio = - cumulative_parser_metrics.mismatched_tags as f32 / cumulative_parser_metrics.tags as f32; - if mismatched_tag_ratio > mismatched_tag_threshold { - for eval_output in eval_outputs { - println!("{}", eval_output); - } - panic!("Too many mismatched tags: {:?}", cumulative_parser_metrics); - } -} - -fn run_eval(eval: EvalInput, tx: mpsc::Sender>) { +fn run_eval(eval: EvalInput) -> eval_utils::EvalOutput { let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); let mut cx = TestAppContext::build(dispatcher, None); - let output = cx.executor().block_test(async { + let result = cx.executor().block_test(async { let test = EditAgentTest::new(&mut cx).await; test.eval(eval, &mut cx).await }); - tx.send(output).unwrap(); + match result { + Ok(output) => eval_utils::EvalOutput { + data: output.to_string(), + outcome: if output.assertion.score < 80 { + eval_utils::OutcomeKind::Failed + } else { + eval_utils::OutcomeKind::Passed + }, + metadata: EditEvalMetadata { + tags: output.sample.edit_output.parser_metrics.tags, + mismatched_tags: output.sample.edit_output.parser_metrics.mismatched_tags, + }, + }, + Err(e) => eval_utils::EvalOutput { + data: format!("{e:?}"), + outcome: eval_utils::OutcomeKind::Error, + metadata: EditEvalMetadata { + tags: 0, + mismatched_tags: 0, + }, + }, + } } #[derive(Clone)] -struct EvalOutput { +struct EditEvalOutput { sample: EvalSample, assertion: EvalAssertionOutcome, } -impl Display for EvalOutput { +impl Display for EditEvalOutput { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { writeln!(f, "Score: {:?}", self.assertion.score)?; if let Some(message) = self.assertion.message.as_ref() { @@ -1439,22 +1392,6 @@ impl Display for EvalOutput { } } -fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usize) { - let passed_count = evaluated_count - failed_count; - let passed_ratio = if evaluated_count == 0 { - 0.0 - } else { - passed_count as f64 / evaluated_count as f64 - }; - print!( - "\r\x1b[KEvaluated {}/{} ({:.2}% passed)", - evaluated_count, - iterations, - passed_ratio * 100.0 - ); - std::io::stdout().flush().unwrap(); -} - struct EditAgentTest { agent: EditAgent, project: Entity, @@ -1550,7 +1487,10 @@ impl EditAgentTest { }) } - async fn eval(&self, eval: EvalInput, cx: &mut TestAppContext) -> Result { + async fn eval(&self, mut eval: EvalInput, cx: &mut TestAppContext) -> Result { + // Make sure the last message in the conversation is cached. + eval.conversation.last_mut().unwrap().cache = true; + let path = self .project .read_with(cx, |project, cx| { @@ -1656,7 +1596,7 @@ impl EditAgentTest { .run(&sample, self.judge_model.clone(), cx) .await?; - Ok(EvalOutput { assertion, sample }) + Ok(EditEvalOutput { assertion, sample }) } } diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index 0f52c07078f447c9d8a95312ccd96561516907a1..048ffab9b72bdecce3754320bf34f1702f021554 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -13,7 +13,8 @@ path = "src/agent_ui.rs" doctest = false [features] -test-support = ["gpui/test-support", "language/test-support"] +test-support = ["gpui/test-support", "language/test-support", "reqwest_client"] +unit-eval = [] [dependencies] acp_thread.workspace = true @@ -47,6 +48,7 @@ fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +gpui_tokio.workspace = true html_to_markdown.workspace = true http_client.workspace = true indoc.workspace = true @@ -98,14 +100,17 @@ workspace.workspace = true zed_actions.workspace = true image.workspace = true async-fs.workspace = true +reqwest_client = { workspace = true, optional = true } [dev-dependencies] acp_thread = { workspace = true, features = ["test-support"] } agent = { workspace = true, features = ["test-support"] } assistant_text_thread = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } +clock.workspace = true db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +eval_utils.workspace = true gpui = { workspace = true, "features" = ["test-support"] } indoc.workspace = true language = { workspace = true, "features" = ["test-support"] } @@ -115,5 +120,6 @@ pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true rand.workspace = true +reqwest_client.workspace = true tree-sitter-md.workspace = true unindent.workspace = true diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9dd77774ff4e6f00bdfd26d024e9ee4b389b7f7e..18e8f1e731defa82e865dd45e66389634992037c 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2685,16 +2685,17 @@ impl rules_library::InlineAssistDelegate for PromptLibraryInlineAssist { return; }; let project = workspace.read(cx).project().downgrade(); + let thread_store = panel.read(cx).thread_store().clone(); assistant.assist( prompt_editor, self.workspace.clone(), project, - panel.read(cx).thread_store().clone(), + thread_store, None, initial_prompt, window, cx, - ) + ); }) } diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 5f5682b7dcc90d2b779744ba353380987a5907a1..f7b07b7bd393b8d3efffc3757eaf6025d5c651cd 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -7,6 +7,8 @@ mod buffer_codegen; mod completion_provider; mod context; mod context_server_configuration; +#[cfg(test)] +mod evals; mod inline_assistant; mod inline_prompt_editor; mod language_model_selector; diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1ac3ec1aec38c8d44d7557e1cf1e3ff09832c9d9..972ead664464876e57d7830b18db3f2b0c49629c 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -719,6 +719,7 @@ impl CodegenAlternative { output_tokens = usage.output_tokens, ) } + cx.emit(CodegenEvent::Finished); cx.notify(); }) diff --git a/crates/agent_ui/src/evals.rs b/crates/agent_ui/src/evals.rs new file mode 100644 index 0000000000000000000000000000000000000000..e82d21bd1fdb02a666c61bdf4754f27e79f92fda --- /dev/null +++ b/crates/agent_ui/src/evals.rs @@ -0,0 +1,89 @@ +use std::str::FromStr; + +use crate::inline_assistant::test::run_inline_assistant_test; + +use eval_utils::{EvalOutput, NoProcessor}; +use gpui::TestAppContext; +use language_model::{LanguageModelRegistry, SelectedModel}; +use rand::{SeedableRng as _, rngs::StdRng}; + +#[test] +#[cfg_attr(not(feature = "unit-eval"), ignore)] +fn eval_single_cursor_edit() { + eval_utils::eval(20, 1.0, NoProcessor, move || { + run_eval( + &EvalInput { + prompt: "Rename this variable to buffer_text".to_string(), + buffer: indoc::indoc! {" + struct EvalExampleStruct { + text: Strˇing, + prompt: String, + } + "} + .to_string(), + }, + &|_, output| { + let expected = indoc::indoc! {" + struct EvalExampleStruct { + buffer_text: String, + prompt: String, + } + "}; + if output == expected { + EvalOutput { + outcome: eval_utils::OutcomeKind::Passed, + data: "Passed!".to_string(), + metadata: (), + } + } else { + EvalOutput { + outcome: eval_utils::OutcomeKind::Failed, + data: format!("Failed to rename variable, output: {}", output), + metadata: (), + } + } + }, + ) + }); +} + +struct EvalInput { + buffer: String, + prompt: String, +} + +fn run_eval( + input: &EvalInput, + judge: &dyn Fn(&EvalInput, &str) -> eval_utils::EvalOutput<()>, +) -> eval_utils::EvalOutput<()> { + let dispatcher = gpui::TestDispatcher::new(StdRng::from_os_rng()); + let mut cx = TestAppContext::build(dispatcher, None); + cx.skip_drawing(); + + let buffer_text = run_inline_assistant_test( + input.buffer.clone(), + input.prompt.clone(), + |cx| { + // Reconfigure to use a real model instead of the fake one + let model_name = std::env::var("ZED_AGENT_MODEL") + .unwrap_or("anthropic/claude-sonnet-4-latest".into()); + + let selected_model = SelectedModel::from_str(&model_name) + .expect("Invalid model format. Use 'provider/model-id'"); + + log::info!("Selected model: {selected_model:?}"); + + cx.update(|_, cx| { + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + registry.select_inline_assistant_model(Some(&selected_model), cx); + }); + }); + }, + |_cx| { + log::info!("Waiting for actual response from the LLM..."); + }, + &mut cx, + ); + + judge(input, &buffer_text) +} diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index 3f27d0985991f19148cc852c44bfa60c57eaf750..cbc5891036fdf03ee04cca6b77820748faed2d0a 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -32,7 +32,7 @@ use editor::{ }, }; use fs::Fs; -use futures::FutureExt; +use futures::{FutureExt, channel::mpsc}; use gpui::{ App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal, WeakEntity, Window, point, @@ -102,6 +102,7 @@ pub struct InlineAssistant { prompt_builder: Arc, telemetry: Arc, fs: Arc, + _inline_assistant_completions: Option>>, } impl Global for InlineAssistant {} @@ -123,9 +124,18 @@ impl InlineAssistant { prompt_builder, telemetry, fs, + _inline_assistant_completions: None, } } + #[cfg(any(test, feature = "test-support"))] + pub fn set_completion_receiver( + &mut self, + sender: mpsc::UnboundedSender>, + ) { + self._inline_assistant_completions = Some(sender); + } + pub fn register_workspace( &mut self, workspace: &Entity, @@ -287,7 +297,7 @@ impl InlineAssistant { action.prompt.clone(), window, cx, - ) + ); }) } InlineAssistTarget::Terminal(active_terminal) => { @@ -301,8 +311,8 @@ impl InlineAssistant { action.prompt.clone(), window, cx, - ) - }) + ); + }); } }; @@ -598,13 +608,13 @@ impl InlineAssistant { initial_prompt: Option, window: &mut Window, cx: &mut App, - ) { + ) -> Option { let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx)); let Some((codegen_ranges, newest_selection)) = self.codegen_ranges(editor, &snapshot, window, cx) else { - return; + return None; }; let assist_to_focus = self.batch_assist( @@ -624,6 +634,8 @@ impl InlineAssistant { if let Some(assist_id) = assist_to_focus { self.focus_assist(assist_id, window, cx); } + + assist_to_focus } pub fn suggest_assist( @@ -1740,6 +1752,16 @@ impl InlineAssist { && assist.decorations.is_none() && let Some(workspace) = assist.workspace.upgrade() { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender + .unbounded_send(Err(anyhow::anyhow!( + "Inline assistant error: {}", + error + ))) + .ok(); + } + let error = format!("Inline assistant error: {}", error); workspace.update(cx, |workspace, cx| { struct InlineAssistantError; @@ -1750,6 +1772,11 @@ impl InlineAssist { workspace.show_toast(Toast::new(id, error), cx); }) + } else { + #[cfg(any(test, feature = "test-support"))] + if let Some(sender) = &mut this._inline_assistant_completions { + sender.unbounded_send(Ok(assist_id)).ok(); + } } if assist.decorations.is_none() { @@ -1943,3 +1970,160 @@ fn merge_ranges(ranges: &mut Vec>, buffer: &MultiBufferSnapshot) { } } } + +#[cfg(any(test, feature = "test-support"))] +pub mod test { + use std::sync::Arc; + + use agent::HistoryStore; + use assistant_text_thread::TextThreadStore; + use client::{Client, UserStore}; + use editor::{Editor, MultiBuffer, MultiBufferOffset}; + use fs::FakeFs; + use futures::channel::mpsc; + use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; + use language::Buffer; + use language_model::LanguageModelRegistry; + use project::Project; + use prompt_store::PromptBuilder; + use smol::stream::StreamExt as _; + use util::test::marked_text_ranges; + use workspace::Workspace; + + use crate::InlineAssistant; + + pub fn run_inline_assistant_test( + base_buffer: String, + prompt: String, + setup: SetupF, + test: TestF, + cx: &mut TestAppContext, + ) -> String + where + SetupF: FnOnce(&mut gpui::VisualTestContext), + TestF: FnOnce(&mut gpui::VisualTestContext), + { + let fs = FakeFs::new(cx.executor()); + let app_state = cx.update(|cx| workspace::AppState::test(cx)); + let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); + let http = Arc::new(reqwest_client::ReqwestClient::user_agent("agent tests").unwrap()); + let client = cx.update(|cx| { + cx.set_http_client(http); + Client::production(cx) + }); + let mut inline_assistant = + InlineAssistant::new(fs.clone(), prompt_builder, client.telemetry().clone()); + + let (tx, mut completion_rx) = mpsc::unbounded(); + inline_assistant.set_completion_receiver(tx); + + // Initialize settings and client + cx.update(|cx| { + gpui_tokio::init(cx); + settings::init(cx); + client::init(&client, cx); + workspace::init(app_state.clone(), cx); + let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); + language_model::init(client.clone(), cx); + language_models::init(user_store, client.clone(), cx); + + cx.set_global(inline_assistant); + }); + + let project = cx + .executor() + .block_test(async { Project::test(fs.clone(), [], cx).await }); + + // Create workspace with window + let (workspace, cx) = cx.add_window_view(|window, cx| { + window.activate_window(); + Workspace::new(None, project.clone(), app_state.clone(), window, cx) + }); + + setup(cx); + + let (_editor, buffer) = cx.update(|window, cx| { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let multibuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); + let editor = cx.new(|cx| Editor::for_multibuffer(multibuffer, None, window, cx)); + editor.update(cx, |editor, cx| { + let (unmarked_text, selection_ranges) = marked_text_ranges(&base_buffer, true); + editor.set_text(unmarked_text, window, cx); + editor.change_selections(Default::default(), window, cx, |s| { + s.select_ranges( + selection_ranges.into_iter().map(|range| { + MultiBufferOffset(range.start)..MultiBufferOffset(range.end) + }), + ) + }) + }); + + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx)); + + // Add editor to workspace + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + + // Call assist method + InlineAssistant::update_global(cx, |inline_assistant, cx| { + let assist_id = inline_assistant + .assist( + &editor, + workspace.downgrade(), + project.downgrade(), + history_store, // thread_store + None, // prompt_store + Some(prompt), + window, + cx, + ) + .unwrap(); + + inline_assistant.start_assist(assist_id, window, cx); + }); + + (editor, buffer) + }); + + cx.run_until_parked(); + + test(cx); + + cx.executor() + .block_test(async { completion_rx.next().await }); + + buffer.read_with(cx, |buffer, _| buffer.text()) + } + + #[allow(unused)] + pub fn test_inline_assistant( + base_buffer: &'static str, + llm_output: &'static str, + cx: &mut TestAppContext, + ) -> String { + run_inline_assistant_test( + base_buffer.to_string(), + "Prompt doesn't matter because we're using a fake model".to_string(), + |cx| { + cx.update(|_, cx| LanguageModelRegistry::test(cx)); + }, + |cx| { + let fake_model = cx.update(|_, cx| { + LanguageModelRegistry::global(cx) + .update(cx, |registry, _| registry.fake_model()) + }); + let fake = fake_model.as_fake(); + + // let fake = fake_model; + fake.send_last_completion_stream_text_chunk(llm_output.to_string()); + fake.end_last_completion_stream(); + + // Run again to process the model's response + cx.run_until_parked(); + }, + cx, + ) + } +} diff --git a/crates/eval_utils/Cargo.toml b/crates/eval_utils/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..a512035f5d1754f0f6f942faa27d063e169a22ef --- /dev/null +++ b/crates/eval_utils/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "eval_utils" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/eval_utils.rs" +doctest = false + +[dependencies] +gpui.workspace = true +serde.workspace = true +smol.workspace = true diff --git a/crates/eval_utils/LICENSE-GPL b/crates/eval_utils/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..e0f9dbd5d63fef1630c297edc4ceba4790be6f02 --- /dev/null +++ b/crates/eval_utils/LICENSE-GPL @@ -0,0 +1 @@ +LICENSE-GPL \ No newline at end of file diff --git a/crates/eval_utils/README.md b/crates/eval_utils/README.md new file mode 100644 index 0000000000000000000000000000000000000000..617077a81524ff918e8b9b93aa970d636504479c --- /dev/null +++ b/crates/eval_utils/README.md @@ -0,0 +1,3 @@ +# eval_utils + +Utilities for evals of agents. diff --git a/crates/eval_utils/src/eval_utils.rs b/crates/eval_utils/src/eval_utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..880b1a97e414bbc3219bdf8f7163dbf9b6c9c82b --- /dev/null +++ b/crates/eval_utils/src/eval_utils.rs @@ -0,0 +1,128 @@ +//! Utilities for evaluation and benchmarking. + +use std::{ + collections::HashMap, + sync::{Arc, mpsc}, +}; + +fn report_progress(evaluated_count: usize, failed_count: usize, iterations: usize) { + let passed_count = evaluated_count - failed_count; + let passed_ratio = if evaluated_count == 0 { + 0.0 + } else { + passed_count as f64 / evaluated_count as f64 + }; + println!( + "\r\x1b[KEvaluated {}/{} ({:.2}% passed)", + evaluated_count, + iterations, + passed_ratio * 100.0 + ) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OutcomeKind { + Passed, + Failed, + Error, +} + +pub trait EvalOutputProcessor { + type Metadata: 'static + Send; + fn process(&mut self, output: &EvalOutput); + fn assert(&mut self); +} + +#[derive(Clone, Debug)] +pub struct EvalOutput { + pub outcome: OutcomeKind, + pub data: String, + pub metadata: M, +} + +pub struct NoProcessor; +impl EvalOutputProcessor for NoProcessor { + type Metadata = (); + + fn process(&mut self, _output: &EvalOutput) {} + + fn assert(&mut self) {} +} + +pub fn eval

( + iterations: usize, + expected_pass_ratio: f32, + mut processor: P, + evalf: impl Fn() -> EvalOutput + Send + Sync + 'static, +) where + P: EvalOutputProcessor, +{ + let mut evaluated_count = 0; + let mut failed_count = 0; + let evalf = Arc::new(evalf); + report_progress(evaluated_count, failed_count, iterations); + + let (tx, rx) = mpsc::channel(); + + let executor = gpui::background_executor(); + let semaphore = Arc::new(smol::lock::Semaphore::new(32)); + let evalf = Arc::new(evalf); + // Warm the cache once + let first_output = evalf(); + tx.send(first_output).ok(); + + for _ in 1..iterations { + let tx = tx.clone(); + let semaphore = semaphore.clone(); + let evalf = evalf.clone(); + executor + .spawn(async move { + let _guard = semaphore.acquire().await; + let output = evalf(); + tx.send(output).ok(); + }) + .detach(); + } + drop(tx); + + let mut failed_evals = Vec::new(); + let mut errored_evals = HashMap::new(); + while let Ok(output) = rx.recv() { + processor.process(&output); + + match output.outcome { + OutcomeKind::Passed => {} + OutcomeKind::Failed => { + failed_count += 1; + failed_evals.push(output); + } + OutcomeKind::Error => { + failed_count += 1; + *errored_evals.entry(output.data).or_insert(0) += 1; + } + } + + evaluated_count += 1; + report_progress(evaluated_count, failed_count, iterations); + } + + let actual_pass_ratio = (iterations - failed_count) as f32 / iterations as f32; + println!("Actual pass ratio: {}\n", actual_pass_ratio); + if actual_pass_ratio < expected_pass_ratio { + for (error, count) in errored_evals { + println!("Eval errored {} times. Error: {}", count, error); + } + + for failed in failed_evals { + println!("Eval failed"); + println!("{}", failed.data); + } + + panic!( + "Actual pass ratio: {}\nExpected pass ratio: {}", + actual_pass_ratio, expected_pass_ratio + ); + } + + processor.assert(); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index c042d85a1239dc6723b6501b27690a9f593a021b..2f4c7611dcf9d24302b3dda1d05c4c2b8711a68d 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -551,12 +551,39 @@ impl SystemWindowTabController { } } +pub(crate) enum GpuiMode { + #[cfg(any(test, feature = "test-support"))] + Test { + skip_drawing: bool, + }, + Production, +} + +impl GpuiMode { + #[cfg(any(test, feature = "test-support"))] + pub fn test() -> Self { + GpuiMode::Test { + skip_drawing: false, + } + } + + #[inline] + pub(crate) fn skip_drawing(&self) -> bool { + match self { + #[cfg(any(test, feature = "test-support"))] + GpuiMode::Test { skip_drawing } => *skip_drawing, + GpuiMode::Production => false, + } + } +} + /// Contains the state of the full application, and passed as a reference to a variety of callbacks. /// Other [Context] derefs to this type. /// You need a reference to an `App` to access the state of a [Entity]. pub struct App { pub(crate) this: Weak, pub(crate) platform: Rc, + pub(crate) mode: GpuiMode, text_system: Arc, flushing_effects: bool, pending_updates: usize, @@ -635,6 +662,7 @@ impl App { this: this.clone(), platform: platform.clone(), text_system, + mode: GpuiMode::Production, actions: Rc::new(ActionRegistry::default()), flushing_effects: false, pending_updates: 0, diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 4a7b73c359ed3dd55b136b22e9487dee1735e42e..5be2e394e8edfd26a25c70c79c321a7fb8fdc8ba 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -5,7 +5,7 @@ use crate::{ ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, - WindowHandle, WindowOptions, + WindowHandle, WindowOptions, app::GpuiMode, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -132,8 +132,11 @@ impl TestAppContext { let http_client = http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); + let mut app = App::new_app(platform.clone(), asset_source, http_client); + app.borrow_mut().mode = GpuiMode::test(); + Self { - app: App::new_app(platform.clone(), asset_source, http_client), + app, background_executor, foreground_executor, dispatcher, @@ -144,6 +147,11 @@ impl TestAppContext { } } + /// Skip all drawing operations for the duration of this test. + pub fn skip_drawing(&mut self) { + self.app.borrow_mut().mode = GpuiMode::Test { skip_drawing: true }; + } + /// Create a single TestAppContext, for non-multi-client tests pub fn single() -> Self { let dispatcher = TestDispatcher::new(StdRng::seed_from_u64(0)); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index dabf7cf2b42cf57becb996e1f9360aaba0b6eead..2d525adb8f82a96c24ee3f524030782a7de3577c 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -2006,7 +2006,9 @@ impl Window { if let Some(input_handler) = self.platform_window.take_input_handler() { self.rendered_frame.input_handlers.push(Some(input_handler)); } - self.draw_roots(cx); + if !cx.mode.skip_drawing() { + self.draw_roots(cx); + } self.dirty_views.clear(); self.next_frame.window_active = self.active.get(); diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 98c67f4e27a8e8b20489cc3c4ad4a1207e8b848f..f357e01da062398d18134df6625d30b8129bf875 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -408,6 +408,7 @@ impl FakeHttpClient { } pub fn with_404_response() -> Arc { + log::warn!("Using fake HTTP client with 404 response"); Self::create(|_| async move { Ok(Response::builder() .status(404) @@ -417,6 +418,7 @@ impl FakeHttpClient { } pub fn with_200_response() -> Arc { + log::warn!("Using fake HTTP client with 200 response"); Self::create(|_| async move { Ok(Response::builder() .status(200) diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index 6ed8bf07c4e976c88fecebd929843335333b1fa6..27b8309810962981d3c0ec78e6e67dfdfba122bf 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -135,6 +135,11 @@ impl LanguageModelRegistry { fake_provider } + #[cfg(any(test, feature = "test-support"))] + pub fn fake_model(&self) -> Arc { + self.default_model.as_ref().unwrap().model.clone() + } + pub fn register_provider( &mut self, provider: Arc, From 87976e91cf4b6e049563146085b0b0d9c4a017fb Mon Sep 17 00:00:00 2001 From: Andrew Farkas <6060305+HactarCE@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:56:39 -0500 Subject: [PATCH 50/67] Add more preview tab settings and fix janky behavior (#43921) Closes #41495 Known issues: - File path links always open as non-preview tabs. Fixing this is not technically too difficult but requires more invasive changes and so should be done in a future PR. Release Notes: - Fixed strange behavior when reopening closed preview tabs - Overhauled preview tabs settings: - Added setting `preview_tabs.enable_preview_from_project_panel` (default `true`) - Kept setting `preview_tabs.enable_preview_from_file_finder` (default `false`) - Added setting `preview_tabs.enable_preview_from_multibuffer` (default `true`) - Added setting `preview_tabs.enable_preview_multibuffer_from_code_navigation` (default `false`) - Added setting `preview_tabs.enable_preview_file_from_code_navigation` (default `true`) - Renamed setting `preview_tabs.enable_preview_from_code_navigation` to `preview_tabs.enable_keep_preview_on_code_navigation` (default `false`) --------- Co-authored-by: Smit Barmase Co-authored-by: Cole Miller --- assets/settings/default.json | 15 ++- crates/editor/src/editor.rs | 87 +++++++------ crates/migrator/src/migrations.rs | 6 + .../src/migrations/m_2025_12_01/settings.rs | 55 ++++++++ crates/migrator/src/migrator.rs | 119 +++++++++++++----- crates/project_panel/src/project_panel.rs | 5 +- crates/project_symbols/src/project_symbols.rs | 5 +- .../src/settings_content/workspace.rs | 21 +++- crates/settings/src/vscode_import.rs | 6 +- crates/settings_ui/src/page_data.rs | 102 +++++++++++++-- crates/workspace/src/item.rs | 20 ++- crates/workspace/src/pane.rs | 89 +++++++------ crates/workspace/src/workspace.rs | 31 +++-- docs/src/configuring-zed.md | 52 +++++++- 14 files changed, 478 insertions(+), 135 deletions(-) create mode 100644 crates/migrator/src/migrations/m_2025_12_01/settings.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index f53019744e72daa253e3ddfa96f48a0541186b61..f687778d7bd7fc0f6d66404199c34fac8d77e7a8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1100,13 +1100,22 @@ "preview_tabs": { // Whether preview tabs should be enabled. // Preview tabs allow you to open files in preview mode, where they close automatically - // when you switch to another file unless you explicitly pin them. + // when you open another preview tab. // This is useful for quickly viewing files without cluttering your workspace. "enabled": true, + // Whether to open tabs in preview mode when opened from the project panel with a single click. + "enable_preview_from_project_panel": true, // Whether to open tabs in preview mode when selected from the file finder. "enable_preview_from_file_finder": false, - // Whether a preview tab gets replaced when code navigation is used to navigate away from the tab. - "enable_preview_from_code_navigation": false + // Whether to open tabs in preview mode when opened from a multibuffer. + "enable_preview_from_multibuffer": true, + // Whether to open tabs in preview mode when code navigation is used to open a multibuffer. + "enable_preview_multibuffer_from_code_navigation": false, + // Whether to open tabs in preview mode when code navigation is used to open a single file. + "enable_preview_file_from_code_navigation": true, + // Whether to keep tabs in preview mode when code navigation is used to navigate away from them. + // If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. + "enable_keep_preview_on_code_navigation": false }, // Settings related to the file finder. "file_finder": { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f6489c8ffece51d581e3fb73d3f683ff1283c433..f2d6e168fc9ed47cd3c490f3449bc856f90e79fd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -17012,7 +17012,9 @@ impl Editor { }) .collect(); - let workspace = self.workspace(); + let Some(workspace) = self.workspace() else { + return Task::ready(Ok(Navigated::No)); + }; cx.spawn_in(window, async move |editor, cx| { let locations: Vec = future::join_all(definitions) @@ -17038,10 +17040,6 @@ impl Editor { } if num_locations > 1 { - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - let tab_kind = match kind { Some(GotoDefinitionKind::Implementation) => "Implementations", Some(GotoDefinitionKind::Symbol) | None => "Definitions", @@ -17073,11 +17071,14 @@ impl Editor { let opened = workspace .update_in(cx, |workspace, window, cx| { + let allow_preview = PreviewTabsSettings::get_global(cx) + .enable_preview_multibuffer_from_code_navigation; Self::open_locations_in_multibuffer( workspace, locations, title, split, + allow_preview, MultibufferSelectionMode::First, window, cx, @@ -17094,10 +17095,9 @@ impl Editor { Ok(Navigated::Yes) } Some(Either::Right(path)) => { - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - + // TODO(andrew): respect preview tab settings + // `enable_keep_preview_on_code_navigation` and + // `enable_preview_file_from_code_navigation` workspace .update_in(cx, |workspace, window, cx| { workspace.open_resolved_path(path, window, cx) @@ -17108,10 +17108,6 @@ impl Editor { None => Ok(Navigated::No), } } else { - let Some(workspace) = workspace else { - return Ok(Navigated::No); - }; - let (target_buffer, target_ranges) = locations.into_iter().next().unwrap(); let target_range = target_ranges.first().unwrap().clone(); @@ -17135,11 +17131,19 @@ impl Editor { workspace.active_pane().clone() }; + let preview_tabs_settings = PreviewTabsSettings::get_global(cx); + let keep_old_preview = preview_tabs_settings + .enable_keep_preview_on_code_navigation; + let allow_new_preview = preview_tabs_settings + .enable_preview_file_from_code_navigation; + workspace.open_project_item( pane, target_buffer.clone(), true, true, + keep_old_preview, + allow_new_preview, window, cx, ) @@ -17416,11 +17420,14 @@ impl Editor { } else { format!("References to {target}") }; + let allow_preview = PreviewTabsSettings::get_global(cx) + .enable_preview_multibuffer_from_code_navigation; Self::open_locations_in_multibuffer( workspace, locations, title, false, + allow_preview, MultibufferSelectionMode::First, window, cx, @@ -17436,6 +17443,7 @@ impl Editor { locations: std::collections::HashMap, Vec>>, title: String, split: bool, + allow_preview: bool, multibuffer_selection_mode: MultibufferSelectionMode, window: &mut Window, cx: &mut Context, @@ -17483,6 +17491,7 @@ impl Editor { .is_some_and(|it| *it == key) }) }); + let was_existing = existing.is_some(); let editor = existing.unwrap_or_else(|| { cx.new(|cx| { let mut editor = Editor::for_multibuffer( @@ -17523,29 +17532,23 @@ impl Editor { }); let item = Box::new(editor); - let item_id = item.item_id(); - - if split { - let pane = workspace.adjacent_pane(window, cx); - workspace.add_item(pane, item, None, true, true, window, cx); - } else if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation { - let (preview_item_id, preview_item_idx) = - workspace.active_pane().read_with(cx, |pane, _| { - (pane.preview_item_id(), pane.preview_item_idx()) - }); - workspace.add_item_to_active_pane(item, preview_item_idx, true, window, cx); + let pane = if split { + workspace.adjacent_pane(window, cx) + } else { + workspace.active_pane().clone() + }; + let activate_pane = split; - if let Some(preview_item_id) = preview_item_id { - workspace.active_pane().update(cx, |pane, cx| { - pane.remove_item(preview_item_id, false, false, window, cx); - }); + let mut destination_index = None; + pane.update(cx, |pane, cx| { + if allow_preview && !was_existing { + destination_index = pane.replace_preview_item_id(item.item_id(), window, cx); } - } else { - workspace.add_item_to_active_pane(item, None, true, window, cx); - } - workspace.active_pane().update(cx, |pane, cx| { - pane.set_preview_item_id(Some(item_id), cx); + if was_existing && !allow_preview { + pane.unpreview_item_if_preview(item.item_id()); + } + pane.add_item(item, activate_pane, true, destination_index, window, cx); }); } @@ -20783,6 +20786,7 @@ impl Editor { locations, format!("Selections for '{title}'"), false, + false, MultibufferSelectionMode::All, window, cx, @@ -22002,29 +22006,40 @@ impl Editor { // Handle file-less buffers separately: those are not really the project items, so won't have a project path or entity id, // so `workspace.open_project_item` will never find them, always opening a new editor. // Instead, we try to activate the existing editor in the pane first. - let (editor, pane_item_index) = + let (editor, pane_item_index, pane_item_id) = pane.read(cx).items().enumerate().find_map(|(i, item)| { let editor = item.downcast::()?; let singleton_buffer = editor.read(cx).buffer().read(cx).as_singleton()?; if singleton_buffer == buffer { - Some((editor, i)) + Some((editor, i, item.item_id())) } else { None } })?; pane.update(cx, |pane, cx| { - pane.activate_item(pane_item_index, true, true, window, cx) + pane.activate_item(pane_item_index, true, true, window, cx); + if !PreviewTabsSettings::get_global(cx) + .enable_preview_from_multibuffer + { + pane.unpreview_item_if_preview(pane_item_id); + } }); Some(editor) }) .flatten() .unwrap_or_else(|| { + let keep_old_preview = PreviewTabsSettings::get_global(cx) + .enable_keep_preview_on_code_navigation; + let allow_new_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_multibuffer; workspace.open_project_item::( pane.clone(), buffer, true, true, + keep_old_preview, + allow_new_preview, window, cx, ) diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index 07b7d3f0afb141d4dde77b883ca97f4df67cdd6c..398d5aaf9405d34e8d8a4e93d5c9b9045ee49118 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -153,3 +153,9 @@ pub(crate) mod m_2025_11_25 { pub(crate) use settings::remove_context_server_source; } + +pub(crate) mod m_2025_12_01 { + mod settings; + + pub(crate) use settings::SETTINGS_PATTERNS; +} diff --git a/crates/migrator/src/migrations/m_2025_12_01/settings.rs b/crates/migrator/src/migrations/m_2025_12_01/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c3816dab3446b483f197575e9f602986eee7e47 --- /dev/null +++ b/crates/migrator/src/migrations/m_2025_12_01/settings.rs @@ -0,0 +1,55 @@ +use std::ops::Range; +use tree_sitter::{Query, QueryMatch}; + +use crate::MigrationPatterns; +use crate::patterns::SETTINGS_NESTED_KEY_VALUE_PATTERN; + +pub const SETTINGS_PATTERNS: MigrationPatterns = &[( + SETTINGS_NESTED_KEY_VALUE_PATTERN, + rename_enable_preview_from_code_navigation_setting, +)]; + +fn rename_enable_preview_from_code_navigation_setting( + contents: &str, + mat: &QueryMatch, + query: &Query, +) -> Option<(Range, String)> { + if !is_enable_preview_from_code_navigation(contents, mat, query) { + return None; + } + + let setting_name_ix = query.capture_index_for_name("setting_name")?; + let setting_name_range = mat + .nodes_for_capture_index(setting_name_ix) + .next()? + .byte_range(); + + Some(( + setting_name_range, + "enable_keep_preview_on_code_navigation".to_string(), + )) +} + +fn is_enable_preview_from_code_navigation(contents: &str, mat: &QueryMatch, query: &Query) -> bool { + let parent_key_ix = match query.capture_index_for_name("parent_key") { + Some(ix) => ix, + None => return false, + }; + let parent_range = match mat.nodes_for_capture_index(parent_key_ix).next() { + Some(node) => node.byte_range(), + None => return false, + }; + if contents.get(parent_range) != Some("preview_tabs") { + return false; + } + + let setting_name_ix = match query.capture_index_for_name("setting_name") { + Some(ix) => ix, + None => return false, + }; + let setting_name_range = match mat.nodes_for_capture_index(setting_name_ix).next() { + Some(node) => node.byte_range(), + None => return false, + }; + contents.get(setting_name_range) == Some("enable_preview_from_code_navigation") +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 444ebadfb615628e91422ed62c351722d8cb9300..9fb6d8a1151719f350ea7877bfe2492d6b443c23 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -219,6 +219,10 @@ pub fn migrate_settings(text: &str) -> Result> { migrations::m_2025_11_12::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_11_12, ), + MigrationType::TreeSitter( + migrations::m_2025_12_01::SETTINGS_PATTERNS, + &SETTINGS_QUERY_2025_12_01, + ), MigrationType::TreeSitter( migrations::m_2025_11_20::SETTINGS_PATTERNS, &SETTINGS_QUERY_2025_11_20, @@ -346,6 +350,10 @@ define_query!( SETTINGS_QUERY_2025_11_12, migrations::m_2025_11_12::SETTINGS_PATTERNS ); +define_query!( + SETTINGS_QUERY_2025_12_01, + migrations::m_2025_12_01::SETTINGS_PATTERNS +); define_query!( SETTINGS_QUERY_2025_11_20, migrations::m_2025_11_20::SETTINGS_PATTERNS @@ -2262,6 +2270,54 @@ mod tests { ); } + #[test] + fn test_remove_context_server_source() { + assert_migrate_settings( + &r#" + { + "context_servers": { + "extension_server": { + "source": "extension", + "settings": { + "foo": "bar" + } + }, + "custom_server": { + "source": "custom", + "command": "foo", + "args": ["bar"], + "env": { + "FOO": "BAR" + } + }, + } + } + "# + .unindent(), + Some( + &r#" + { + "context_servers": { + "extension_server": { + "settings": { + "foo": "bar" + } + }, + "custom_server": { + "command": "foo", + "args": ["bar"], + "env": { + "FOO": "BAR" + } + }, + } + } + "# + .unindent(), + ), + ); + } + #[test] fn test_project_panel_open_file_on_paste_migration() { assert_migrate_settings( @@ -2308,25 +2364,14 @@ mod tests { } #[test] - fn test_remove_context_server_source() { + fn test_enable_preview_from_code_navigation_migration() { assert_migrate_settings( &r#" { - "context_servers": { - "extension_server": { - "source": "extension", - "settings": { - "foo": "bar" - } - }, - "custom_server": { - "source": "custom", - "command": "foo", - "args": ["bar"], - "env": { - "FOO": "BAR" - } - }, + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_preview_from_code_navigation": false } } "# @@ -2334,19 +2379,35 @@ mod tests { Some( &r#" { - "context_servers": { - "extension_server": { - "settings": { - "foo": "bar" - } - }, - "custom_server": { - "command": "foo", - "args": ["bar"], - "env": { - "FOO": "BAR" - } - }, + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_keep_preview_on_code_navigation": false + } + } + "# + .unindent(), + ), + ); + + assert_migrate_settings( + &r#" + { + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_preview_from_code_navigation": true + } + } + "# + .unindent(), + Some( + &r#" + { + "other_setting_1": 1, + "preview_tabs": { + "other_setting_2": 2, + "enable_keep_preview_on_code_navigation": true } } "# diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index d191b9f3fea5a7183bbcc89b751a71b00c1a31b7..e53be8cd33fa265dfadb201b2bcd613c54ffb9dd 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1529,7 +1529,8 @@ impl ProjectPanel { } fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context) { - let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled; + let preview_tabs_enabled = + PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel; self.open_internal(true, !preview_tabs_enabled, None, window, cx); } @@ -4819,7 +4820,7 @@ impl ProjectPanel { project_panel.toggle_expanded(entry_id, window, cx); } } else { - let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled; + let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enable_preview_from_project_panel; let click_count = event.click_count(); let focus_opened_item = click_count > 1; let allow_preview = preview_tabs_enabled && click_count == 1; diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 61ed715ffd639c532257319d2165d530ae5c0513..d96de4b876030deb5a6083b1474a167f8cba81ad 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -133,8 +133,9 @@ impl PickerDelegate for ProjectSymbolsDelegate { workspace.active_pane().clone() }; - let editor = - workspace.open_project_item::(pane, buffer, true, true, window, cx); + let editor = workspace.open_project_item::( + pane, buffer, true, true, true, true, window, cx, + ); editor.update(cx, |editor, cx| { editor.change_selections( diff --git a/crates/settings/src/settings_content/workspace.rs b/crates/settings/src/settings_content/workspace.rs index 088d478e464bd0f4e9a92419440c16576005fc95..b809a8fa85a9b27da3f3af5242e99b280466a4bb 100644 --- a/crates/settings/src/settings_content/workspace.rs +++ b/crates/settings/src/settings_content/workspace.rs @@ -152,14 +152,31 @@ pub struct PreviewTabsSettingsContent { /// /// Default: true pub enabled: Option, + /// Whether to open tabs in preview mode when opened from the project panel with a single click. + /// + /// Default: true + pub enable_preview_from_project_panel: Option, /// Whether to open tabs in preview mode when selected from the file finder. /// /// Default: false pub enable_preview_from_file_finder: Option, - /// Whether a preview tab gets replaced when code navigation is used to navigate away from the tab. + /// Whether to open tabs in preview mode when opened from a multibuffer. + /// + /// Default: true + pub enable_preview_from_multibuffer: Option, + /// Whether to open tabs in preview mode when code navigation is used to open a multibuffer. + /// + /// Default: false + pub enable_preview_multibuffer_from_code_navigation: Option, + /// Whether to open tabs in preview mode when code navigation is used to open a single file. + /// + /// Default: true + pub enable_preview_file_from_code_navigation: Option, + /// Whether to keep tabs in preview mode when code navigation is used to navigate away from them. + /// If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. /// /// Default: false - pub enable_preview_from_code_navigation: Option, + pub enable_keep_preview_on_code_navigation: Option, } #[derive( diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0a4e249d60c6888d9a950dcc5be4600d0047ce00..587850303f13649fcc4adf8cf4ddbb8dc7181dcb 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -619,9 +619,13 @@ impl VsCodeSettings { fn preview_tabs_settings_content(&self) -> Option { skip_default(PreviewTabsSettingsContent { enabled: self.read_bool("workbench.editor.enablePreview"), + enable_preview_from_project_panel: None, enable_preview_from_file_finder: self .read_bool("workbench.editor.enablePreviewFromQuickOpen"), - enable_preview_from_code_navigation: self + enable_preview_from_multibuffer: None, + enable_preview_multibuffer_from_code_navigation: None, + enable_preview_file_from_code_navigation: None, + enable_keep_preview_on_code_navigation: self .read_bool("workbench.editor.enablePreviewFromCodeNavigation"), }) } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 1525271a39776f4b8b456244f40e3dfbc43cbaac..0c383970c990c3ba19eab7aa5d3b7c699f8a195e 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3145,7 +3145,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { SettingsPageItem::SectionHeader("Preview Tabs"), SettingsPageItem::SettingItem(SettingItem { title: "Preview Tabs Enabled", - description: "Show opened editors as Preview tabs.", + description: "Show opened editors as preview tabs.", field: Box::new(SettingField { json_path: Some("preview_tabs.enabled"), pick: |settings_content| { @@ -3161,9 +3161,31 @@ pub(crate) fn settings_data(cx: &App) -> Vec { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Enable Preview From Project Panel", + description: "Whether to open tabs in preview mode when opened from the project panel with a single click.", + field: Box::new(SettingField { + json_path: Some("preview_tabs.enable_preview_from_project_panel"), + pick: |settings_content| { + settings_content + .preview_tabs + .as_ref()? + .enable_preview_from_project_panel + .as_ref() + }, + write: |settings_content, value| { + settings_content + .preview_tabs + .get_or_insert_default() + .enable_preview_from_project_panel = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Enable Preview From File Finder", - description: "Whether to open tabs in Preview mode when selected from the file finder.", + description: "Whether to open tabs in preview mode when selected from the file finder.", field: Box::new(SettingField { json_path: Some("preview_tabs.enable_preview_from_file_finder"), pick: |settings_content| { @@ -3184,22 +3206,88 @@ pub(crate) fn settings_data(cx: &App) -> Vec { files: USER, }), SettingsPageItem::SettingItem(SettingItem { - title: "Enable Preview From Code Navigation", - description: "Whether a preview tab gets replaced when code navigation is used to navigate away from the tab.", + title: "Enable Preview From Multibuffer", + description: "Whether to open tabs in preview mode when opened from a multibuffer.", + field: Box::new(SettingField { + json_path: Some("preview_tabs.enable_preview_from_multibuffer"), + pick: |settings_content| { + settings_content + .preview_tabs + .as_ref()? + .enable_preview_from_multibuffer + .as_ref() + }, + write: |settings_content, value| { + settings_content + .preview_tabs + .get_or_insert_default() + .enable_preview_from_multibuffer = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Enable Preview Multibuffer From Code Navigation", + description: "Whether to open tabs in preview mode when code navigation is used to open a multibuffer.", + field: Box::new(SettingField { + json_path: Some("preview_tabs.enable_preview_multibuffer_from_code_navigation"), + pick: |settings_content| { + settings_content + .preview_tabs + .as_ref()? + .enable_preview_multibuffer_from_code_navigation + .as_ref() + }, + write: |settings_content, value| { + settings_content + .preview_tabs + .get_or_insert_default() + .enable_preview_multibuffer_from_code_navigation = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Enable Preview File From Code Navigation", + description: "Whether to open tabs in preview mode when code navigation is used to open a single file.", + field: Box::new(SettingField { + json_path: Some("preview_tabs.enable_preview_file_from_code_navigation"), + pick: |settings_content| { + settings_content + .preview_tabs + .as_ref()? + .enable_preview_file_from_code_navigation + .as_ref() + }, + write: |settings_content, value| { + settings_content + .preview_tabs + .get_or_insert_default() + .enable_preview_file_from_code_navigation = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Enable Keep Preview On Code Navigation", + description: "Whether to keep tabs in preview mode when code navigation is used to navigate away from them. If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one.", field: Box::new(SettingField { - json_path: Some("preview_tabs.enable_preview_from_code_navigation"), + json_path: Some("preview_tabs.enable_keep_preview_on_code_navigation"), pick: |settings_content| { settings_content .preview_tabs .as_ref()? - .enable_preview_from_code_navigation + .enable_keep_preview_on_code_navigation .as_ref() }, write: |settings_content, value| { settings_content .preview_tabs .get_or_insert_default() - .enable_preview_from_code_navigation = value; + .enable_keep_preview_on_code_navigation = value; }, }), metadata: None, diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 8f459557270e7b4595e26e15f2aad3c33aea4cd8..42eb754c21347e7dced792f3e56cb9901bc70bd1 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -64,8 +64,12 @@ pub struct ItemSettings { #[derive(RegisterSetting)] pub struct PreviewTabsSettings { pub enabled: bool, + pub enable_preview_from_project_panel: bool, pub enable_preview_from_file_finder: bool, - pub enable_preview_from_code_navigation: bool, + pub enable_preview_from_multibuffer: bool, + pub enable_preview_multibuffer_from_code_navigation: bool, + pub enable_preview_file_from_code_navigation: bool, + pub enable_keep_preview_on_code_navigation: bool, } impl Settings for ItemSettings { @@ -87,9 +91,19 @@ impl Settings for PreviewTabsSettings { let preview_tabs = content.preview_tabs.as_ref().unwrap(); Self { enabled: preview_tabs.enabled.unwrap(), + enable_preview_from_project_panel: preview_tabs + .enable_preview_from_project_panel + .unwrap(), enable_preview_from_file_finder: preview_tabs.enable_preview_from_file_finder.unwrap(), - enable_preview_from_code_navigation: preview_tabs - .enable_preview_from_code_navigation + enable_preview_from_multibuffer: preview_tabs.enable_preview_from_multibuffer.unwrap(), + enable_preview_multibuffer_from_code_navigation: preview_tabs + .enable_preview_multibuffer_from_code_navigation + .unwrap(), + enable_preview_file_from_code_navigation: preview_tabs + .enable_preview_file_from_code_navigation + .unwrap(), + enable_keep_preview_on_code_navigation: preview_tabs + .enable_keep_preview_on_code_navigation .unwrap(), } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 5f0fb8ba9647f969b3bea4a83194dd600e1f84aa..e99f8d1dc959def06deebae7c4acc454c9210933 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -873,10 +873,35 @@ impl Pane { self.preview_item_id == Some(item_id) } + /// Promotes the item with the given ID to not be a preview item. + /// This does nothing if it wasn't already a preview item. + pub fn unpreview_item_if_preview(&mut self, item_id: EntityId) { + if self.is_active_preview_item(item_id) { + self.preview_item_id = None; + } + } + + /// Marks the item with the given ID as the preview item. + /// This will be ignored if the global setting `preview_tabs` is disabled. + /// + /// The old preview item (if there was one) is closed and its index is returned. + pub fn replace_preview_item_id( + &mut self, + item_id: EntityId, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let idx = self.close_current_preview_item(window, cx); + self.set_preview_item_id(Some(item_id), cx); + idx + } + /// Marks the item with the given ID as the preview item. /// This will be ignored if the global setting `preview_tabs` is disabled. - pub fn set_preview_item_id(&mut self, item_id: Option, cx: &App) { - if PreviewTabsSettings::get_global(cx).enabled { + /// + /// This is a low-level method. Prefer `unpreview_item_if_preview()` or `set_new_preview_item()`. + pub(crate) fn set_preview_item_id(&mut self, item_id: Option, cx: &App) { + if item_id.is_none() || PreviewTabsSettings::get_global(cx).enabled { self.preview_item_id = item_id; } } @@ -895,7 +920,7 @@ impl Pane { && preview_item.item_id() == item_id && !preview_item.preserve_preview(cx) { - self.set_preview_item_id(None, cx); + self.unpreview_item_if_preview(item_id); } } @@ -936,14 +961,8 @@ impl Pane { let set_up_existing_item = |index: usize, pane: &mut Self, window: &mut Window, cx: &mut Context| { - // If the item is already open, and the item is a preview item - // and we are not allowing items to open as preview, mark the item as persistent. - if let Some(preview_item_id) = pane.preview_item_id - && let Some(tab) = pane.items.get(index) - && tab.item_id() == preview_item_id - && !allow_preview - { - pane.set_preview_item_id(None, cx); + if !allow_preview && let Some(item) = pane.items.get(index) { + pane.unpreview_item_if_preview(item.item_id()); } if activate { pane.activate_item(index, focus_item, focus_item, window, cx); @@ -955,7 +974,7 @@ impl Pane { window: &mut Window, cx: &mut Context| { if allow_preview { - pane.set_preview_item_id(Some(new_item.item_id()), cx); + pane.replace_preview_item_id(new_item.item_id(), window, cx); } if let Some(text) = new_item.telemetry_event_text(cx) { @@ -1036,6 +1055,7 @@ impl Pane { ) -> Option { let item_idx = self.preview_item_idx()?; let id = self.preview_item_id()?; + self.set_preview_item_id(None, cx); let prev_active_item_index = self.active_item_index; self.remove_item(id, false, false, window, cx); @@ -1981,9 +2001,7 @@ impl Pane { item.on_removed(cx); self.nav_history.set_mode(mode); - if self.is_active_preview_item(item.item_id()) { - self.set_preview_item_id(None, cx); - } + self.unpreview_item_if_preview(item.item_id()); if let Some(path) = item.project_path(cx) { let abs_path = self @@ -2194,9 +2212,7 @@ impl Pane { if can_save { pane.update_in(cx, |pane, window, cx| { - if pane.is_active_preview_item(item.item_id()) { - pane.set_preview_item_id(None, cx); - } + pane.unpreview_item_if_preview(item.item_id()); item.save( SaveOptions { format: should_format, @@ -2450,8 +2466,8 @@ impl Pane { let id = self.item_for_index(ix)?.item_id(); let should_activate = ix == self.active_item_index; - if matches!(operation, PinOperation::Pin) && self.is_active_preview_item(id) { - self.set_preview_item_id(None, cx); + if matches!(operation, PinOperation::Pin) { + self.unpreview_item_if_preview(id); } match operation { @@ -2624,12 +2640,9 @@ impl Pane { ) .on_mouse_down( MouseButton::Left, - cx.listener(move |pane, event: &MouseDownEvent, _, cx| { - if let Some(id) = pane.preview_item_id - && id == item_id - && event.click_count > 1 - { - pane.set_preview_item_id(None, cx); + cx.listener(move |pane, event: &MouseDownEvent, _, _| { + if event.click_count > 1 { + pane.unpreview_item_if_preview(item_id); } }), ) @@ -3272,11 +3285,7 @@ impl Pane { let mut to_pane = cx.entity(); let split_direction = self.drag_split_direction; let item_id = dragged_tab.item.item_id(); - if let Some(preview_item_id) = self.preview_item_id - && item_id == preview_item_id - { - self.set_preview_item_id(None, cx); - } + self.unpreview_item_if_preview(item_id); let is_clone = cfg!(target_os = "macos") && window.modifiers().alt || cfg!(not(target_os = "macos")) && window.modifiers().control; @@ -3788,15 +3797,17 @@ impl Render for Pane { .on_action(cx.listener(Self::toggle_pin_tab)) .on_action(cx.listener(Self::unpin_all_tabs)) .when(PreviewTabsSettings::get_global(cx).enabled, |this| { - this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, _, cx| { - if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { - if pane.is_active_preview_item(active_item_id) { - pane.set_preview_item_id(None, cx); - } else { - pane.set_preview_item_id(Some(active_item_id), cx); + this.on_action( + cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, window, cx| { + if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { + if pane.is_active_preview_item(active_item_id) { + pane.unpreview_item_if_preview(active_item_id); + } else { + pane.replace_preview_item_id(active_item_id, window, cx); + } } - } - })) + }), + ) }) .on_action( cx.listener(|pane: &mut Self, action: &CloseActiveItem, window, cx| { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d5a1c3a291c8e337695b30c1e6e1f3b3b76a3a62..b1ad520493b4869d646a76df4a0e576646253117 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3636,14 +3636,33 @@ impl Workspace { project_item: Entity, activate_pane: bool, focus_item: bool, + keep_old_preview: bool, + allow_new_preview: bool, window: &mut Window, cx: &mut Context, ) -> Entity where T: ProjectItem, { + let old_item_id = pane.read(cx).active_item().map(|item| item.item_id()); + if let Some(item) = self.find_project_item(&pane, &project_item, cx) { + if !keep_old_preview + && let Some(old_id) = old_item_id + && old_id != item.item_id() + { + // switching to a different item, so unpreview old active item + pane.update(cx, |pane, _| { + pane.unpreview_item_if_preview(old_id); + }); + } + self.activate_item(&item, activate_pane, focus_item, window, cx); + if !allow_new_preview { + pane.update(cx, |pane, _| { + pane.unpreview_item_if_preview(item.item_id()); + }); + } return item; } @@ -3652,16 +3671,14 @@ impl Workspace { T::for_project_item(self.project().clone(), Some(pane), project_item, window, cx) }) }); - let item_id = item.item_id(); let mut destination_index = None; pane.update(cx, |pane, cx| { - if PreviewTabsSettings::get_global(cx).enable_preview_from_code_navigation - && let Some(preview_item_id) = pane.preview_item_id() - && preview_item_id != item_id - { - destination_index = pane.close_current_preview_item(window, cx); + if !keep_old_preview && let Some(old_id) = old_item_id { + pane.unpreview_item_if_preview(old_id); + } + if allow_new_preview { + destination_index = pane.replace_preview_item_id(item.item_id(), window, cx); } - pane.set_preview_item_id(Some(item.item_id()), cx) }); self.add_item( diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 3b90120407fe56643e4b3f279d88443b9740e154..477885a4537580aaf562aa596c1a06cae1c65bc8 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -2861,11 +2861,25 @@ Configuration object for defining settings profiles. Example: ```json [settings] "preview_tabs": { "enabled": true, + "enable_preview_from_project_panel": true, "enable_preview_from_file_finder": false, - "enable_preview_from_code_navigation": false, + "enable_preview_from_multibuffer": true, + "enable_preview_multibuffer_from_code_navigation": false, + "enable_preview_file_from_code_navigation": true, + "enable_keep_preview_on_code_navigation": false, } ``` +### Enable preview from project panel + +- Description: Determines whether to open files in preview mode when opened from the project panel with a single click. +- Setting: `enable_preview_from_project_panel` +- Default: `true` + +**Options** + +`boolean` values + ### Enable preview from file finder - Description: Determines whether to open files in preview mode when selected from the file finder. @@ -2876,10 +2890,40 @@ Configuration object for defining settings profiles. Example: `boolean` values -### Enable preview from code navigation +### Enable preview from multibuffer + +- Description: Determines whether to open files in preview mode when opened from a multibuffer. +- Setting: `enable_preview_from_multibuffer` +- Default: `true` + +**Options** + +`boolean` values + +### Enable preview multibuffer from code navigation + +- Description: Determines whether to open tabs in preview mode when code navigation is used to open a multibuffer. +- Setting: `enable_preview_multibuffer_from_code_navigation` +- Default: `false` + +**Options** + +`boolean` values + +### Enable preview file from code navigation + +- Description: Determines whether to open tabs in preview mode when code navigation is used to open a single file. +- Setting: `enable_preview_file_from_code_navigation` +- Default: `true` + +**Options** + +`boolean` values + +### Enable keep preview on code navigation -- Description: Determines whether a preview tab gets replaced when code navigation is used to navigate away from the tab. -- Setting: `enable_preview_from_code_navigation` +- Description: Determines whether to keep tabs in preview mode when code navigation is used to navigate away from them. If `enable_preview_file_from_code_navigation` or `enable_preview_multibuffer_from_code_navigation` is also true, the new tab may replace the existing one. +- Setting: `enable_keep_preview_on_code_navigation` - Default: `false` **Options** From 8ad3a150c8372d1e7b048f7eb3f0500e94a58ae8 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Wed, 3 Dec 2025 14:25:04 -0800 Subject: [PATCH 51/67] editor: Add active match highlight for buffer and project search (#44098) Closes #28617 image Release Notes: - Improved visibility of the currently active match when browsing results in buffer or project search. --------- Co-authored-by: DarkMatter-999 --- assets/themes/ayu/ayu.json | 3 + assets/themes/gruvbox/gruvbox.json | 6 + assets/themes/one/one.json | 2 + crates/agent_ui/src/text_thread_editor.rs | 6 +- crates/debugger_tools/src/dap_log.rs | 6 +- .../src/session/running/console.rs | 3 +- crates/editor/src/editor.rs | 29 +- crates/editor/src/editor_tests.rs | 10 +- crates/editor/src/hover_popover.rs | 2 +- crates/editor/src/items.rs | 9 +- crates/language_tools/src/lsp_log_view.rs | 6 +- crates/language_tools/src/syntax_tree_view.rs | 2 +- crates/search/src/buffer_search.rs | 25 +- crates/search/src/project_search.rs | 326 ++++++++++++------ crates/settings/src/settings_content/theme.rs | 3 + crates/terminal_view/src/terminal_view.rs | 1 + crates/theme/src/default_colors.rs | 2 + crates/theme/src/fallback_themes.rs | 1 + crates/theme/src/schema.rs | 15 +- crates/theme/src/styles/colors.rs | 3 + crates/vim/src/normal/yank.rs | 2 +- crates/vim/src/replace.rs | 2 +- crates/workspace/src/searchable.rs | 19 +- 23 files changed, 338 insertions(+), 145 deletions(-) diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 7c84c603bda7fd7590067ec9f566f3582ba6aefd..e2b7c3c91fca46ab0e4064719bea5c8793faaccc 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#1f2127ff", "tab.active_background": "#0d1016ff", "search.match_background": "#5ac2fe66", + "search.active_match_background": "#ea570166", "panel.background": "#1f2127ff", "panel.focused_border": "#5ac1feff", "pane.focused_border": null, @@ -436,6 +437,7 @@ "tab.inactive_background": "#ececedff", "tab.active_background": "#fcfcfcff", "search.match_background": "#3b9ee566", + "search.active_match_background": "#f88b3666", "panel.background": "#ececedff", "panel.focused_border": "#3b9ee5ff", "pane.focused_border": null, @@ -827,6 +829,7 @@ "tab.inactive_background": "#353944ff", "tab.active_background": "#242835ff", "search.match_background": "#73cffe66", + "search.active_match_background": "#fd722b66", "panel.background": "#353944ff", "panel.focused_border": null, "pane.focused_border": null, diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index a0f0a3ad637a4d212c8bf38f95f2e8424919d6bf..90973fd6c3469a1ef0e698d629376dfaaf3b5a76 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -46,6 +46,7 @@ "tab.inactive_background": "#3a3735ff", "tab.active_background": "#282828ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c09f3f66", "panel.background": "#3a3735ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, @@ -452,6 +453,7 @@ "tab.inactive_background": "#393634ff", "tab.active_background": "#1d2021ff", "search.match_background": "#83a59866", + "search.active_match_background": "#c9653666", "panel.background": "#393634ff", "panel.focused_border": "#83a598ff", "pane.focused_border": null, @@ -858,6 +860,7 @@ "tab.inactive_background": "#3b3735ff", "tab.active_background": "#32302fff", "search.match_background": "#83a59866", + "search.active_match_background": "#aea85166", "panel.background": "#3b3735ff", "panel.focused_border": null, "pane.focused_border": null, @@ -1264,6 +1267,7 @@ "tab.inactive_background": "#ecddb4ff", "tab.active_background": "#fbf1c7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#ba2d1166", "panel.background": "#ecddb4ff", "panel.focused_border": null, "pane.focused_border": null, @@ -1670,6 +1674,7 @@ "tab.inactive_background": "#ecddb5ff", "tab.active_background": "#f9f5d7ff", "search.match_background": "#0b667866", + "search.active_match_background": "#dc351466", "panel.background": "#ecddb5ff", "panel.focused_border": null, "pane.focused_border": null, @@ -2076,6 +2081,7 @@ "tab.inactive_background": "#ecdcb3ff", "tab.active_background": "#f2e5bcff", "search.match_background": "#0b667866", + "search.active_match_background": "#d7331466", "panel.background": "#ecdcb3ff", "panel.focused_border": null, "pane.focused_border": null, diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index d9d7a37e996053d6f7c6cb28ec7f0d3f92e3b394..c72c92471761c473bea05edc37b1f96f18b2f683 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -45,6 +45,7 @@ "tab.inactive_background": "#2f343eff", "tab.active_background": "#282c33ff", "search.match_background": "#74ade866", + "search.active_match_background": "#e8af7466", "panel.background": "#2f343eff", "panel.focused_border": null, "pane.focused_border": null, @@ -448,6 +449,7 @@ "tab.inactive_background": "#ebebecff", "tab.active_background": "#fafafaff", "search.match_background": "#5c79e266", + "search.active_match_background": "#d0a92366", "panel.background": "#ebebecff", "panel.focused_border": null, "pane.focused_border": null, diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 6d5e226b6a5f1ae441314d45f2546a57c84ca664..161fad95e68c015f720df825b1f0ca32f5d79124 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -2622,11 +2622,13 @@ impl SearchableItem for TextThreadEditor { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |editor, cx| editor.update_matches(matches, window, cx)); + self.editor.update(cx, |editor, cx| { + editor.update_matches(matches, active_match_index, window, cx) + }); } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs index 8841a3744a4452355e2b02c9dca969cab493796e..317ce8b4c65e441f1fc4041706989532aa150204 100644 --- a/crates/debugger_tools/src/dap_log.rs +++ b/crates/debugger_tools/src/dap_log.rs @@ -1017,11 +1017,13 @@ impl SearchableItem for DapLogView { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |e, cx| e.update_matches(matches, window, cx)) + self.editor.update(cx, |e, cx| { + e.update_matches(matches, active_match_index, window, cx) + }) } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs index d20108b61205bacd3ea09af0ea34fabbec621c20..927a57dc8bdf956eb7f7ff63d3ea058500abf6c3 100644 --- a/crates/debugger_ui/src/session/running/console.rs +++ b/crates/debugger_ui/src/session/running/console.rs @@ -252,10 +252,11 @@ impl Console { let start_offset = range.start; let range = buffer.anchor_after(MultiBufferOffset(range.start)) ..buffer.anchor_before(MultiBufferOffset(range.end)); + let color_fn = color_fetcher(color); console.highlight_background_key::( start_offset, &[range], - color_fetcher(color), + move |_, theme| color_fn(theme), cx, ); } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f2d6e168fc9ed47cd3c490f3449bc856f90e79fd..f90400b2b1e07b14959d5b532c5926f7c3224dbe 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -726,7 +726,10 @@ impl EditorActionId { // type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor; // type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option; -type BackgroundHighlight = (fn(&Theme) -> Hsla, Arc<[Range]>); +type BackgroundHighlight = ( + Arc Hsla + Send + Sync>, + Arc<[Range]>, +); type GutterHighlight = (fn(&App) -> Hsla, Vec>); #[derive(Default)] @@ -6610,7 +6613,7 @@ impl Editor { editor.update(cx, |editor, cx| { editor.highlight_background::( &ranges_to_highlight, - |theme| theme.colors().editor_highlighted_line_background, + |_, theme| theme.colors().editor_highlighted_line_background, cx, ); }); @@ -7012,12 +7015,12 @@ impl Editor { this.highlight_background::( &read_ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); this.highlight_background::( &write_ranges, - |theme| theme.colors().editor_document_highlight_write_background, + |_, theme| theme.colors().editor_document_highlight_write_background, cx, ); cx.notify(); @@ -7125,7 +7128,7 @@ impl Editor { if !match_ranges.is_empty() { editor.highlight_background::( &match_ranges, - |theme| theme.colors().editor_document_highlight_bracket_background, + |_, theme| theme.colors().editor_document_highlight_bracket_background, cx, ) } @@ -17519,7 +17522,7 @@ impl Editor { } editor.highlight_background::( &ranges, - |theme| theme.colors().editor_highlighted_line_background, + |_, theme| theme.colors().editor_highlighted_line_background, cx, ); } @@ -20989,7 +20992,7 @@ impl Editor { pub fn set_search_within_ranges(&mut self, ranges: &[Range], cx: &mut Context) { self.highlight_background::( ranges, - |colors| colors.colors().editor_document_highlight_read_background, + |_, colors| colors.colors().editor_document_highlight_read_background, cx, ) } @@ -21005,12 +21008,12 @@ impl Editor { pub fn highlight_background( &mut self, ranges: &[Range], - color_fetcher: fn(&Theme) -> Hsla, + color_fetcher: impl Fn(&usize, &Theme) -> Hsla + Send + Sync + 'static, cx: &mut Context, ) { self.background_highlights.insert( HighlightKey::Type(TypeId::of::()), - (color_fetcher, Arc::from(ranges)), + (Arc::new(color_fetcher), Arc::from(ranges)), ); self.scrollbar_marker_state.dirty = true; cx.notify(); @@ -21020,12 +21023,12 @@ impl Editor { &mut self, key: usize, ranges: &[Range], - color_fetcher: fn(&Theme) -> Hsla, + color_fetcher: impl Fn(&usize, &Theme) -> Hsla + Send + Sync + 'static, cx: &mut Context, ) { self.background_highlights.insert( HighlightKey::TypePlus(TypeId::of::(), key), - (color_fetcher, Arc::from(ranges)), + (Arc::new(color_fetcher), Arc::from(ranges)), ); self.scrollbar_marker_state.dirty = true; cx.notify(); @@ -21250,7 +21253,6 @@ impl Editor { ) -> Vec<(Range, Hsla)> { let mut results = Vec::new(); for (color_fetcher, ranges) in self.background_highlights.values() { - let color = color_fetcher(theme); let start_ix = match ranges.binary_search_by(|probe| { let cmp = probe .end @@ -21263,7 +21265,7 @@ impl Editor { }) { Ok(i) | Err(i) => i, }; - for range in &ranges[start_ix..] { + for (index, range) in ranges[start_ix..].iter().enumerate() { if range .start .cmp(&search_range.end, &display_snapshot.buffer_snapshot()) @@ -21272,6 +21274,7 @@ impl Editor { break; } + let color = color_fetcher(&(start_ix + index), theme); let start = range.start.to_display_point(display_snapshot); let end = range.end.to_display_point(display_snapshot); results.push((start..end, color)) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index d95f0f78bf8acea8703bb7780ca842f037850d64..011715804665563b9588da28bad3137120f9c4c3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -16978,7 +16978,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { anchor_range(Point::new(6, 3)..Point::new(6, 5)), anchor_range(Point::new(8, 4)..Point::new(8, 6)), ], - |_| Hsla::red(), + |_, _| Hsla::red(), cx, ); editor.highlight_background::( @@ -16988,7 +16988,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) { anchor_range(Point::new(7, 4)..Point::new(7, 7)), anchor_range(Point::new(9, 5)..Point::new(9, 8)), ], - |_| Hsla::green(), + |_, _| Hsla::green(), cx, ); @@ -23973,7 +23973,7 @@ async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background::( &[highlight_range], - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -24051,7 +24051,7 @@ async fn test_rename_without_prepare(cx: &mut TestAppContext) { let highlight_range = highlight_range.to_anchors(&editor.buffer().read(cx).snapshot(cx)); editor.highlight_background::( &[highlight_range], - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); @@ -27299,7 +27299,7 @@ let result = variable * 2;", editor.highlight_background::( &anchor_ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); }); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 0b9a25d3ee0fcb1cb67497bf51fe41ed73a3692e..caabe6e6f5ab6ae80b3ead9d72fdcbec59937ff6 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -518,7 +518,7 @@ fn show_hover( // Highlight the selected symbol using a background highlight editor.highlight_background::( &hover_highlights, - |theme| theme.colors().element_hover, // todo update theme + |_, theme| theme.colors().element_hover, // todo update theme cx, ); } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 4e1305866ee9e4219295c02bdc519b4bc857cddf..ca8937bebe3d3578c7fe2fdec2c6252bdd395e6d 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1487,6 +1487,7 @@ impl SearchableItem for Editor { fn update_matches( &mut self, matches: &[Range], + active_match_index: Option, _: &mut Window, cx: &mut Context, ) { @@ -1497,7 +1498,13 @@ impl SearchableItem for Editor { let updated = existing_range != Some(matches); self.highlight_background::( matches, - |theme| theme.colors().search_match_background, + move |index, theme| { + if active_match_index == Some(*index) { + theme.colors().search_active_match_background + } else { + theme.colors().search_match_background + } + }, cx, ); if updated { diff --git a/crates/language_tools/src/lsp_log_view.rs b/crates/language_tools/src/lsp_log_view.rs index 4295985b5f846cbf1ff87a1012042ee6b6608945..314dcc0b9bde998a0fec65b2847ae13641f0d011 100644 --- a/crates/language_tools/src/lsp_log_view.rs +++ b/crates/language_tools/src/lsp_log_view.rs @@ -805,11 +805,13 @@ impl SearchableItem for LspLogView { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ) { - self.editor - .update(cx, |e, cx| e.update_matches(matches, window, cx)) + self.editor.update(cx, |e, cx| { + e.update_matches(matches, active_match_index, window, cx) + }) } fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index c06ecd21e7f2eb86b4114ec2671f38297fd5fa25..0fbcdcca5eca80a01738888266389db5a678f3e8 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -459,7 +459,7 @@ impl SyntaxTreeView { editor.clear_background_highlights::(cx); editor.highlight_background::( &[range], - |theme| { + |_, theme| { theme .colors() .editor_document_highlight_write_background diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index d17efa635074f7898ab3ea829f3418e2ddd09934..a9c26ac9bad0f524acdb47d6f09c2bd67cb8dfc6 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1031,7 +1031,7 @@ impl BufferSearchBar { let new_match_index = searchable_item .match_index_for_direction(matches, index, direction, count, window, cx); - searchable_item.update_matches(matches, window, cx); + searchable_item.update_matches(matches, Some(new_match_index), window, cx); searchable_item.activate_match(new_match_index, matches, window, cx); } } @@ -1045,7 +1045,7 @@ impl BufferSearchBar { if matches.is_empty() { return; } - searchable_item.update_matches(matches, window, cx); + searchable_item.update_matches(matches, Some(0), window, cx); searchable_item.activate_match(0, matches, window, cx); } } @@ -1060,7 +1060,7 @@ impl BufferSearchBar { return; } let new_match_index = matches.len() - 1; - searchable_item.update_matches(matches, window, cx); + searchable_item.update_matches(matches, Some(new_match_index), window, cx); searchable_item.activate_match(new_match_index, matches, window, cx); } } @@ -1300,7 +1300,12 @@ impl BufferSearchBar { if matches.is_empty() { active_searchable_item.clear_matches(window, cx); } else { - active_searchable_item.update_matches(matches, window, cx); + active_searchable_item.update_matches( + matches, + this.active_match_index, + window, + cx, + ); } let _ = done_tx.send(()); } @@ -1335,6 +1340,18 @@ impl BufferSearchBar { }); if new_index != self.active_match_index { self.active_match_index = new_index; + if !self.dismissed { + if let Some(searchable_item) = self.active_searchable_item.as_ref() { + if let Some(matches) = self + .searchable_items_with_matches + .get(&searchable_item.downgrade()) + { + if !matches.is_empty() { + searchable_item.update_matches(matches, new_index, window, cx); + } + } + } + } cx.notify(); } } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 2bd994754aa50ed01d4808455e40b5248bb11e19..41de3246532d6fcfe781f9c5c1d2c250f0cae93e 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1444,6 +1444,7 @@ impl ProjectSearchView { s.select_ranges([range_to_select]) }); }); + self.highlight_matches(&match_ranges, Some(new_index), cx); } } @@ -1518,11 +1519,6 @@ impl ProjectSearchView { }); editor.scroll(Point::default(), Some(Axis::Vertical), window, cx); } - editor.highlight_background::( - &match_ranges, - |theme| theme.colors().search_match_background, - cx, - ); }); if is_new_search && self.query_editor.focus_handle(cx).is_focused(window) { self.focus_results_editor(window, cx); @@ -1535,18 +1531,41 @@ impl ProjectSearchView { fn update_match_index(&mut self, cx: &mut Context) { let results_editor = self.results_editor.read(cx); + let match_ranges = self.entity.read(cx).match_ranges.clone(); let new_index = active_match_index( Direction::Next, - &self.entity.read(cx).match_ranges, + &match_ranges, &results_editor.selections.newest_anchor().head(), &results_editor.buffer().read(cx).snapshot(cx), ); + self.highlight_matches(&match_ranges, new_index, cx); if self.active_match_index != new_index { self.active_match_index = new_index; cx.notify(); } } + fn highlight_matches( + &self, + match_ranges: &[Range], + active_index: Option, + cx: &mut Context, + ) { + self.results_editor.update(cx, |editor, cx| { + editor.highlight_background::( + match_ranges, + move |index, theme| { + if active_index == Some(*index) { + theme.colors().search_active_match_background + } else { + theme.colors().search_match_background + } + }, + cx, + ); + }); + } + pub fn has_matches(&self) -> bool { self.active_match_index.is_some() } @@ -2456,7 +2475,9 @@ pub mod tests { use pretty_assertions::assert_eq; use project::FakeFs; use serde_json::json; - use settings::{InlayHintSettingsContent, SettingsStore}; + use settings::{ + InlayHintSettingsContent, SettingsStore, ThemeColorsContent, ThemeStyleContent, + }; use util::{path, paths::PathStyle, rel_path::rel_path}; use util_macros::perf; use workspace::DeploySearch; @@ -2464,8 +2485,105 @@ pub mod tests { #[perf] #[gpui::test] async fn test_project_search(cx: &mut TestAppContext) { + fn dp(row: u32, col: u32) -> DisplayPoint { + DisplayPoint::new(DisplayRow(row), col) + } + + fn assert_active_match_index( + search_view: &WindowHandle, + cx: &mut TestAppContext, + expected_index: usize, + ) { + search_view + .update(cx, |search_view, _window, _cx| { + assert_eq!(search_view.active_match_index, Some(expected_index)); + }) + .unwrap(); + } + + fn assert_selection_range( + search_view: &WindowHandle, + cx: &mut TestAppContext, + expected_range: Range, + ) { + search_view + .update(cx, |search_view, _window, cx| { + assert_eq!( + search_view.results_editor.update(cx, |editor, cx| editor + .selections + .display_ranges(&editor.display_snapshot(cx))), + [expected_range] + ); + }) + .unwrap(); + } + + fn assert_highlights( + search_view: &WindowHandle, + cx: &mut TestAppContext, + expected_highlights: Vec<(Range, &str)>, + ) { + search_view + .update(cx, |search_view, window, cx| { + let match_bg = cx.theme().colors().search_match_background; + let active_match_bg = cx.theme().colors().search_active_match_background; + let selection_bg = cx + .theme() + .colors() + .editor_document_highlight_bracket_background; + + let highlights: Vec<_> = expected_highlights + .into_iter() + .map(|(range, color_type)| { + let color = match color_type { + "active" => active_match_bg, + "match" => match_bg, + "selection" => selection_bg, + _ => panic!("Unknown color type"), + }; + (range, color) + }) + .collect(); + + assert_eq!( + search_view.results_editor.update(cx, |editor, cx| editor + .all_text_background_highlights(window, cx)), + highlights.as_slice() + ); + }) + .unwrap(); + } + + fn select_match( + search_view: &WindowHandle, + cx: &mut TestAppContext, + direction: Direction, + ) { + search_view + .update(cx, |search_view, window, cx| { + search_view.select_match(direction, window, cx); + }) + .unwrap(); + } + init_test(cx); + // Override active search match color since the fallback theme uses the same color + // for normal search match and active one, which can make this test less robust. + cx.update(|cx| { + SettingsStore::update_global(cx, |settings, cx| { + settings.update_user_settings(cx, |settings| { + settings.theme.experimental_theme_overrides = Some(ThemeStyleContent { + colors: ThemeColorsContent { + search_active_match_background: Some("#ff0000ff".to_string()), + ..Default::default() + }, + ..Default::default() + }); + }); + }); + }); + let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/dir"), @@ -2486,113 +2604,113 @@ pub mod tests { }); perform_search(search_view, "TWO", cx); - search_view.update(cx, |search_view, window, cx| { - assert_eq!( - search_view - .results_editor - .update(cx, |editor, cx| editor.display_text(cx)), - "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;" - ); - let match_background_color = cx.theme().colors().search_match_background; - let selection_background_color = cx.theme().colors().editor_document_highlight_bracket_background; - assert_eq!( - search_view - .results_editor - .update(cx, |editor, cx| editor.all_text_background_highlights(window, cx)), - &[ - ( - DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35), - match_background_color - ), - ( - DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40), - selection_background_color - ), - ( - DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40), - match_background_color - ), - ( - DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9), - match_background_color - ), - - ] - ); - assert_eq!(search_view.active_match_index, Some(0)); - assert_eq!( - search_view - .results_editor - .update(cx, |editor, cx| editor.selections.display_ranges(&editor.display_snapshot(cx))), - [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)] - ); - - search_view.select_match(Direction::Next, window, cx); - }).unwrap(); + cx.run_until_parked(); search_view - .update(cx, |search_view, window, cx| { - assert_eq!(search_view.active_match_index, Some(1)); + .update(cx, |search_view, _window, cx| { assert_eq!( - search_view.results_editor.update(cx, |editor, cx| editor - .selections - .display_ranges(&editor.display_snapshot(cx))), - [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)] + search_view + .results_editor + .update(cx, |editor, cx| editor.display_text(cx)), + "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;" ); - search_view.select_match(Direction::Next, window, cx); }) .unwrap(); - search_view - .update(cx, |search_view, window, cx| { - assert_eq!(search_view.active_match_index, Some(2)); - assert_eq!( - search_view.results_editor.update(cx, |editor, cx| editor - .selections - .display_ranges(&editor.display_snapshot(cx))), - [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)] - ); - search_view.select_match(Direction::Next, window, cx); - }) - .unwrap(); + assert_active_match_index(&search_view, cx, 0); + assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35)); + assert_highlights( + &search_view, + cx, + vec![ + (dp(2, 32)..dp(2, 35), "active"), + (dp(2, 37)..dp(2, 40), "selection"), + (dp(2, 37)..dp(2, 40), "match"), + (dp(5, 6)..dp(5, 9), "match"), + // TODO: we should be getting selection highlight here after project search + // but for some reason we are not getting it here + ], + ); + select_match(&search_view, cx, Direction::Next); + cx.run_until_parked(); - search_view - .update(cx, |search_view, window, cx| { - assert_eq!(search_view.active_match_index, Some(0)); - assert_eq!( - search_view.results_editor.update(cx, |editor, cx| editor - .selections - .display_ranges(&editor.display_snapshot(cx))), - [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)] - ); - search_view.select_match(Direction::Prev, window, cx); - }) - .unwrap(); + assert_active_match_index(&search_view, cx, 1); + assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40)); + assert_highlights( + &search_view, + cx, + vec![ + (dp(2, 32)..dp(2, 35), "selection"), + (dp(2, 32)..dp(2, 35), "match"), + (dp(2, 37)..dp(2, 40), "active"), + (dp(5, 6)..dp(5, 9), "selection"), + (dp(5, 6)..dp(5, 9), "match"), + ], + ); + select_match(&search_view, cx, Direction::Next); + cx.run_until_parked(); - search_view - .update(cx, |search_view, window, cx| { - assert_eq!(search_view.active_match_index, Some(2)); - assert_eq!( - search_view.results_editor.update(cx, |editor, cx| editor - .selections - .display_ranges(&editor.display_snapshot(cx))), - [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)] - ); - search_view.select_match(Direction::Prev, window, cx); - }) - .unwrap(); + assert_active_match_index(&search_view, cx, 2); + assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9)); + assert_highlights( + &search_view, + cx, + vec![ + (dp(2, 32)..dp(2, 35), "selection"), + (dp(2, 32)..dp(2, 35), "match"), + (dp(2, 37)..dp(2, 40), "selection"), + (dp(2, 37)..dp(2, 40), "match"), + (dp(5, 6)..dp(5, 9), "active"), + ], + ); + select_match(&search_view, cx, Direction::Next); + cx.run_until_parked(); - search_view - .update(cx, |search_view, _, cx| { - assert_eq!(search_view.active_match_index, Some(1)); - assert_eq!( - search_view.results_editor.update(cx, |editor, cx| editor - .selections - .display_ranges(&editor.display_snapshot(cx))), - [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)] - ); - }) - .unwrap(); + assert_active_match_index(&search_view, cx, 0); + assert_selection_range(&search_view, cx, dp(2, 32)..dp(2, 35)); + assert_highlights( + &search_view, + cx, + vec![ + (dp(2, 32)..dp(2, 35), "active"), + (dp(2, 37)..dp(2, 40), "selection"), + (dp(2, 37)..dp(2, 40), "match"), + (dp(5, 6)..dp(5, 9), "selection"), + (dp(5, 6)..dp(5, 9), "match"), + ], + ); + select_match(&search_view, cx, Direction::Prev); + cx.run_until_parked(); + + assert_active_match_index(&search_view, cx, 2); + assert_selection_range(&search_view, cx, dp(5, 6)..dp(5, 9)); + assert_highlights( + &search_view, + cx, + vec![ + (dp(2, 32)..dp(2, 35), "selection"), + (dp(2, 32)..dp(2, 35), "match"), + (dp(2, 37)..dp(2, 40), "selection"), + (dp(2, 37)..dp(2, 40), "match"), + (dp(5, 6)..dp(5, 9), "active"), + ], + ); + select_match(&search_view, cx, Direction::Prev); + cx.run_until_parked(); + + assert_active_match_index(&search_view, cx, 1); + assert_selection_range(&search_view, cx, dp(2, 37)..dp(2, 40)); + assert_highlights( + &search_view, + cx, + vec![ + (dp(2, 32)..dp(2, 35), "selection"), + (dp(2, 32)..dp(2, 35), "match"), + (dp(2, 37)..dp(2, 40), "active"), + (dp(5, 6)..dp(5, 9), "selection"), + (dp(5, 6)..dp(5, 9), "match"), + ], + ); } #[perf] diff --git a/crates/settings/src/settings_content/theme.rs b/crates/settings/src/settings_content/theme.rs index 49942634af3da9f7009ba02ca6cbf79c30ddaa13..94045b75a1112af64ed56de318d4e27c392a230e 100644 --- a/crates/settings/src/settings_content/theme.rs +++ b/crates/settings/src/settings_content/theme.rs @@ -570,6 +570,9 @@ pub struct ThemeColorsContent { #[serde(rename = "search.match_background")] pub search_match_background: Option, + #[serde(rename = "search.active_match_background")] + pub search_active_match_background: Option, + #[serde(rename = "panel.background")] pub panel_background: Option, diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 2a5213ce7ebc3326c7f4a0b5a8291e098e65cd78..4d567d902ff4f9271a0bdcf6a4db94d0e3a34ec6 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1434,6 +1434,7 @@ impl SearchableItem for TerminalView { fn update_matches( &mut self, matches: &[Self::Match], + _active_match_index: Option, _window: &mut Window, cx: &mut Context, ) { diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 50da8c72b63443f2c70df59ccb9f5f5caf777ca8..82be2896c67f155ac61de1ca6afb058adbf5ea9c 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -91,6 +91,7 @@ impl ThemeColors { tab_inactive_background: neutral().light().step_2(), tab_active_background: neutral().light().step_1(), search_match_background: neutral().light().step_5(), + search_active_match_background: neutral().light().step_7(), panel_background: neutral().light().step_2(), panel_focused_border: blue().light().step_10(), panel_indent_guide: neutral().light_alpha().step_5(), @@ -228,6 +229,7 @@ impl ThemeColors { tab_inactive_background: neutral().dark().step_2(), tab_active_background: neutral().dark().step_1(), search_match_background: neutral().dark().step_5(), + search_active_match_background: neutral().dark().step_3(), panel_background: neutral().dark().step_2(), panel_focused_border: blue().dark().step_8(), panel_indent_guide: neutral().dark_alpha().step_4(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 2351ed6bcbd2297ebb5a173d17c095d92bb27c20..6bfcb1c86811136388eb5a557458f88c65d0ac09 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -152,6 +152,7 @@ pub(crate) fn zed_default_dark() -> Theme { tab_inactive_background: bg, tab_active_background: editor, search_match_background: bg, + search_active_match_background: bg, editor_background: editor, editor_gutter_background: editor, diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 9c9cfbffef681890a802d21b8bcff85d358a64b8..f52b2cf0e50bc5d8b26de9457432aba9218a17b9 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -287,6 +287,15 @@ pub fn theme_colors_refinement( .panel_background .as_ref() .and_then(|color| try_parse_color(color).ok()); + let search_match_background = this + .search_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()); + let search_active_match_background = this + .search_active_match_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()) + .or(search_match_background); ThemeColorsRefinement { border, border_variant: this @@ -442,10 +451,8 @@ pub fn theme_colors_refinement( .tab_active_background .as_ref() .and_then(|color| try_parse_color(color).ok()), - search_match_background: this - .search_match_background - .as_ref() - .and_then(|color| try_parse_color(color).ok()), + search_match_background: search_match_background, + search_active_match_background: search_active_match_background, panel_background, panel_focused_border: this .panel_focused_border diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index c6766ca955700e2b7c3cd0e86ab16535fca8d852..905f2245e03ad7a8ce7a4eb8be6799e5ded379c4 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -128,6 +128,7 @@ pub struct ThemeColors { pub tab_inactive_background: Hsla, pub tab_active_background: Hsla, pub search_match_background: Hsla, + pub search_active_match_background: Hsla, pub panel_background: Hsla, pub panel_focused_border: Hsla, pub panel_indent_guide: Hsla, @@ -352,6 +353,7 @@ pub enum ThemeColorField { TabInactiveBackground, TabActiveBackground, SearchMatchBackground, + SearchActiveMatchBackground, PanelBackground, PanelFocusedBorder, PanelIndentGuide, @@ -467,6 +469,7 @@ impl ThemeColors { ThemeColorField::TabInactiveBackground => self.tab_inactive_background, ThemeColorField::TabActiveBackground => self.tab_active_background, ThemeColorField::SearchMatchBackground => self.search_match_background, + ThemeColorField::SearchActiveMatchBackground => self.search_active_match_background, ThemeColorField::PanelBackground => self.panel_background, ThemeColorField::PanelFocusedBorder => self.panel_focused_border, ThemeColorField::PanelIndentGuide => self.panel_indent_guide, diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 71ed0d44384a5ed8644f486aa16cdd704e9ce944..81350d780a507a6e1d2502cf0f05115dc19abcdf 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -227,7 +227,7 @@ impl Vim { editor.highlight_background::( &ranges_to_highlight, - |colors| colors.colors().editor_document_highlight_read_background, + |_, colors| colors.colors().editor_document_highlight_read_background, cx, ); cx.spawn(async move |this, cx| { diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 93c30141daeac21805e8ea1aab610988a09a9635..63d452f84bfd5ee1cea8970698962169dc8fe94a 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -273,7 +273,7 @@ impl Vim { let ranges = [new_range]; editor.highlight_background::( &ranges, - |theme| theme.colors().editor_document_highlight_read_background, + |_, theme| theme.colors().editor_document_highlight_read_background, cx, ); } diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 64dad0345fa323eb724b6b51656b841c8d433688..badfe7d2437424c1ce18a1afde19507e7d6e1d3b 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -96,6 +96,7 @@ pub trait SearchableItem: Item + EventEmitter { fn update_matches( &mut self, matches: &[Self::Match], + active_match_index: Option, window: &mut Window, cx: &mut Context, ); @@ -179,7 +180,13 @@ pub trait SearchableItemHandle: ItemHandle { handler: Box, ) -> Subscription; fn clear_matches(&self, window: &mut Window, cx: &mut App); - fn update_matches(&self, matches: &AnyVec, window: &mut Window, cx: &mut App); + fn update_matches( + &self, + matches: &AnyVec, + active_match_index: Option, + window: &mut Window, + cx: &mut App, + ); fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String; fn activate_match( &self, @@ -264,10 +271,16 @@ impl SearchableItemHandle for Entity { fn clear_matches(&self, window: &mut Window, cx: &mut App) { self.update(cx, |this, cx| this.clear_matches(window, cx)); } - fn update_matches(&self, matches: &AnyVec, window: &mut Window, cx: &mut App) { + fn update_matches( + &self, + matches: &AnyVec, + active_match_index: Option, + window: &mut Window, + cx: &mut App, + ) { let matches = matches.downcast_ref().unwrap(); self.update(cx, |this, cx| { - this.update_matches(matches.as_slice(), window, cx) + this.update_matches(matches.as_slice(), active_match_index, window, cx) }); } fn query_suggestion(&self, window: &mut Window, cx: &mut App) -> String { From 40a611bf34bf73f2149f133a6c332276f58d2248 Mon Sep 17 00:00:00 2001 From: Andrew Farkas <6060305+HactarCE@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:49:44 -0500 Subject: [PATCH 52/67] tab_switcher: Subscribe to workspace events instead of pane events (#44101) Closes #43171 Previously the tab switcher only subscribed to events from a single pane so closing tabs in other panes wouldn't cause the tab switcher to update. This PR changes that so the tab switcher subscribes to the whole workspace and thus updates when tabs in other panes are closed. It also modifies the work in #44006 to sync selected index across the whole workspace instead of just the original pane in the case of the all-panes tab switcher. Release Notes: - Fixed all-panes tab switcher not updating in response to changes in other panes --- crates/tab_switcher/src/tab_switcher.rs | 49 +++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 2b98f6c7e329e7f98edb6b6e994de444a8b835da..85186ad504eb098264aae64ba3c2354d20d011a4 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -23,9 +23,9 @@ use ui::{ }; use util::ResultExt; use workspace::{ - ModalView, Pane, SaveIntent, Workspace, + Event as WorkspaceEvent, ModalView, Pane, SaveIntent, Workspace, item::{ItemHandle, ItemSettings, ShowDiagnostics, TabContentParams}, - pane::{Event as PaneEvent, render_item_indicator, tab_details}, + pane::{render_item_indicator, tab_details}, }; const PANEL_WIDTH_REMS: f32 = 28.; @@ -322,7 +322,7 @@ impl TabSwitcherDelegate { cx: &mut Context, original_items: Vec<(Entity, usize)>, ) -> Self { - Self::subscribe_to_updates(&pane, window, cx); + Self::subscribe_to_updates(&workspace, window, cx); Self { select_last, tab_switcher, @@ -338,34 +338,36 @@ impl TabSwitcherDelegate { } fn subscribe_to_updates( - pane: &WeakEntity, + workspace: &WeakEntity, window: &mut Window, cx: &mut Context, ) { - let Some(pane) = pane.upgrade() else { + let Some(workspace) = workspace.upgrade() else { return; }; - cx.subscribe_in(&pane, window, |tab_switcher, _, event, window, cx| { + cx.subscribe_in(&workspace, window, |tab_switcher, _, event, window, cx| { match event { - PaneEvent::AddItem { .. } | PaneEvent::Remove { .. } => { + WorkspaceEvent::ItemAdded { .. } | WorkspaceEvent::PaneRemoved => { tab_switcher.picker.update(cx, |picker, cx| { let query = picker.query(cx); picker.delegate.update_matches(query, window, cx); cx.notify(); }) } - PaneEvent::RemovedItem { .. } => tab_switcher.picker.update(cx, |picker, cx| { - let query = picker.query(cx); - picker.delegate.update_matches(query, window, cx); - - // When the Tab Switcher is being used and an item is - // removed, there's a chance that the new selected index - // will not match the actual tab that is now being displayed - // by the pane, as such, the selected index needs to be - // updated to match the pane's state. - picker.delegate.sync_selected_index(cx); - cx.notify(); - }), + WorkspaceEvent::ItemRemoved { .. } => { + tab_switcher.picker.update(cx, |picker, cx| { + let query = picker.query(cx); + picker.delegate.update_matches(query, window, cx); + + // When the Tab Switcher is being used and an item is + // removed, there's a chance that the new selected index + // will not match the actual tab that is now being displayed + // by the pane, as such, the selected index needs to be + // updated to match the pane's state. + picker.delegate.sync_selected_index(cx); + cx.notify(); + }) + } _ => {} }; }) @@ -563,7 +565,14 @@ impl TabSwitcherDelegate { /// as the pane's active item can be indirectly updated and this method /// ensures that the picker can react to those changes. fn sync_selected_index(&mut self, cx: &mut Context>) { - let Ok(Some(item)) = self.pane.read_with(cx, |pane, _cx| pane.active_item()) else { + let item = if self.is_all_panes { + self.workspace + .read_with(cx, |workspace, cx| workspace.active_item(cx)) + } else { + self.pane.read_with(cx, |pane, _cx| pane.active_item()) + }; + + let Ok(Some(item)) = item else { return; }; From f90d9d26a55eb4730cd700c6814c6cee73bd94d7 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 3 Dec 2025 17:56:51 -0500 Subject: [PATCH 53/67] Prefer to disable options over hiding (git panel entry context menu) (#44102) When adding the File History option here, I used the pattern to hide the option, since that's what another option was already doing here, but I see other menus in the git panel (`...`) that use disabling over hiding, which is what I think is a nicer experience (allows you to learn of actions, the full range of actions is always visible, don't have to worry about how multiple hidden items might interact in various configurations, etc). SCR-20251203-pnpy SCR-20251203-pobg In general, I think it would be good to move to being more consistent with disabling over hiding - there are other places in the app that are hiding - some might be valid, but others might just choices made on a whim. Release Notes: - N/A --- crates/git_ui/src/git_panel.rs | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 1c9b817be2507f806eab505555163f72b2fd148a..62bd118daf1751e32dd0b805a773be47e19e4357 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4004,28 +4004,21 @@ impl GitPanel { "Restore File" }; let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| { - let mut context_menu = context_menu + let is_created = entry.status.is_created(); + context_menu .context(self.focus_handle.clone()) .action(stage_title, ToggleStaged.boxed_clone()) - .action(restore_title, git::RestoreFile::default().boxed_clone()); - - if entry.status.is_created() { - context_menu = - context_menu.action("Add to .gitignore", git::AddToGitignore.boxed_clone()) - } - - context_menu = context_menu + .action(restore_title, git::RestoreFile::default().boxed_clone()) + .action_disabled_when( + !is_created, + "Add to .gitignore", + git::AddToGitignore.boxed_clone(), + ) .separator() .action("Open Diff", Confirm.boxed_clone()) - .action("Open File", SecondaryConfirm.boxed_clone()); - - if !entry.status.is_created() { - context_menu = context_menu - .separator() - .action("File History", Box::new(git::FileHistory)); - } - - context_menu + .action("Open File", SecondaryConfirm.boxed_clone()) + .separator() + .action_disabled_when(is_created, "File History", Box::new(git::FileHistory)) }); self.selected_entry = Some(ix); self.set_context_menu(context_menu, position, window, cx); From 1e4d80a21f6bce80aff18f6d2f1b2ae41f527dc0 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 4 Dec 2025 00:29:31 -0700 Subject: [PATCH 54/67] Update fancy-regex (#44120) Fancy regex has a max backtracking limit which defaults to 1,000,000 backtracks. This avoids spinning the CPU forever in the case that a match is taking a long time (though does mean that some matches may be missed). Unfortunately the verison we depended on causes an infinite loop when the backtracking limit is hit (https://github.com/fancy-regex/fancy-regex/issues/137), so we got the worse of both worlds: matches were missed *and* we spun the CPU forever. Updating fixes this. Excitingly regex may gain support for lookarounds (https://github.com/rust-lang/regex/pull/1315), which will make fancy-regex much less load bearing. Closes #43821 Release Notes: - Fix a bug where search regexes with look-around or backreferences could hang the CPU. They will now abort after a certain number of match attempts. --- Cargo.lock | 88 +++++++++++++++++++++++++----------------------------- Cargo.toml | 6 ++-- 2 files changed, 43 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03b7339856a9adba3538152ac3874fd0dec859b5..5078c79e21ce1a580a6e055a7ce8ab4295f56906 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2130,30 +2130,15 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.8.0" @@ -2332,9 +2317,9 @@ dependencies = [ [[package]] name = "borrow-or-share" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" [[package]] name = "borsh" @@ -6008,22 +5993,11 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fancy-regex" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" -dependencies = [ - "bit-set 0.5.3", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fancy-regex" -version = "0.14.0" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ - "bit-set 0.8.0", + "bit-set", "regex-automata", "regex-syntax", ] @@ -6245,9 +6219,9 @@ checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" [[package]] name = "fluent-uri" -version = "0.3.2" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" dependencies = [ "borrow-or-share", "ref-cast", @@ -7543,6 +7517,17 @@ dependencies = [ "serde", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.8.4" @@ -8632,21 +8617,21 @@ dependencies = [ [[package]] name = "jsonschema" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1b46a0365a611fbf1d2143104dcf910aada96fafd295bab16c60b802bf6fa1d" +checksum = "73c9ffb2b5c56d58030e1b532d8e8389da94590515f118cf35b5cb68e4764a7e" dependencies = [ "ahash 0.8.12", - "base64 0.22.1", "bytecount", + "data-encoding", "email_address", - "fancy-regex 0.14.0", + "fancy-regex", "fraction", + "getrandom 0.3.4", "idna", "itoa", "num-cmp", "num-traits", - "once_cell", "percent-encoding", "referencing", "regex", @@ -8654,6 +8639,7 @@ dependencies = [ "reqwest 0.12.24", "serde", "serde_json", + "unicode-general-category", "uuid-simd", ] @@ -10202,7 +10188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", - "bit-set 0.8.0", + "bit-set", "bitflags 2.9.4", "cfg_aliases 0.2.1", "codespan-reporting 0.12.0", @@ -13058,7 +13044,7 @@ dependencies = [ "dap", "dap_adapters", "extension", - "fancy-regex 0.14.0", + "fancy-regex", "fs", "futures 0.3.31", "fuzzy", @@ -13929,13 +13915,14 @@ dependencies = [ [[package]] name = "referencing" -version = "0.30.0" +version = "0.37.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8eff4fa778b5c2a57e85c5f2fe3a709c52f0e60d23146e2151cbef5893f420e" +checksum = "4283168a506f0dcbdce31c9f9cce3129c924da4c6bca46e46707fcb746d2d70c" dependencies = [ "ahash 0.8.12", "fluent-uri", - "once_cell", + "getrandom 0.3.4", + "hashbrown 0.16.1", "parking_lot", "percent-encoding", "serde_json", @@ -17129,7 +17116,7 @@ dependencies = [ "alacritty_terminal", "anyhow", "collections", - "fancy-regex 0.14.0", + "fancy-regex", "futures 0.3.31", "gpui", "itertools 0.14.0", @@ -17363,12 +17350,12 @@ dependencies = [ [[package]] name = "tiktoken-rs" version = "0.9.1" -source = "git+https://github.com/zed-industries/tiktoken-rs?rev=7249f999c5fdf9bf3cc5c288c964454e4dac0c00#7249f999c5fdf9bf3cc5c288c964454e4dac0c00" +source = "git+https://github.com/zed-industries/tiktoken-rs?rev=2570c4387a8505fb8f1d3f3557454b474f1e8271#2570c4387a8505fb8f1d3f3557454b474f1e8271" dependencies = [ "anyhow", "base64 0.22.1", "bstr", - "fancy-regex 0.13.0", + "fancy-regex", "lazy_static", "regex", "rustc-hash 1.1.0", @@ -18500,6 +18487,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -18734,7 +18727,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" dependencies = [ "outref", - "uuid", "vsimd", ] diff --git a/Cargo.toml b/Cargo.toml index e81e53426fc9ee47000e14cb8141ce4e4b6d8b30..59b9a53d4a60b28582625fb90b64b934079cdc40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -505,7 +505,7 @@ ec4rs = "1.1" emojis = "0.6.1" env_logger = "0.11" exec = "0.3.1" -fancy-regex = "0.14.0" +fancy-regex = "0.16.0" fork = "0.4.0" futures = "0.3" futures-batch = "0.6.1" @@ -531,7 +531,7 @@ indoc = "2" inventory = "0.3.19" itertools = "0.14.0" json_dotpath = "1.1" -jsonschema = "0.30.0" +jsonschema = "0.37.0" jsonwebtoken = "9.3" jupyter-protocol = "0.10.0" jupyter-websocket-client = "0.15.0" @@ -658,7 +658,7 @@ sysinfo = "0.37.0" take-until = "0.2.0" tempfile = "3.20.0" thiserror = "2.0.12" -tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "7249f999c5fdf9bf3cc5c288c964454e4dac0c00" } +tiktoken-rs = { git = "https://github.com/zed-industries/tiktoken-rs", rev = "2570c4387a8505fb8f1d3f3557454b474f1e8271" } time = { version = "0.3", features = [ "macros", "parsing", From 391c92b07ae3f5d6fb5137b6cb727bc096e04317 Mon Sep 17 00:00:00 2001 From: John Tur Date: Thu, 4 Dec 2025 02:45:36 -0500 Subject: [PATCH 55/67] Reduce priority of Windows thread pool work items (#44121) `WorkItemPriority::High` will enqueue the work items to threads with higher-than-normal priority. If the work items are very intensive, this can cause the system to become unresponsive. It's not clear what this gets us, so let's avoid the responsiveness issue by deleting this. Release Notes: - N/A --- crates/gpui/src/platform/windows/dispatcher.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/platform/windows/dispatcher.rs b/crates/gpui/src/platform/windows/dispatcher.rs index dd53c86f5ed687c9b22a08779f262392f44a66ce..6214e60e5b4b178c20b1fff655f4ac8b49be3f4c 100644 --- a/crates/gpui/src/platform/windows/dispatcher.rs +++ b/crates/gpui/src/platform/windows/dispatcher.rs @@ -7,9 +7,7 @@ use std::{ use flume::Sender; use util::ResultExt; use windows::{ - System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, - }, + System::Threading::{ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler}, Win32::{ Foundation::{LPARAM, WPARAM}, UI::WindowsAndMessaging::PostMessageW, @@ -55,7 +53,7 @@ impl WindowsDispatcher { Ok(()) }) }; - ThreadPool::RunWithPriorityAsync(&handler, WorkItemPriority::High).log_err(); + ThreadPool::RunAsync(&handler).log_err(); } fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) { From db2e26f67bf0d71b8d23b1e3d0e6e16280c005f0 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 4 Dec 2025 12:21:37 +0200 Subject: [PATCH 56/67] Re-colorize the brackets when the theme changes (#44130) Closes https://github.com/zed-industries/zed/issues/44127 Release Notes: - Fixed brackets not re-colorizing on theme change --- crates/editor/src/editor.rs | 39 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f90400b2b1e07b14959d5b532c5926f7c3224dbe..05287847190691221e6f948ba53efecc7269e9be 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -191,7 +191,7 @@ use std::{ use task::{ResolvedTask, RunnableTag, TaskTemplate, TaskVariables}; use text::{BufferId, FromAnchor, OffsetUtf16, Rope, ToOffset as _}; use theme::{ - ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings, + AccentColors, ActiveTheme, PlayerColor, StatusColors, SyntaxTheme, Theme, ThemeSettings, observe_buffer_font_size_adjustment, }; use ui::{ @@ -1206,11 +1206,17 @@ pub struct Editor { select_next_is_case_sensitive: Option, pub lookup_key: Option>, applicable_language_settings: HashMap, LanguageSettings>, - accent_overrides: Vec, + accent_data: Option, fetched_tree_sitter_chunks: HashMap>>, use_base_text_line_numbers: bool, } +#[derive(Debug, PartialEq)] +struct AccentData { + colors: AccentColors, + overrides: Vec, +} + fn debounce_value(debounce_ms: u64) -> Option { if debounce_ms > 0 { Some(Duration::from_millis(debounce_ms)) @@ -2354,7 +2360,7 @@ impl Editor { lookup_key: None, select_next_is_case_sensitive: None, applicable_language_settings: HashMap::default(), - accent_overrides: Vec::new(), + accent_data: None, fetched_tree_sitter_chunks: HashMap::default(), use_base_text_line_numbers: false, }; @@ -2364,7 +2370,7 @@ impl Editor { } editor.applicable_language_settings = editor.fetch_applicable_language_settings(cx); - editor.accent_overrides = editor.fetch_accent_overrides(cx); + editor.accent_data = editor.fetch_accent_data(cx); if let Some(breakpoints) = editor.breakpoint_store.as_ref() { editor @@ -21706,16 +21712,18 @@ impl Editor { cx.notify(); } - fn fetch_accent_overrides(&self, cx: &App) -> Vec { + fn fetch_accent_data(&self, cx: &App) -> Option { if !self.mode.is_full() { - return Vec::new(); + return None; } let theme_settings = theme::ThemeSettings::get_global(cx); + let theme = cx.theme(); + let accent_colors = theme.accents().clone(); - theme_settings + let accent_overrides = theme_settings .theme_overrides - .get(cx.theme().name.as_ref()) + .get(theme.name.as_ref()) .map(|theme_style| &theme_style.accents) .into_iter() .flatten() @@ -21728,7 +21736,12 @@ impl Editor { .flatten(), ) .flat_map(|accent| accent.0.clone()) - .collect() + .collect(); + + Some(AccentData { + colors: accent_colors, + overrides: accent_overrides, + }) } fn fetch_applicable_language_settings( @@ -21758,9 +21771,9 @@ impl Editor { let language_settings_changed = new_language_settings != self.applicable_language_settings; self.applicable_language_settings = new_language_settings; - let new_accent_overrides = self.fetch_accent_overrides(cx); - let accent_overrides_changed = new_accent_overrides != self.accent_overrides; - self.accent_overrides = new_accent_overrides; + let new_accents = self.fetch_accent_data(cx); + let accents_changed = new_accents != self.accent_data; + self.accent_data = new_accents; if self.diagnostics_enabled() { let new_severity = EditorSettings::get_global(cx) @@ -21834,7 +21847,7 @@ impl Editor { } } - if language_settings_changed || accent_overrides_changed { + if language_settings_changed || accents_changed { self.colorize_brackets(true, cx); } From b07389d9f3b1e1c2cf5c275302438061d372a226 Mon Sep 17 00:00:00 2001 From: Aero Date: Thu, 4 Dec 2025 18:38:10 +0800 Subject: [PATCH 57/67] macos: Add missing file access entitlements (#43609) Adds `com.apple.security.files.user-selected.read-write` and `com.apple.security.files.downloads.read-write` to zed.entitlements. This resolves an issue where the integrated terminal could not access external drives or user-selected files on macOS, even when "Full Disk Access" was granted. These entitlements are required for the application to properly inherit file access permissions. Release Notes: - Resolves an issue where the integrated terminal could not access external drives or user-selected files on macOS. --- crates/zed/resources/zed.entitlements | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/zed/resources/zed.entitlements b/crates/zed/resources/zed.entitlements index cb4cd3dc692160047ae5012489a350829c4a1ccf..2a16afe7551f433e3f835a2097df61a2e9e86ee1 100644 --- a/crates/zed/resources/zed.entitlements +++ b/crates/zed/resources/zed.entitlements @@ -22,5 +22,9 @@ com.apple.security.personal-information.photos-library + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + From 9db0d662518c0c56547e1330d4cd63ab86c8355e Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 4 Dec 2025 07:40:51 -0300 Subject: [PATCH 58/67] linux: Spawn at least two background threads (#44110) Related to https://github.com/zed-industries/zed/pull/44109, https://github.com/zed-industries/zed/issues/43884, https://github.com/zed-industries/zed/issues/43809. In the Linux dispatcher, we create one background thread per CPU, but when a single core is available, having a single background thread significantly hinders the perceived performance of Zed. This is particularly helpful when SSH remoting to low-resource servers. We may want to bump this to more than two threads actually, but I wanted to be conservative, and this seems to make a big difference already. Release Notes: - N/A --- crates/gpui/src/platform/linux/dispatcher.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/platform/linux/dispatcher.rs b/crates/gpui/src/platform/linux/dispatcher.rs index c300109ffe32b3537acbbca47b4c39674cad2fd1..d0c32140f3642e037df326f4e2beae16c59dd883 100644 --- a/crates/gpui/src/platform/linux/dispatcher.rs +++ b/crates/gpui/src/platform/linux/dispatcher.rs @@ -26,12 +26,13 @@ pub(crate) struct LinuxDispatcher { main_thread_id: thread::ThreadId, } +const MIN_THREADS: usize = 2; + impl LinuxDispatcher { pub fn new(main_sender: Sender) -> Self { let (background_sender, background_receiver) = flume::unbounded::(); - let thread_count = std::thread::available_parallelism() - .map(|i| i.get()) - .unwrap_or(1); + let thread_count = + std::thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); let mut background_threads = (0..thread_count) .map(|i| { From 0f0017dc8e7dce1ab8cfbbac337e83d79b612289 Mon Sep 17 00:00:00 2001 From: Shardul Vaidya <31039336+5herlocked@users.noreply.github.com> Date: Thu, 4 Dec 2025 06:14:31 -0500 Subject: [PATCH 59/67] bedrock: Support global endpoints and new regional endpoints (#44103) Closes #43598 Release Notes: - bedrock: Added opt-in `allow_global` which enables global endpoints - bedrock: Updated cross-region-inference endpoint and model list - bedrock: Fixed Opus 4.5 access on Bedrock, now only accessible through the `allow_global` setting --- crates/bedrock/src/models.rs | 201 +++++++++++++----- .../language_models/src/provider/bedrock.rs | 14 +- crates/language_models/src/settings.rs | 1 + .../src/settings_content/language_model.rs | 1 + docs/src/ai/llm-providers.md | 28 ++- 5 files changed, 185 insertions(+), 60 deletions(-) diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs index f3b276a8d2f30e8062931e76608bbc3a302ad734..51e1b29f9ad3cf953605c5c59090785f3ab45eac 100644 --- a/crates/bedrock/src/models.rs +++ b/crates/bedrock/src/models.rs @@ -584,41 +584,100 @@ impl Model { } } - pub fn cross_region_inference_id(&self, region: &str) -> anyhow::Result { + pub fn cross_region_inference_id( + &self, + region: &str, + allow_global: bool, + ) -> anyhow::Result { + // List derived from here: + // https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system + let model_id = self.request_id(); + + let supports_global = matches!( + self, + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking + ); + let region_group = if region.starts_with("us-gov-") { "us-gov" - } else if region.starts_with("us-") { - "us" + } else if region.starts_with("us-") + || region.starts_with("ca-") + || region.starts_with("sa-") + { + if allow_global && supports_global { + "global" + } else { + "us" + } } else if region.starts_with("eu-") { - "eu" + if allow_global && supports_global { + "global" + } else { + "eu" + } } else if region.starts_with("ap-") || region == "me-central-1" || region == "me-south-1" { - "apac" - } else if region.starts_with("ca-") || region.starts_with("sa-") { - // Canada and South America regions - default to US profiles - "us" + if allow_global && supports_global { + "global" + } else { + "apac" + } } else { anyhow::bail!("Unsupported Region {region}"); }; - let model_id = self.request_id(); + match (self, region_group, region) { + (Model::Custom { .. }, _, _) => Ok(self.request_id().into()), - match (self, region_group) { - // Custom models can't have CRI IDs - (Model::Custom { .. }, _) => Ok(self.request_id().into()), + ( + Model::ClaudeOpus4_5 + | Model::ClaudeOpus4_5Thinking + | Model::ClaudeHaiku4_5 + | Model::ClaudeSonnet4 + | Model::ClaudeSonnet4Thinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "global", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), - // Models with US Gov only - (Model::Claude3_5Sonnet, "us-gov") | (Model::Claude3Haiku, "us-gov") => { - Ok(format!("{}.{}", region_group, model_id)) - } + ( + Model::Claude3Haiku + | Model::Claude3_5Sonnet + | Model::Claude3_7Sonnet + | Model::Claude3_7SonnetThinking + | Model::ClaudeSonnet4_5 + | Model::ClaudeSonnet4_5Thinking, + "us-gov", + _, + ) => Ok(format!("{}.{}", region_group, model_id)), - // Available everywhere - (Model::AmazonNovaLite | Model::AmazonNovaMicro | Model::AmazonNovaPro, _) => { - Ok(format!("{}.{}", region_group, model_id)) + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-southeast-2" | "ap-southeast-4", + ) => Ok(format!("au.{}", model_id)), + + ( + Model::ClaudeHaiku4_5 | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking, + "apac", + "ap-northeast-1" | "ap-northeast-3", + ) => Ok(format!("jp.{}", model_id)), + + (Model::AmazonNovaLite, "us", r) if r.starts_with("ca-") => { + Ok(format!("ca.{}", model_id)) } - // Models in US ( Model::AmazonNovaPremier + | Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro | Model::Claude3_5Haiku | Model::ClaudeHaiku4_5 | Model::Claude3_5Sonnet @@ -655,16 +714,18 @@ impl Model { | Model::PalmyraWriterX4 | Model::PalmyraWriterX5, "us", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in EU ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet | Model::ClaudeHaiku4_5 | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking | Model::ClaudeSonnet4_5 | Model::ClaudeSonnet4_5Thinking | Model::Claude3Haiku @@ -673,26 +734,26 @@ impl Model { | Model::MetaLlama323BInstructV1 | Model::MistralPixtralLarge2502V1, "eu", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Models available in APAC ( - Model::Claude3_5Sonnet + Model::AmazonNovaLite + | Model::AmazonNovaMicro + | Model::AmazonNovaPro + | Model::Claude3_5Sonnet | Model::Claude3_5SonnetV2 | Model::ClaudeHaiku4_5 - | Model::Claude3Haiku - | Model::Claude3Sonnet | Model::Claude3_7Sonnet | Model::Claude3_7SonnetThinking | Model::ClaudeSonnet4 - | Model::ClaudeSonnet4Thinking - | Model::ClaudeSonnet4_5 - | Model::ClaudeSonnet4_5Thinking, + | Model::Claude3Haiku + | Model::Claude3Sonnet, "apac", + _, ) => Ok(format!("{}.{}", region_group, model_id)), - // Any other combination is not supported - _ => Ok(self.request_id().into()), + _ => Ok(model_id.into()), } } } @@ -705,15 +766,15 @@ mod tests { fn test_us_region_inference_ids() -> anyhow::Result<()> { // Test US regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-east-1", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("us-west-2", false)?, "us.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaPro.cross_region_inference_id("us-east-2")?, + Model::AmazonNovaPro.cross_region_inference_id("us-east-2", false)?, "us.amazon.nova-pro-v1:0" ); Ok(()) @@ -723,19 +784,19 @@ mod tests { fn test_eu_region_inference_ids() -> anyhow::Result<()> { // Test European regions assert_eq!( - Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1")?, + Model::ClaudeSonnet4.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-20250514-v1:0" ); assert_eq!( - Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1")?, + Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" ); assert_eq!( - Model::Claude3Sonnet.cross_region_inference_id("eu-west-1")?, + Model::Claude3Sonnet.cross_region_inference_id("eu-west-1", false)?, "eu.anthropic.claude-3-sonnet-20240229-v1:0" ); assert_eq!( - Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1")?, + Model::AmazonNovaMicro.cross_region_inference_id("eu-north-1", false)?, "eu.amazon.nova-micro-v1:0" ); Ok(()) @@ -745,15 +806,15 @@ mod tests { fn test_apac_region_inference_ids() -> anyhow::Result<()> { // Test Asia-Pacific regions assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-northeast-1", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2")?, + Model::Claude3_5SonnetV2.cross_region_inference_id("ap-southeast-2", false)?, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0" ); assert_eq!( - Model::AmazonNovaLite.cross_region_inference_id("ap-south-1")?, + Model::AmazonNovaLite.cross_region_inference_id("ap-south-1", false)?, "apac.amazon.nova-lite-v1:0" ); Ok(()) @@ -763,11 +824,11 @@ mod tests { fn test_gov_region_inference_ids() -> anyhow::Result<()> { // Test Government regions assert_eq!( - Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1")?, + Model::Claude3_5Sonnet.cross_region_inference_id("us-gov-east-1", false)?, "us-gov.anthropic.claude-3-5-sonnet-20240620-v1:0" ); assert_eq!( - Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1")?, + Model::Claude3Haiku.cross_region_inference_id("us-gov-west-1", false)?, "us-gov.anthropic.claude-3-haiku-20240307-v1:0" ); Ok(()) @@ -777,15 +838,15 @@ mod tests { fn test_meta_models_inference_ids() -> anyhow::Result<()> { // Test Meta models assert_eq!( - Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama370BInstructV1.cross_region_inference_id("us-east-1", false)?, "meta.llama3-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1")?, + Model::MetaLlama3170BInstructV1.cross_region_inference_id("us-east-1", false)?, "us.meta.llama3-1-70b-instruct-v1:0" ); assert_eq!( - Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1")?, + Model::MetaLlama321BInstructV1.cross_region_inference_id("eu-west-1", false)?, "eu.meta.llama3-2-1b-instruct-v1:0" ); Ok(()) @@ -796,11 +857,11 @@ mod tests { // Mistral models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1")?, + Model::MistralMistralLarge2402V1.cross_region_inference_id("us-east-1", false)?, "mistral.mistral-large-2402-v1:0" ); assert_eq!( - Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1")?, + Model::MistralMixtral8x7BInstructV0.cross_region_inference_id("eu-west-1", false)?, "mistral.mixtral-8x7b-instruct-v0:1" ); Ok(()) @@ -811,11 +872,11 @@ mod tests { // AI21 models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::AI21J2UltraV1.cross_region_inference_id("us-east-1")?, + Model::AI21J2UltraV1.cross_region_inference_id("us-east-1", false)?, "ai21.j2-ultra-v1" ); assert_eq!( - Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1")?, + Model::AI21JambaInstructV1.cross_region_inference_id("eu-west-1", false)?, "ai21.jamba-instruct-v1:0" ); Ok(()) @@ -826,11 +887,11 @@ mod tests { // Cohere models don't follow the regional prefix pattern, // so they should return their original IDs assert_eq!( - Model::CohereCommandRV1.cross_region_inference_id("us-east-1")?, + Model::CohereCommandRV1.cross_region_inference_id("us-east-1", false)?, "cohere.command-r-v1:0" ); assert_eq!( - Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1")?, + Model::CohereCommandTextV14_4k.cross_region_inference_id("ap-southeast-1", false)?, "cohere.command-text-v14:7:4k" ); Ok(()) @@ -850,10 +911,17 @@ mod tests { // Custom model should return its name unchanged assert_eq!( - custom_model.cross_region_inference_id("us-east-1")?, + custom_model.cross_region_inference_id("us-east-1", false)?, "custom.my-model-v1:0" ); + // Test that models without global support fall back to regional when allow_global is true + assert_eq!( + Model::AmazonNovaPro.cross_region_inference_id("us-east-1", true)?, + "us.amazon.nova-pro-v1:0", + "Nova Pro should fall back to regional profile even when allow_global is true" + ); + Ok(()) } @@ -892,3 +960,28 @@ mod tests { ); } } + +#[test] +fn test_global_inference_ids() -> anyhow::Result<()> { + // Test global inference for models that support it when allow_global is true + assert_eq!( + Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", true)?, + "global.anthropic.claude-sonnet-4-20250514-v1:0" + ); + assert_eq!( + Model::ClaudeSonnet4_5.cross_region_inference_id("eu-west-1", true)?, + "global.anthropic.claude-sonnet-4-5-20250929-v1:0" + ); + assert_eq!( + Model::ClaudeHaiku4_5.cross_region_inference_id("ap-south-1", true)?, + "global.anthropic.claude-haiku-4-5-20251001-v1:0" + ); + + // Test that regional prefix is used when allow_global is false + assert_eq!( + Model::ClaudeSonnet4.cross_region_inference_id("us-east-1", false)?, + "us.anthropic.claude-sonnet-4-20250514-v1:0" + ); + + Ok(()) +} diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index 9672d61f90512be62ea58e77682d63cc8553710f..e478c193a27a9e30301ae9233ea666c8160b25f5 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -71,6 +71,7 @@ pub struct AmazonBedrockSettings { pub profile_name: Option, pub role_arn: Option, pub authentication_method: Option, + pub allow_global: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, EnumIter, IntoStaticStr, JsonSchema)] @@ -239,6 +240,13 @@ impl State { .or(settings_region) .unwrap_or(String::from("us-east-1")) } + + fn get_allow_global(&self) -> bool { + self.settings + .as_ref() + .and_then(|s| s.allow_global) + .unwrap_or(false) + } } pub struct BedrockLanguageModelProvider { @@ -545,11 +553,13 @@ impl LanguageModel for BedrockModel { LanguageModelCompletionError, >, > { - let Ok(region) = cx.read_entity(&self.state, |state, _cx| state.get_region()) else { + let Ok((region, allow_global)) = cx.read_entity(&self.state, |state, _cx| { + (state.get_region(), state.get_allow_global()) + }) else { return async move { Err(anyhow::anyhow!("App State Dropped").into()) }.boxed(); }; - let model_id = match self.model.cross_region_inference_id(®ion) { + let model_id = match self.model.cross_region_inference_id(®ion, allow_global) { Ok(s) => s, Err(e) => { return async move { Err(e.into()) }.boxed(); diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index edff1f768e9fc6d2ad9333133b20d88c7676c24d..43a8e7334a744c84d6edfae3ffc97115eb8f51b2 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -58,6 +58,7 @@ impl settings::Settings for AllLanguageModelSettings { profile_name: bedrock.profile, role_arn: None, // todo(was never a setting for this...) authentication_method: bedrock.authentication_method.map(Into::into), + allow_global: bedrock.allow_global, }, deepseek: DeepSeekSettings { api_url: deepseek.api_url.unwrap(), diff --git a/crates/settings/src/settings_content/language_model.rs b/crates/settings/src/settings_content/language_model.rs index 0a746c1284c1d981fdf95745952baacc74548d04..48f5a463a4b8d896885d9ba5b7d804d16ecb5b6b 100644 --- a/crates/settings/src/settings_content/language_model.rs +++ b/crates/settings/src/settings_content/language_model.rs @@ -61,6 +61,7 @@ pub struct AmazonBedrockSettingsContent { pub region: Option, pub profile: Option, pub authentication_method: Option, + pub allow_global: Option, } #[with_fallible_options] diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 3e40d7ae0283b3dbd1c50ba1bef5ae410d969305..f13ece5d3eb6aac3af38a0046abddc474649f503 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -89,12 +89,32 @@ To do this: #### Cross-Region Inference -The Zed implementation of Amazon Bedrock uses [Cross-Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html) for all the models and region combinations that support it. +The Zed implementation of Amazon Bedrock uses [Cross-Region inference](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html) to improve availability and throughput. With Cross-Region inference, you can distribute traffic across multiple AWS Regions, enabling higher throughput. -For example, if you use `Claude Sonnet 3.7 Thinking` from `us-east-1`, it may be processed across the US regions, namely: `us-east-1`, `us-east-2`, or `us-west-2`. -Cross-Region inference requests are kept within the AWS Regions that are part of the geography where the data originally resides. -For example, a request made within the US is kept within the AWS Regions in the US. +##### Regional vs Global Inference Profiles + +Bedrock supports two types of cross-region inference profiles: + +- **Regional profiles** (default): Route requests within a specific geography (US, EU, APAC). For example, `us-east-1` uses the `us.*` profile which routes across `us-east-1`, `us-east-2`, and `us-west-2`. +- **Global profiles**: Route requests across all commercial AWS Regions for maximum availability and performance. + +By default, Zed uses **regional profiles** which keep your data within the same geography. You can opt into global profiles by adding `"allow_global": true` to your Bedrock configuration: + +```json [settings] +{ + "language_models": { + "bedrock": { + "authentication_method": "named_profile", + "region": "your-aws-region", + "profile": "your-profile-name", + "allow_global": true + } + } +} +``` + +**Note:** Only select newer models support global inference profiles. See the [AWS Bedrock supported models documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system) for the current list of models that support global inference. If you encounter availability issues with a model in your region, enabling `allow_global` may resolve them. Although the data remains stored only in the source Region, your input prompts and output results might move outside of your source Region during cross-Region inference. All data will be transmitted encrypted across Amazon's secure network. From 4ec2d04ad948f118b420bf00f74873ae058e043d Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:21:02 +0100 Subject: [PATCH 60/67] search: Fix sort order not being maintained in presence of open buffers (#44135) In project search UI code we were seeing an issue where "Go to next match" would act up and behave weirdly. It would not wrap at times. Stuff would be weird, yo. It turned out that match ranges reported by core project search were sometimes out of sync with the state of the multi-buffer. As in, the sort order of `search::ProjectSearch::match_ranges` would not match up with multi-buffer's sort order. This is ~because multi-buffers maintain their own sort order. What happened within project search is that we were skipping straight from stage 1 (filtering paths) to stage 3 via an internal channel and in the process we've dropped the channel used to maintain result sorting. This made is so that, given 2 files to scan: - project/file1.rs <- not open, has to go through stage2 (FS scan) - project/file2.rs <- open, goes straight from stage1 (path filtering) to stage3 (finding all matches) We would report matches for project/file2.rs first, because we would notice that there's an existing language::Buffer for it. However, we should wait for project/file1.rs status to be reported first before we kick off project/file2.rs The fix is to use the sorting channel instead of an internal one, as that keeps the sorting worker "in the loop" about the state of the world. Closes #43672 Co-authored-by: Smit Barmase Release Notes: - Fixed "Select next match" in project search results misbehaving when some of the buffers within the search results were open before search was ran. - Fixed project search results being scrolled to the last file active prior to running the search. --------- Co-authored-by: Smit Barmase Co-authored-by: Smit --- crates/project/src/project_search.rs | 29 ++++++---------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index d3e24b47b3eab20391dd390c9b6a21b3fc2a1981..90687f247338750b2c1197037576098281083e36 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -93,9 +93,6 @@ enum FindSearchCandidates { /// based on disk contents of a buffer. This step is not performed for buffers we already have in memory. confirm_contents_will_match_tx: Sender, confirm_contents_will_match_rx: Receiver, - /// Of those that contain at least one match (or are already in memory), look for rest of matches (and figure out their ranges). - /// But wait - first, we need to go back to the main thread to open a buffer (& create an entity for it). - get_buffer_for_full_scan_tx: Sender, }, Remote, OpenBuffersOnly, @@ -226,7 +223,7 @@ impl Search { .boxed_local(), cx.background_spawn(Self::maintain_sorted_search_results( sorted_search_results_rx, - get_buffer_for_full_scan_tx.clone(), + get_buffer_for_full_scan_tx, self.limit, )) .boxed_local(), @@ -234,7 +231,6 @@ impl Search { ( FindSearchCandidates::Local { fs, - get_buffer_for_full_scan_tx, confirm_contents_will_match_tx, confirm_contents_will_match_rx, input_paths_rx, @@ -593,7 +589,6 @@ impl Worker<'_> { input_paths_rx, confirm_contents_will_match_rx, mut confirm_contents_will_match_tx, - mut get_buffer_for_full_scan_tx, fs, ) = match self.candidates { FindSearchCandidates::Local { @@ -601,21 +596,15 @@ impl Worker<'_> { input_paths_rx, confirm_contents_will_match_rx, confirm_contents_will_match_tx, - get_buffer_for_full_scan_tx, } => ( input_paths_rx, confirm_contents_will_match_rx, confirm_contents_will_match_tx, - get_buffer_for_full_scan_tx, Some(fs), ), - FindSearchCandidates::Remote | FindSearchCandidates::OpenBuffersOnly => ( - unbounded().1, - unbounded().1, - unbounded().0, - unbounded().0, - None, - ), + FindSearchCandidates::Remote | FindSearchCandidates::OpenBuffersOnly => { + (unbounded().1, unbounded().1, unbounded().0, None) + } }; // WorkerA: grabs a request for "find all matches in file/a" <- takes 5 minutes // right after: WorkerB: grabs a request for "find all matches in file/b" <- takes 5 seconds @@ -629,7 +618,6 @@ impl Worker<'_> { open_entries: &self.open_buffers, fs: fs.as_deref(), confirm_contents_will_match_tx: &confirm_contents_will_match_tx, - get_buffer_for_full_scan_tx: &get_buffer_for_full_scan_tx, }; // Whenever we notice that some step of a pipeline is closed, we don't want to close subsequent // steps straight away. Another worker might be about to produce a value that will @@ -645,10 +633,7 @@ impl Worker<'_> { find_first_match = find_first_match.next() => { if let Some(buffer_with_at_least_one_match) = find_first_match { handler.handle_find_first_match(buffer_with_at_least_one_match).await; - } else { - get_buffer_for_full_scan_tx = bounded(1).0; } - }, scan_path = scan_path.next() => { if let Some(path_to_scan) = scan_path { @@ -673,7 +658,6 @@ struct RequestHandler<'worker> { fs: Option<&'worker dyn Fs>, open_entries: &'worker HashSet, confirm_contents_will_match_tx: &'worker Sender, - get_buffer_for_full_scan_tx: &'worker Sender, } impl RequestHandler<'_> { @@ -729,9 +713,8 @@ impl RequestHandler<'_> { _ = maybe!(async move { let InputPath { entry, - snapshot, - should_scan_tx, + mut should_scan_tx, } = req; if entry.is_fifo || !entry.is_file() { @@ -754,7 +737,7 @@ impl RequestHandler<'_> { if self.open_entries.contains(&entry.id) { // The buffer is already in memory and that's the version we want to scan; // hence skip the dilly-dally and look for all matches straight away. - self.get_buffer_for_full_scan_tx + should_scan_tx .send(ProjectPath { worktree_id: snapshot.id(), path: entry.path.clone(), From bad6bde03a1789b80ffc13c5d8c066746017b013 Mon Sep 17 00:00:00 2001 From: John Gibb <32365131+JPGibb@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:07:40 +0000 Subject: [PATCH 61/67] Use buffer language when formatting with Prettier (#43368) Set `prettier_parser` explicitly if the file extension for the buffer does not match a known one for the current language Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- crates/editor/src/editor_tests.rs | 103 ++++++++++++++++++++++++++++++ crates/prettier/src/prettier.rs | 78 +++++++++++++++++----- 2 files changed, 165 insertions(+), 16 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 011715804665563b9588da28bad3137120f9c4c3..64c335e2e4b0dc660efe1b28bb87984fba8aafb4 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -19095,6 +19095,109 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) { ); } +#[gpui::test] +async fn test_document_format_with_prettier_explicit_language(cx: &mut TestAppContext) { + init_test(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::Single(Formatter::Prettier)) + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_file(path!("/file.settings"), Default::default()) + .await; + + let project = Project::test(fs, [path!("/file.settings").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + + let ts_lang = Arc::new(Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..LanguageMatcher::default() + }, + prettier_parser_name: Some("typescript".to_string()), + ..LanguageConfig::default() + }, + Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), + )); + + language_registry.add(ts_lang.clone()); + + update_test_language_settings(cx, |settings| { + settings.defaults.prettier.get_or_insert_default().allowed = Some(true); + }); + + let test_plugin = "test_plugin"; + let _ = language_registry.register_fake_lsp( + "TypeScript", + FakeLspAdapter { + prettier_plugins: vec![test_plugin], + ..Default::default() + }, + ); + + let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX; + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/file.settings"), cx) + }) + .await + .unwrap(); + + project.update(cx, |project, cx| { + project.set_language_for_buffer(&buffer, ts_lang, cx) + }); + + let buffer_text = "one\ntwo\nthree\n"; + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + editor.update_in(cx, |editor, window, cx| { + editor.set_text(buffer_text, window, cx) + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }) + .unwrap() + .await; + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + prettier_format_suffix + "\ntypescript", + "Test prettier formatting was not applied to the original buffer text", + ); + + update_test_language_settings(cx, |settings| { + settings.defaults.formatter = Some(FormatterList::default()) + }); + let format = editor.update_in(cx, |editor, window, cx| { + editor.perform_format( + project.clone(), + FormatTrigger::Manual, + FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()), + window, + cx, + ) + }); + format.await.unwrap(); + + assert_eq!( + editor.update(cx, |editor, cx| editor.text(cx)), + buffer_text.to_string() + + prettier_format_suffix + + "\ntypescript\n" + + prettier_format_suffix + + "\ntypescript", + "Autoformatting (via test prettier) was not applied to the original buffer text", + ); +} + #[gpui::test] async fn test_addition_reverts(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/prettier/src/prettier.rs b/crates/prettier/src/prettier.rs index 381fdc2b2b35be53a0f07878c83cadd2862d06bf..bc4ce609a1fd39e4303c5fd048a0c8605b3a3ddc 100644 --- a/crates/prettier/src/prettier.rs +++ b/crates/prettier/src/prettier.rs @@ -2,7 +2,8 @@ use anyhow::Context as _; use collections::{HashMap, HashSet}; use fs::Fs; use gpui::{AsyncApp, Entity}; -use language::{Buffer, Diff, language_settings::language_settings}; +use language::language_settings::PrettierSettings; +use language::{Buffer, Diff, Language, language_settings::language_settings}; use lsp::{LanguageServer, LanguageServerId}; use node_runtime::NodeRuntime; use paths::default_prettier_dir; @@ -349,7 +350,7 @@ impl Prettier { Self::Real(local) => { let params = buffer .update(cx, |buffer, cx| { - let buffer_language = buffer.language(); + let buffer_language = buffer.language().map(|language| language.as_ref()); let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx); let prettier_settings = &language_settings.prettier; anyhow::ensure!( @@ -449,15 +450,7 @@ impl Prettier { }) .collect(); - let mut prettier_parser = prettier_settings.parser.as_deref(); - if buffer_path.is_none() { - prettier_parser = prettier_parser.or_else(|| buffer_language.and_then(|language| language.prettier_parser_name())); - if prettier_parser.is_none() { - log::error!("Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}"); - anyhow::bail!("Cannot determine prettier parser for unsaved file"); - } - - } + let parser = prettier_parser_name(buffer_path.as_deref(), buffer_language, prettier_settings).context("getting prettier parser")?; let ignore_path = ignore_dir.and_then(|dir| { let ignore_file = dir.join(".prettierignore"); @@ -475,15 +468,15 @@ impl Prettier { anyhow::Ok(FormatParams { text: buffer.text(), options: FormatOptions { - parser: prettier_parser.map(ToOwned::to_owned), - plugins, path: buffer_path, + parser, + plugins, prettier_options, ignore_path, }, }) - })? - .context("building prettier request")?; + })? + .context("building prettier request")?; let response = local .server @@ -503,7 +496,26 @@ impl Prettier { { Some("rust") => anyhow::bail!("prettier does not support Rust"), Some(_other) => { - let formatted_text = buffer.text() + FORMAT_SUFFIX; + let mut formatted_text = buffer.text() + FORMAT_SUFFIX; + + let buffer_language = + buffer.language().map(|language| language.as_ref()); + let language_settings = language_settings( + buffer_language.map(|l| l.name()), + buffer.file(), + cx, + ); + let prettier_settings = &language_settings.prettier; + let parser = prettier_parser_name( + buffer_path.as_deref(), + buffer_language, + prettier_settings, + )?; + + if let Some(parser) = parser { + formatted_text = format!("{formatted_text}\n{parser}"); + } + Ok(buffer.diff(formatted_text, cx)) } None => panic!("Should not format buffer without a language with prettier"), @@ -551,6 +563,40 @@ impl Prettier { } } +fn prettier_parser_name( + buffer_path: Option<&Path>, + buffer_language: Option<&Language>, + prettier_settings: &PrettierSettings, +) -> anyhow::Result> { + let parser = if buffer_path.is_none() { + let parser = prettier_settings + .parser + .as_deref() + .or_else(|| buffer_language.and_then(|language| language.prettier_parser_name())); + if parser.is_none() { + log::error!( + "Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}" + ); + anyhow::bail!("Cannot determine prettier parser for unsaved file"); + } + parser + } else if let (Some(buffer_language), Some(buffer_path)) = (buffer_language, buffer_path) + && buffer_path.extension().is_some_and(|extension| { + !buffer_language + .config() + .matcher + .path_suffixes + .contains(&extension.to_string_lossy().into_owned()) + }) + { + buffer_language.prettier_parser_name() + } else { + prettier_settings.parser.as_deref() + }; + + Ok(parser.map(ToOwned::to_owned)) +} + async fn has_prettier_in_node_modules(fs: &dyn Fs, path: &Path) -> anyhow::Result { let possible_node_modules_location = path.join("node_modules").join(PRETTIER_PACKAGE_NAME); if let Some(node_modules_location_metadata) = fs From 0d80b452fb8c986d8ff060a34047e88dcbf10f1a Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:33:13 +0100 Subject: [PATCH 62/67] python: Improve sorting order of toolchains to give higher precedence to project-local virtual environments that are within current subproject (#44141) Closes #44090 Co-authored-by: Smit Barmase Release Notes: - python: Improved sorting order of toolchains in monorepos with multiple local virtual environments. - python: Fixed toolchain selector not having an active toolchain selected on open. --------- Co-authored-by: Smit Barmase Co-authored-by: Smit --- crates/languages/src/python.rs | 41 ++++++++++++++---- .../src/toolchain_selector.rs | 24 ++++++----- crates/workspace/src/persistence.rs | 43 ------------------- 3 files changed, 45 insertions(+), 63 deletions(-) diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index db61d5902d3f18444988caa0596f998f61636cee..fc2f91121e96e0c0709b4d5e8d0666102ce9866d 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use settings::Settings; use smol::lock::OnceCell; -use std::cmp::Ordering; +use std::cmp::{Ordering, Reverse}; use std::env::consts; use terminal::terminal_settings::TerminalSettings; use util::command::new_smol_command; @@ -1101,13 +1101,33 @@ fn get_venv_parent_dir(env: &PythonEnvironment) -> Option { venv.parent().map(|parent| parent.to_path_buf()) } -fn wr_distance(wr: &PathBuf, venv: Option<&PathBuf>) -> usize { +// How far is this venv from the root of our current project? +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum SubprojectDistance { + WithinSubproject(Reverse), + WithinWorktree(Reverse), + NotInWorktree, +} + +fn wr_distance( + wr: &PathBuf, + subroot_relative_path: &RelPath, + venv: Option<&PathBuf>, +) -> SubprojectDistance { if let Some(venv) = venv && let Ok(p) = venv.strip_prefix(wr) { - p.components().count() + if subroot_relative_path.components().next().is_some() + && let Ok(distance) = p + .strip_prefix(subroot_relative_path.as_std_path()) + .map(|p| p.components().count()) + { + SubprojectDistance::WithinSubproject(Reverse(distance)) + } else { + SubprojectDistance::WithinWorktree(Reverse(p.components().count())) + } } else { - usize::MAX + SubprojectDistance::NotInWorktree } } @@ -1170,11 +1190,14 @@ impl ToolchainLister for PythonToolchainProvider { }); // Compare project paths against worktree root - let proj_ordering = || { - let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs)); - let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs)); - wr_distance(&wr, lhs_project.as_ref()).cmp(&wr_distance(&wr, rhs_project.as_ref())) - }; + let proj_ordering = + || { + let lhs_project = lhs.project.clone().or_else(|| get_venv_parent_dir(lhs)); + let rhs_project = rhs.project.clone().or_else(|| get_venv_parent_dir(rhs)); + wr_distance(&wr, &subroot_relative_path, lhs_project.as_ref()).cmp( + &wr_distance(&wr, &subroot_relative_path, rhs_project.as_ref()), + ) + }; // Compare environment priorities let priority_ordering = || env_priority(lhs.kind).cmp(&env_priority(rhs.kind)); diff --git a/crates/toolchain_selector/src/toolchain_selector.rs b/crates/toolchain_selector/src/toolchain_selector.rs index 96f692694dcf6b1adaa6494a4c1cbf6905c57c7c..138f99066f0a80188837de49f6afc67d91d9eeb5 100644 --- a/crates/toolchain_selector/src/toolchain_selector.rs +++ b/crates/toolchain_selector/src/toolchain_selector.rs @@ -588,19 +588,20 @@ impl ToolchainSelector { .worktree_for_id(worktree_id, cx)? .read(cx) .abs_path(); - let workspace_id = workspace.database_id()?; let weak = workspace.weak_handle(); cx.spawn_in(window, async move |workspace, cx| { - let active_toolchain = workspace::WORKSPACE_DB - .toolchain( - workspace_id, - worktree_id, - relative_path.clone(), - language_name.clone(), - ) - .await - .ok() - .flatten(); + let active_toolchain = project + .read_with(cx, |this, cx| { + this.active_toolchain( + ProjectPath { + worktree_id, + path: relative_path.clone(), + }, + language_name.clone(), + cx, + ) + })? + .await; workspace .update_in(cx, |this, window, cx| { this.toggle_modal(window, cx, move |window, cx| { @@ -618,6 +619,7 @@ impl ToolchainSelector { }); }) .ok(); + anyhow::Ok(()) }) .detach(); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 3d7ddf5d2ceae40f19e4684b63f6b33c8b53b280..824a9be90b6dc33094f854a3a9672db692e2b592 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1656,49 +1656,6 @@ impl WorkspaceDb { } } - pub async fn toolchain( - &self, - workspace_id: WorkspaceId, - worktree_id: WorktreeId, - relative_worktree_path: Arc, - language_name: LanguageName, - ) -> Result> { - self.write(move |this| { - let mut select = this - .select_bound(sql!( - SELECT - name, path, raw_json - FROM toolchains - WHERE - workspace_id = ? AND - language_name = ? AND - worktree_id = ? AND - relative_worktree_path = ? - )) - .context("select toolchain")?; - - let toolchain: Vec<(String, String, String)> = select(( - workspace_id, - language_name.as_ref().to_string(), - worktree_id.to_usize(), - relative_worktree_path.as_unix_str().to_string(), - ))?; - - Ok(toolchain - .into_iter() - .next() - .and_then(|(name, path, raw_json)| { - Some(Toolchain { - name: name.into(), - path: path.into(), - language_name, - as_json: serde_json::Value::from_str(&raw_json).ok()?, - }) - })) - }) - .await - } - pub(crate) async fn toolchains( &self, workspace_id: WorkspaceId, From 4c51fffbb59e89347d3bc7e7406f484773643aa6 Mon Sep 17 00:00:00 2001 From: Coenen Benjamin Date: Thu, 4 Dec 2025 14:23:36 +0100 Subject: [PATCH 63/67] Add support for git remotes (#42819) Follow up of #42486 Closes #26559 https://github.com/user-attachments/assets/e2f54dda-a78b-4d9b-a910-16d51f98a111 Release Notes: - Added support for git remotes --------- Signed-off-by: Benjamin <5719034+bnjjj@users.noreply.github.com> --- crates/collab/src/rpc.rs | 2 + crates/fs/src/fake_git_repo.rs | 43 +- crates/git/src/remote.rs | 3 +- crates/git/src/repository.rs | 70 +- crates/git_ui/src/branch_picker.rs | 1298 ++++++++++++++++++++++++---- crates/git_ui/src/git_panel.rs | 2 - crates/git_ui/src/remote_output.rs | 1 + crates/project/src/git_store.rs | 96 +- crates/proto/proto/git.proto | 13 + crates/proto/proto/zed.proto | 7 +- crates/proto/src/proto.rs | 6 + crates/zed_actions/src/lib.rs | 4 + 12 files changed, 1353 insertions(+), 192 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index aa77ba25bfb687b6c5cb0da84e14c843f8a2a3bc..9511087af8887a3c799357d06050ce48431b38a6 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -469,6 +469,8 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_message_handler(broadcast_project_message_from_host::) .add_message_handler(update_context) diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index b6beb9fc6ecb470b30c6ed4edca06be479db11c0..3bc411ff2d9b917fd409c29cca03d2191ee80978 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -50,6 +50,8 @@ pub struct FakeGitRepositoryState { pub blames: HashMap, pub current_branch_name: Option, pub branches: HashSet, + /// List of remotes, keys are names and values are URLs + pub remotes: HashMap, pub simulated_index_write_error_message: Option, pub refs: HashMap, } @@ -68,6 +70,7 @@ impl FakeGitRepositoryState { refs: HashMap::from_iter([("HEAD".into(), "abc".into())]), merge_base_contents: Default::default(), oids: Default::default(), + remotes: HashMap::default(), } } } @@ -432,8 +435,13 @@ impl GitRepository for FakeGitRepository { }) } - fn delete_branch(&self, _name: String) -> BoxFuture<'_, Result<()>> { - unimplemented!() + fn delete_branch(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + if !state.branches.remove(&name) { + bail!("no such branch: {name}"); + } + Ok(()) + }) } fn blame(&self, path: RepoPath, _content: Rope) -> BoxFuture<'_, Result> { @@ -598,15 +606,24 @@ impl GitRepository for FakeGitRepository { unimplemented!() } - fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { - unimplemented!() + fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + self.with_state_async(false, move |state| { + let remotes = state + .remotes + .keys() + .map(|r| Remote { + name: r.clone().into(), + }) + .collect::>(); + Ok(remotes) + }) } - fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { + fn get_push_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } - fn get_all_remotes(&self) -> BoxFuture<'_, Result>> { + fn get_branch_remote(&self, _branch: String) -> BoxFuture<'_, Result>> { unimplemented!() } @@ -683,6 +700,20 @@ impl GitRepository for FakeGitRepository { fn default_branch(&self) -> BoxFuture<'_, Result>> { async { Ok(Some("main".into())) }.boxed() } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.insert(name, url); + Ok(()) + }) + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.remotes.remove(&name); + Ok(()) + }) + } } #[cfg(test)] diff --git a/crates/git/src/remote.rs b/crates/git/src/remote.rs index e9814afc51a4a24fd154d74d0be2387c28c59fa3..8fb44839848278a3a698d7f2562741f682f38e24 100644 --- a/crates/git/src/remote.rs +++ b/crates/git/src/remote.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use std::sync::LazyLock; use derive_more::Deref; @@ -11,7 +12,7 @@ pub struct RemoteUrl(Url); static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z\-_]+@").expect("Failed to create USERNAME_REGEX")); -impl std::str::FromStr for RemoteUrl { +impl FromStr for RemoteUrl { type Err = url::ParseError; fn from_str(input: &str) -> Result { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index e49b1715901f3dcc463bee0e7870d69073fa0561..f79bade2d6bc12553b173c4f4e86989a961e6d31 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -7,13 +7,15 @@ use collections::HashMap; use futures::future::BoxFuture; use futures::io::BufWriter; use futures::{AsyncWriteExt, FutureExt as _, select_biased}; -use git2::BranchType; +use git2::{BranchType, ErrorCode}; use gpui::{AppContext as _, AsyncApp, BackgroundExecutor, SharedString, Task}; use parking_lot::Mutex; use rope::Rope; use schemars::JsonSchema; use serde::Deserialize; use smol::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; + +use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::process::{ExitStatus, Stdio}; use std::{ @@ -55,6 +57,12 @@ impl Branch { self.ref_name.starts_with("refs/remotes/") } + pub fn remote_name(&self) -> Option<&str> { + self.ref_name + .strip_prefix("refs/remotes/") + .and_then(|stripped| stripped.split("/").next()) + } + pub fn tracking_status(&self) -> Option { self.upstream .as_ref() @@ -590,6 +598,10 @@ pub trait GitRepository: Send + Sync { fn get_all_remotes(&self) -> BoxFuture<'_, Result>>; + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>>; + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>>; + /// returns a list of remote branches that contain HEAD fn check_for_pushed_commit(&self) -> BoxFuture<'_, Result>>; @@ -1385,9 +1397,19 @@ impl GitRepository for RealGitRepository { branch } else if let Ok(revision) = repo.find_branch(&name, BranchType::Remote) { let (_, branch_name) = name.split_once("/").context("Unexpected branch format")?; + let revision = revision.get(); let branch_commit = revision.peel_to_commit()?; - let mut branch = repo.branch(&branch_name, &branch_commit, false)?; + let mut branch = match repo.branch(&branch_name, &branch_commit, false) { + Ok(branch) => branch, + Err(err) if err.code() == ErrorCode::Exists => { + repo.find_branch(&branch_name, BranchType::Local)? + } + Err(err) => { + return Err(err.into()); + } + }; + branch.set_upstream(Some(&name))?; branch } else { @@ -1403,7 +1425,6 @@ impl GitRepository for RealGitRepository { self.executor .spawn(async move { let branch = branch.await?; - GitBinary::new(git_binary_path, working_directory?, executor) .run(&["checkout", &branch]) .await?; @@ -1993,7 +2014,7 @@ impl GitRepository for RealGitRepository { let working_directory = working_directory?; let output = new_smol_command(&git_binary_path) .current_dir(&working_directory) - .args(["remote"]) + .args(["remote", "-v"]) .output() .await?; @@ -2002,14 +2023,43 @@ impl GitRepository for RealGitRepository { "Failed to get all remotes:\n{}", String::from_utf8_lossy(&output.stderr) ); - let remote_names = String::from_utf8_lossy(&output.stdout) - .split('\n') - .filter(|name| !name.is_empty()) - .map(|name| Remote { - name: name.trim().to_string().into(), + let remote_names: HashSet = String::from_utf8_lossy(&output.stdout) + .lines() + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let mut split_line = line.split_whitespace(); + let remote_name = split_line.next()?; + + Some(Remote { + name: remote_name.trim().to_string().into(), + }) }) .collect(); - Ok(remote_names) + + Ok(remote_names.into_iter().collect()) + }) + .boxed() + } + + fn remove_remote(&self, name: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote_delete(&name)?; + + Ok(()) + }) + .boxed() + } + + fn create_remote(&self, name: String, url: String) -> BoxFuture<'_, Result<()>> { + let repo = self.repository.clone(); + self.executor + .spawn(async move { + let repo = repo.lock(); + repo.remote(&name, url.as_ref())?; + Ok(()) }) .boxed() } diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 92c2f92ca342be270aa25f9e1a7ee96f5e06a585..42e043cada2813126af3489c9769aca9c675999f 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1,10 +1,12 @@ use anyhow::Context as _; +use editor::Editor; use fuzzy::StringMatchCandidate; use collections::HashSet; use git::repository::Branch; +use gpui::http_client::Url; use gpui::{ - Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; @@ -14,7 +16,10 @@ use project::project_settings::ProjectSettings; use settings::Settings; use std::sync::Arc; use time::OffsetDateTime; -use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{ + CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, + prelude::*, +}; use util::ResultExt; use workspace::notifications::DetachAndPromptErr; use workspace::{ModalView, Workspace}; @@ -24,8 +29,10 @@ use crate::{branch_picker, git_panel::show_error_toast}; actions!( branch_picker, [ - /// Deletes the selected git branch. - DeleteBranch + /// Deletes the selected git branch or remote. + DeleteBranch, + /// Filter the list of remotes + FilterRemotes ] ); @@ -206,7 +213,7 @@ impl BranchList { .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers) } - fn handle_delete_branch( + fn handle_delete( &mut self, _: &branch_picker::DeleteBranch, window: &mut Window, @@ -215,9 +222,32 @@ impl BranchList { self.picker.update(cx, |picker, cx| { picker .delegate - .delete_branch_at(picker.delegate.selected_index, window, cx) + .delete_at(picker.delegate.selected_index, window, cx) }) } + + fn handle_filter( + &mut self, + _: &branch_picker::FilterRemotes, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |this, cx| { + this.delegate.display_remotes = !this.delegate.display_remotes; + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |picker, window, cx| { + let last_query = picker.delegate.last_query.clone(); + picker.delegate.update_matches(last_query, window, cx) + })? + .await; + + Result::Ok::<_, anyhow::Error>(()) + }) + .detach_and_log_err(cx); + }); + + cx.notify(); + } } impl ModalView for BranchList {} impl EventEmitter for BranchList {} @@ -234,7 +264,8 @@ impl Render for BranchList { .key_context("GitBranchSelector") .w(self.width) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) - .on_action(cx.listener(Self::handle_delete_branch)) + .on_action(cx.listener(Self::handle_delete)) + .on_action(cx.listener(Self::handle_filter)) .child(self.picker.clone()) .on_mouse_down_out({ cx.listener(move |this, _, window, cx| { @@ -246,16 +277,50 @@ impl Render for BranchList { } } -#[derive(Debug, Clone)] -struct BranchEntry { - branch: Branch, - positions: Vec, - is_new: bool, +#[derive(Debug, Clone, PartialEq)] +enum Entry { + Branch { + branch: Branch, + positions: Vec, + }, + NewUrl { + url: String, + }, + NewBranch { + name: String, + }, +} + +impl Entry { + fn as_branch(&self) -> Option<&Branch> { + match self { + Entry::Branch { branch, .. } => Some(branch), + _ => None, + } + } + + fn name(&self) -> &str { + match self { + Entry::Branch { branch, .. } => branch.name(), + Entry::NewUrl { url, .. } => url.as_str(), + Entry::NewBranch { name, .. } => name.as_str(), + } + } + + #[cfg(test)] + fn is_new_url(&self) -> bool { + matches!(self, Self::NewUrl { .. }) + } + + #[cfg(test)] + fn is_new_branch(&self) -> bool { + matches!(self, Self::NewBranch { .. }) + } } pub struct BranchListDelegate { workspace: Option>, - matches: Vec, + matches: Vec, all_branches: Option>, default_branch: Option, repo: Option>, @@ -263,9 +328,24 @@ pub struct BranchListDelegate { selected_index: usize, last_query: String, modifiers: Modifiers, + display_remotes: bool, + state: PickerState, + loading: bool, focus_handle: FocusHandle, } +#[derive(Debug)] +enum PickerState { + /// When we display list of branches/remotes + List, + /// When we set an url to create a new remote + NewRemote, + /// When we confirm the new remote url (after NewRemote) + CreateRemote(SharedString), + /// When we set a new branch to create + NewBranch, +} + impl BranchListDelegate { fn new( workspace: Option>, @@ -283,6 +363,9 @@ impl BranchListDelegate { selected_index: 0, last_query: Default::default(), modifiers: Default::default(), + display_remotes: false, + state: PickerState::List, + loading: false, focus_handle: cx.focus_handle(), } } @@ -313,8 +396,59 @@ impl BranchListDelegate { cx.emit(DismissEvent); } - fn delete_branch_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { - let Some(branch_entry) = self.matches.get(idx) else { + fn create_remote( + &self, + remote_name: String, + remote_url: String, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(repo) = self.repo.clone() else { + return; + }; + cx.spawn(async move |this, cx| { + this.update(cx, |picker, cx| { + picker.delegate.loading = true; + cx.notify(); + }) + .log_err(); + + let stop_loader = |this: &WeakEntity>, cx: &mut AsyncApp| { + this.update(cx, |picker, cx| { + picker.delegate.loading = false; + cx.notify(); + }) + .log_err(); + }; + repo.update(cx, |repo, _| repo.create_remote(remote_name, remote_url)) + .inspect_err(|_err| { + stop_loader(&this, cx); + })? + .await + .inspect_err(|_err| { + stop_loader(&this, cx); + })? + .inspect_err(|_err| { + stop_loader(&this, cx); + })?; + stop_loader(&this, cx); + Ok(()) + }) + .detach_and_prompt_err("Failed to create remote", window, cx, |e, _, _cx| { + Some(e.to_string()) + }); + cx.emit(DismissEvent); + } + + fn loader(&self) -> AnyElement { + Icon::new(IconName::LoadCircle) + .size(IconSize::Small) + .with_rotate_animation(3) + .into_any_element() + } + + fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context>) { + let Some(entry) = self.matches.get(idx).cloned() else { return; }; let Some(repo) = self.repo.clone() else { @@ -322,20 +456,51 @@ impl BranchListDelegate { }; let workspace = self.workspace.clone(); - let branch_name = branch_entry.branch.name().to_string(); - let branch_ref = branch_entry.branch.ref_name.clone(); cx.spawn_in(window, async move |picker, cx| { - let result = repo - .update(cx, |repo, _| repo.delete_branch(branch_name.clone()))? - .await?; + let mut is_remote = false; + let result = match &entry { + Entry::Branch { branch, .. } => match branch.remote_name() { + Some(remote_name) => { + is_remote = true; + repo.update(cx, |repo, _| repo.remove_remote(remote_name.to_string()))? + .await? + } + None => { + repo.update(cx, |repo, _| repo.delete_branch(branch.name().to_string()))? + .await? + } + }, + _ => { + log::error!("Failed to delete remote: wrong entry to delete"); + return Ok(()); + } + }; if let Err(e) = result { - log::error!("Failed to delete branch: {}", e); + if is_remote { + log::error!("Failed to delete remote: {}", e); + } else { + log::error!("Failed to delete branch: {}", e); + } if let Some(workspace) = workspace.and_then(|w| w.upgrade()) { cx.update(|_window, cx| { - show_error_toast(workspace, format!("branch -d {branch_name}"), e, cx) + if is_remote { + show_error_toast( + workspace, + format!("remote remove {}", entry.name()), + e, + cx, + ) + } else { + show_error_toast( + workspace, + format!("branch -d {}", entry.name()), + e, + cx, + ) + } })?; } @@ -343,13 +508,12 @@ impl BranchListDelegate { } picker.update_in(cx, |picker, _, cx| { - picker - .delegate - .matches - .retain(|entry| entry.branch.ref_name != branch_ref); + picker.delegate.matches.retain(|e| e != &entry); - if let Some(all_branches) = &mut picker.delegate.all_branches { - all_branches.retain(|branch| branch.ref_name != branch_ref); + if let Entry::Branch { branch, .. } = &entry { + if let Some(all_branches) = &mut picker.delegate.all_branches { + all_branches.retain(|e| e.ref_name != branch.ref_name); + } } if picker.delegate.matches.is_empty() { @@ -374,6 +538,45 @@ impl PickerDelegate for BranchListDelegate { "Select branch…".into() } + fn render_editor( + &self, + editor: &Entity, + window: &mut Window, + cx: &mut Context>, + ) -> Div { + cx.update_entity(editor, move |editor, cx| { + let placeholder = match self.state { + PickerState::List | PickerState::NewRemote | PickerState::NewBranch => { + if self.display_remotes { + "Select remote…" + } else { + "Select branch…" + } + } + PickerState::CreateRemote(_) => "Choose a name…", + }; + editor.set_placeholder_text(placeholder, window, cx); + }); + + v_flex() + .when( + self.editor_position() == PickerEditorPosition::End, + |this| this.child(Divider::horizontal()), + ) + .child( + h_flex() + .overflow_hidden() + .flex_none() + .h_9() + .px_2p5() + .child(editor.clone()), + ) + .when( + self.editor_position() == PickerEditorPosition::Start, + |this| this.child(Divider::horizontal()), + ) + } + fn editor_position(&self) -> PickerEditorPosition { match self.style { BranchListStyle::Modal => PickerEditorPosition::Start, @@ -409,20 +612,36 @@ impl PickerDelegate for BranchListDelegate { }; const RECENT_BRANCHES_COUNT: usize = 10; + let display_remotes = self.display_remotes; cx.spawn_in(window, async move |picker, cx| { - let mut matches: Vec = if query.is_empty() { + let mut matches: Vec = if query.is_empty() { all_branches .into_iter() - .filter(|branch| !branch.is_remote()) + .filter(|branch| { + if display_remotes { + branch.is_remote() + } else { + !branch.is_remote() + } + }) .take(RECENT_BRANCHES_COUNT) - .map(|branch| BranchEntry { + .map(|branch| Entry::Branch { branch, positions: Vec::new(), - is_new: false, }) .collect() } else { - let candidates = all_branches + let branches = all_branches + .iter() + .filter(|branch| { + if display_remotes { + branch.is_remote() + } else { + !branch.is_remote() + } + }) + .collect::>(); + let candidates = branches .iter() .enumerate() .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name())) @@ -438,31 +657,40 @@ impl PickerDelegate for BranchListDelegate { ) .await .into_iter() - .map(|candidate| BranchEntry { - branch: all_branches[candidate.candidate_id].clone(), + .map(|candidate| Entry::Branch { + branch: branches[candidate.candidate_id].clone(), positions: candidate.positions, - is_new: false, }) .collect() }; picker .update(cx, |picker, _| { + if matches!(picker.delegate.state, PickerState::CreateRemote(_)) { + picker.delegate.last_query = query; + picker.delegate.matches = Vec::new(); + picker.delegate.selected_index = 0; + + return; + } + if !query.is_empty() - && !matches - .first() - .is_some_and(|entry| entry.branch.name() == query) + && !matches.first().is_some_and(|entry| entry.name() == query) { let query = query.replace(' ', "-"); - matches.push(BranchEntry { - branch: Branch { - ref_name: format!("refs/heads/{query}").into(), - is_head: false, - upstream: None, - most_recent_commit: None, - }, - positions: Vec::new(), - is_new: true, - }) + let is_url = query.trim_start_matches("git@").parse::().is_ok(); + let entry = if is_url { + Entry::NewUrl { url: query } + } else { + Entry::NewBranch { name: query } + }; + picker.delegate.state = if is_url { + PickerState::NewRemote + } else { + PickerState::NewBranch + }; + matches.push(entry); + } else { + picker.delegate.state = PickerState::List; } let delegate = &mut picker.delegate; delegate.matches = matches; @@ -479,56 +707,78 @@ impl PickerDelegate for BranchListDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - let Some(entry) = self.matches.get(self.selected_index()) else { - return; - }; - - if entry.is_new { - let from_branch = if secondary { - self.default_branch.clone() - } else { - None - }; - self.create_branch( - from_branch, - entry.branch.name().to_owned().into(), - window, - cx, - ); - return; - } - - let current_branch = self.repo.as_ref().map(|repo| { - repo.read_with(cx, |repo, _| { - repo.branch.as_ref().map(|branch| branch.ref_name.clone()) - }) - }); - - if current_branch - .flatten() - .is_some_and(|current_branch| current_branch == entry.branch.ref_name) - { - cx.emit(DismissEvent); + if let PickerState::CreateRemote(remote_url) = &self.state { + self.create_remote(self.last_query.clone(), remote_url.to_string(), window, cx); + self.state = PickerState::List; + cx.notify(); return; } - let Some(repo) = self.repo.clone() else { + let Some(entry) = self.matches.get(self.selected_index()) else { return; }; - let branch = entry.branch.clone(); - cx.spawn(async move |_, cx| { - repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? - .await??; + match entry { + Entry::Branch { branch, .. } => { + let current_branch = self.repo.as_ref().map(|repo| { + repo.read_with(cx, |repo, _| { + repo.branch.as_ref().map(|branch| branch.ref_name.clone()) + }) + }); + + if current_branch + .flatten() + .is_some_and(|current_branch| current_branch == branch.ref_name) + { + cx.emit(DismissEvent); + return; + } - anyhow::Ok(()) - }) - .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None); + let Some(repo) = self.repo.clone() else { + return; + }; + + let branch = branch.clone(); + cx.spawn(async move |_, cx| { + repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))? + .await??; + + anyhow::Ok(()) + }) + .detach_and_prompt_err( + "Failed to change branch", + window, + cx, + |_, _, _| None, + ); + } + Entry::NewUrl { url } => { + self.state = PickerState::CreateRemote(url.clone().into()); + self.matches = Vec::new(); + self.selected_index = 0; + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |picker, window, cx| { + picker.set_query("", window, cx); + }) + }) + .detach_and_log_err(cx); + cx.notify(); + } + Entry::NewBranch { name } => { + let from_branch = if secondary { + self.default_branch.clone() + } else { + None + }; + self.create_branch(from_branch, format!("refs/heads/{name}").into(), window, cx); + } + } cx.emit(DismissEvent); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { + self.state = PickerState::List; cx.emit(DismissEvent); } @@ -542,49 +792,60 @@ impl PickerDelegate for BranchListDelegate { let entry = &self.matches.get(ix)?; let (commit_time, author_name, subject) = entry - .branch - .most_recent_commit - .as_ref() - .map(|commit| { - let subject = commit.subject.clone(); - let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) - .unwrap_or_else(|_| OffsetDateTime::now_utc()); - let local_offset = - time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); - let formatted_time = time_format::format_localized_timestamp( - commit_time, - OffsetDateTime::now_utc(), - local_offset, - time_format::TimestampFormat::Relative, - ); - let author = commit.author_name.clone(); - (Some(formatted_time), Some(author), Some(subject)) + .as_branch() + .and_then(|branch| { + branch.most_recent_commit.as_ref().map(|commit| { + let subject = commit.subject.clone(); + let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) + .unwrap_or_else(|_| OffsetDateTime::now_utc()); + let local_offset = + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let formatted_time = time_format::format_localized_timestamp( + commit_time, + OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::Relative, + ); + let author = commit.author_name.clone(); + (Some(formatted_time), Some(author), Some(subject)) + }) }) .unwrap_or_else(|| (None, None, None)); - let icon = if let Some(default_branch) = self.default_branch.clone() - && entry.is_new - { - Some( - IconButton::new("branch-from-default", IconName::GitBranchAlt) + let icon = if let Some(default_branch) = self.default_branch.clone() { + let icon = match &entry { + Entry::Branch { .. } => Some(( + IconName::GitBranchAlt, + format!("Create branch based off default: {default_branch}"), + )), + Entry::NewUrl { url } => { + Some((IconName::Screen, format!("Create remote based off {url}"))) + } + Entry::NewBranch { .. } => None, + }; + + icon.map(|(icon, tooltip_text)| { + IconButton::new("branch-from-default", icon) .on_click(cx.listener(move |this, _, window, cx| { this.delegate.set_selected_index(ix, window, cx); this.delegate.confirm(true, window, cx); })) .tooltip(move |_window, cx| { - Tooltip::for_action( - format!("Create branch based off default: {default_branch}"), - &menu::SecondaryConfirm, - cx, - ) - }), - ) + Tooltip::for_action(tooltip_text.clone(), &menu::SecondaryConfirm, cx) + }) + }) } else { None }; - let branch_name = if entry.is_new { - h_flex() + let icon_element = if self.display_remotes { + Icon::new(IconName::Screen) + } else { + Icon::new(IconName::GitBranchAlt) + }; + + let entry_name = match entry { + Entry::NewUrl { .. } => h_flex() .gap_1() .child( Icon::new(IconName::Plus) @@ -592,19 +853,31 @@ impl PickerDelegate for BranchListDelegate { .color(Color::Muted), ) .child( - Label::new(format!("Create branch \"{}\"…", entry.branch.name())) + Label::new("Create remote repository".to_string()) .single_line() .truncate(), ) - .into_any_element() - } else { - h_flex() - .max_w_48() + .into_any_element(), + Entry::NewBranch { name } => h_flex() + .gap_1() .child( - HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone()) + Icon::new(IconName::Plus) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(format!("Create branch \"{name}\"…")) + .single_line() .truncate(), ) - .into_any_element() + .into_any_element(), + Entry::Branch { branch, positions } => h_flex() + .max_w_48() + .child(h_flex().mr_1().child(icon_element)) + .child( + HighlightedLabel::new(branch.name().to_string(), positions.clone()).truncate(), + ) + .into_any_element(), }; Some( @@ -613,11 +886,14 @@ impl PickerDelegate for BranchListDelegate { .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .tooltip({ - let branch_name = entry.branch.name().to_string(); - if entry.is_new { - Tooltip::text(format!("Create branch \"{}\"", branch_name)) - } else { - Tooltip::text(branch_name) + match entry { + Entry::Branch { branch, .. } => Tooltip::text(branch.name().to_string()), + Entry::NewUrl { .. } => { + Tooltip::text("Create remote repository".to_string()) + } + Entry::NewBranch { name } => { + Tooltip::text(format!("Create branch \"{name}\"")) + } } }) .child( @@ -629,7 +905,7 @@ impl PickerDelegate for BranchListDelegate { .gap_6() .justify_between() .overflow_x_hidden() - .child(branch_name) + .child(entry_name) .when_some(commit_time, |label, commit_time| { label.child( Label::new(commit_time) @@ -641,30 +917,35 @@ impl PickerDelegate for BranchListDelegate { ) .when(self.style == BranchListStyle::Modal, |el| { el.child(div().max_w_96().child({ - let message = if entry.is_new { - if let Some(current_branch) = - self.repo.as_ref().and_then(|repo| { - repo.read(cx).branch.as_ref().map(|b| b.name()) - }) - { - format!("based off {}", current_branch) - } else { - "based off the current branch".to_string() - } - } else { - let show_author_name = ProjectSettings::get_global(cx) - .git - .branch_picker - .show_author_name; - - subject.map_or("no commits found".into(), |subject| { - if show_author_name && author_name.is_some() { - format!("{} • {}", author_name.unwrap(), subject) + let message = match entry { + Entry::NewUrl { url } => format!("based off {url}"), + Entry::NewBranch { .. } => { + if let Some(current_branch) = + self.repo.as_ref().and_then(|repo| { + repo.read(cx).branch.as_ref().map(|b| b.name()) + }) + { + format!("based off {}", current_branch) } else { - subject.to_string() + "based off the current branch".to_string() } - }) + } + Entry::Branch { .. } => { + let show_author_name = ProjectSettings::get_global(cx) + .git + .branch_picker + .show_author_name; + + subject.map_or("no commits found".into(), |subject| { + if show_author_name && author_name.is_some() { + format!("{} • {}", author_name.unwrap(), subject) + } else { + subject.to_string() + } + }) + } }; + Label::new(message) .size(LabelSize::Small) .truncate() @@ -676,40 +957,715 @@ impl PickerDelegate for BranchListDelegate { ) } - fn render_footer( + fn render_header( &self, _window: &mut Window, cx: &mut Context>, ) -> Option { - let focus_handle = self.focus_handle.clone(); - + if matches!( + self.state, + PickerState::CreateRemote(_) | PickerState::NewRemote | PickerState::NewBranch + ) { + return None; + } + let label = if self.display_remotes { + "Remote" + } else { + "Local" + }; Some( h_flex() .w_full() .p_1p5() - .gap_0p5() - .justify_end() + .gap_1() .border_t_1() .border_color(cx.theme().colors().border_variant) - .child( - Button::new("delete-branch", "Delete") - .key_binding( - KeyBinding::for_action_in( - &branch_picker::DeleteBranch, - &focus_handle, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), - ) - .on_click(|_, window, cx| { - window.dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); - }), - ) + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) .into_any(), ) } + fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let focus_handle = self.focus_handle.clone(); + + if self.loading { + return Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(self.loader()) + .into_any(), + ); + } + match self.state { + PickerState::List => Some( + h_flex() + .w_full() + .p_1p5() + .gap_0p5() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child( + Button::new("filter-remotes", "Filter remotes") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::FilterRemotes, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_click, window, cx| { + window.dispatch_action( + branch_picker::FilterRemotes.boxed_clone(), + cx, + ); + }) + .disabled(self.loading) + .style(ButtonStyle::Subtle) + .toggle_state(self.display_remotes), + ) + .child( + Button::new("delete-branch", "Delete") + .key_binding( + KeyBinding::for_action_in( + &branch_picker::DeleteBranch, + &focus_handle, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .disabled(self.loading) + .on_click(|_, window, cx| { + window + .dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx); + }), + ) + .when(self.loading, |this| this.child(self.loader())) + .into_any(), + ), + PickerState::CreateRemote(_) => Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Label::new("Choose a name for this remote repository") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + h_flex().w_full().justify_end().child( + Label::new("Save") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .into_any(), + ), + PickerState::NewRemote | PickerState::NewBranch => None, + } + } + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { None } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + use git::repository::{CommitSummary, Remote}; + use gpui::{TestAppContext, VisualTestContext}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + } + + fn create_test_branch( + name: &str, + is_head: bool, + remote_name: Option<&str>, + timestamp: Option, + ) -> Branch { + let ref_name = match remote_name { + Some(remote_name) => format!("refs/remotes/{remote_name}/{name}"), + None => format!("refs/heads/{name}"), + }; + + Branch { + is_head, + ref_name: ref_name.into(), + upstream: None, + most_recent_commit: timestamp.map(|ts| CommitSummary { + sha: "abc123".into(), + commit_timestamp: ts, + author_name: "Test Author".into(), + subject: "Test commit".into(), + has_parent: true, + }), + } + } + + fn create_test_branches() -> Vec { + vec![ + create_test_branch("main", true, None, Some(1000)), + create_test_branch("feature-auth", false, None, Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ] + } + + fn init_branch_list_test( + cx: &mut TestAppContext, + repository: Option>, + branches: Vec, + ) -> (VisualTestContext, Entity) { + let window = cx.add_window(|window, cx| { + let mut delegate = + BranchListDelegate::new(None, repository, BranchListStyle::Modal, cx); + delegate.all_branches = Some(branches); + let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle.clone(); + }); + + let _subscription = cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }); + + BranchList { + picker, + picker_focus_handle, + width: rems(34.), + _subscription, + } + }); + + let branch_list = window.root(cx).unwrap(); + let cx = VisualTestContext::from_window(*window, cx); + + (cx, branch_list) + } + + async fn init_fake_repository(cx: &mut TestAppContext) -> Entity { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + ".git": {}, + "file.txt": "buffer_text".to_string() + }), + ) + .await; + fs.set_head_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "test".to_string())], + "deadbeef", + ); + fs.set_index_for_repo( + path!("/dir/.git").as_ref(), + &[("file.txt", "index_text".to_string())], + ); + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let repository = cx.read(|cx| project.read(cx).active_repository(cx)); + + repository.unwrap() + } + + #[gpui::test] + async fn test_update_branch_matches_with_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = create_test_branches(); + let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + let query = "feature".to_string(); + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 2 existing branches + 1 "create new branch" entry = 3 total + assert_eq!(picker.delegate.matches.len(), 3); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-auth") + ); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "feature-ui") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + async fn update_branch_list_matches_with_empty_query( + branch_list: &Entity, + cx: &mut VisualTestContext, + ) { + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.update_matches(String::new(), window, cx) + }) + }) + .await; + cx.run_until_parked(); + } + + #[gpui::test] + async fn test_delete_branch(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + + let branches = create_test_branches(); + + let branch_names = branches + .iter() + .map(|branch| branch.name().to_string()) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in branch_names { + repo.update(&mut cx, |repo, _| repo.create_branch(branch, None)) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["main", "feature-auth", "feature-ui", "develop"] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_delete_remote(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("origin"), Some(900)), + create_test_branch("feature-ui", false, Some("fork"), Some(800)), + create_test_branch("develop", false, Some("private"), Some(700)), + ]; + + let remote_names = branches + .iter() + .filter_map(|branch| branch.remote_name().map(|r| r.to_string())) + .collect::>(); + let repo = repository.clone(); + cx.spawn(async move |mut cx| { + for branch in remote_names { + repo.update(&mut cx, |repo, _| { + repo.create_remote(branch, String::from("test")) + }) + .unwrap() + .await + .unwrap() + .unwrap(); + } + }) + .await; + cx.run_until_parked(); + + let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let cx = &mut ctx; + // Enable remote filter + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + picker.delegate.display_remotes = true; + }); + }); + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + // Check matches, it should match all existing branches and no option to create new branch + let branch_to_delete = branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 4); + let branch_to_delete = picker.delegate.matches.get(1).unwrap().name().to_string(); + picker.delegate.delete_at(1, window, cx); + branch_to_delete + }) + }); + cx.run_until_parked(); + + // Check matches, it should match one less branch than before + branch_list.update(cx, move |branch_list, cx| { + branch_list.picker.update(cx, move |picker, _cx| { + assert_eq!(picker.delegate.matches.len(), 3); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + [ + "origin/main", + "origin/feature-auth", + "fork/feature-ui", + "private/develop" + ] + .into_iter() + .filter(|name| name != &branch_to_delete) + .collect::>() + ); + }) + }); + } + + #[gpui::test] + async fn test_update_remote_matches_with_query(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![ + create_test_branch("main", true, Some("origin"), Some(1000)), + create_test_branch("feature-auth", false, Some("fork"), Some(900)), + create_test_branch("feature-ui", false, None, Some(800)), + create_test_branch("develop", false, None, Some(700)), + ]; + + let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let cx = &mut ctx; + + update_branch_list_matches_with_empty_query(&branch_list, cx).await; + + // Check matches, it should match all existing branches and no option to create new branch + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 2); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["feature-ui", "develop"] + .into_iter() + .collect::>() + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_branch()); + assert!(!last_match.is_new_url()); + picker.delegate.display_remotes = true; + picker.delegate.update_matches(String::new(), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + assert_eq!(picker.delegate.matches.len(), 2); + let branches = picker + .delegate + .matches + .iter() + .map(|be| be.name()) + .collect::>(); + assert_eq!( + branches, + ["origin/main", "fork/feature-auth"] + .into_iter() + .collect::>() + ); + + // Verify the last entry is NOT the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(!last_match.is_new_url()); + picker.delegate.display_remotes = true; + picker + .delegate + .update_matches(String::from("fork"), window, cx) + }) + }) + .await; + cx.run_until_parked(); + + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "fork/feature-auth") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } + + #[gpui::test] + async fn test_new_branch_creation_with_query(test_cx: &mut TestAppContext) { + init_test(test_cx); + let repository = init_fake_repository(test_cx).await; + + let branches = vec![ + create_test_branch("main", true, None, Some(1000)), + create_test_branch("feature", false, None, Some(900)), + ]; + + let (mut ctx, branch_list) = init_branch_list_test(test_cx, repository.into(), branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "new-feature-branch".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + assert_eq!(last_match.name(), "new-feature-branch"); + assert!(matches!(picker.delegate.state, PickerState::NewBranch)); + picker.delegate.confirm(false, window, cx); + }) + }); + cx.run_until_parked(); + + let branches = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.branches()) + }) + }) + .await + .unwrap() + .unwrap(); + + assert!( + branches + .into_iter() + .any(|branch| branch.name() == "new-feature-branch") + ); + } + + #[gpui::test] + async fn test_remote_url_detection_https(cx: &mut TestAppContext) { + init_test(cx); + let repository = init_fake_repository(cx).await; + let branches = vec![create_test_branch("main", true, None, Some(1000))]; + + let (mut ctx, branch_list) = init_branch_list_test(cx, repository.into(), branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_url()); + assert!(matches!(picker.delegate.state, PickerState::NewRemote)); + picker.delegate.confirm(false, window, cx); + assert_eq!(picker.delegate.matches.len(), 0); + if let PickerState::CreateRemote(remote_url) = &picker.delegate.state + && remote_url.as_ref() == "https://github.com/user/repo.git" + { + } else { + panic!("wrong picker state"); + } + picker + .delegate + .update_matches("my_new_remote".to_string(), window, cx) + }) + }) + .await; + + cx.run_until_parked(); + + branch_list.update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.confirm(false, window, cx); + assert_eq!(picker.delegate.matches.len(), 0); + }) + }); + cx.run_until_parked(); + + // List remotes + let remotes = branch_list + .update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker + .delegate + .repo + .as_ref() + .unwrap() + .update(cx, |repo, _cx| repo.get_remotes(None, false)) + }) + }) + .await + .unwrap() + .unwrap(); + assert_eq!( + remotes, + vec![Remote { + name: SharedString::from("my_new_remote".to_string()) + }] + ); + } + + #[gpui::test] + async fn test_confirm_remote_url_transitions(cx: &mut TestAppContext) { + init_test(cx); + + let branches = vec![create_test_branch("main_branch", true, None, Some(1000))]; + let (mut ctx, branch_list) = init_branch_list_test(cx, None, branches); + let cx = &mut ctx; + + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + let query = "https://github.com/user/repo.git".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to create a new remote but cancel in the middle of the process + branch_list + .update_in(cx, |branch_list, window, cx| { + branch_list.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = picker.delegate.matches.len() - 1; + picker.delegate.confirm(false, window, cx); + + assert!(matches!( + picker.delegate.state, + PickerState::CreateRemote(_) + )); + if let PickerState::CreateRemote(ref url) = picker.delegate.state { + assert_eq!(url.as_ref(), "https://github.com/user/repo.git"); + } + assert_eq!(picker.delegate.matches.len(), 0); + picker.delegate.dismissed(window, cx); + assert!(matches!(picker.delegate.state, PickerState::List)); + let query = "main".to_string(); + picker.delegate.update_matches(query, window, cx) + }) + }) + .await; + cx.run_until_parked(); + + // Try to search a branch again to see if the state is restored properly + branch_list.update(cx, |branch_list, cx| { + branch_list.picker.update(cx, |picker, _cx| { + // Should have 1 existing branch + 1 "create new branch" entry = 2 total + assert_eq!(picker.delegate.matches.len(), 2); + assert!( + picker + .delegate + .matches + .iter() + .any(|m| m.name() == "main_branch") + ); + // Verify the last entry is the "create new branch" option + let last_match = picker.delegate.matches.last().unwrap(); + assert!(last_match.is_new_branch()); + }) + }); + } +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 62bd118daf1751e32dd0b805a773be47e19e4357..c6895f4c15d5afd3ef50ce796059956dd8653f8b 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3463,7 +3463,6 @@ impl GitPanel { ) -> Option { let active_repository = self.active_repository.clone()?; let panel_editor_style = panel_editor_style(true, window, cx); - let enable_coauthors = self.render_co_authors(cx); let editor_focus_handle = self.commit_editor.focus_handle(cx); @@ -4772,7 +4771,6 @@ impl RenderOnce for PanelRepoFooter { const MAX_REPO_LEN: usize = 16; const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN; const MAX_SHORT_SHA_LEN: usize = 8; - let branch_name = self .branch .as_ref() diff --git a/crates/git_ui/src/remote_output.rs b/crates/git_ui/src/remote_output.rs index 8437bf0d0d37c2b2767624110fed056bbae25d05..7fe863ee29df20ca0f61cef5bf64cdae4b198c7a 100644 --- a/crates/git_ui/src/remote_output.rs +++ b/crates/git_ui/src/remote_output.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; + use git::repository::{Remote, RemoteCommandOutput}; use linkify::{LinkFinder, LinkKind}; use ui::SharedString; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 5bc3f4ee43493ee9d07ab2c3a1025214007a653d..81511b21be3599b4686b9fd11aac5118711f11fa 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -472,6 +472,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_change_branch); client.add_entity_request_handler(Self::handle_create_branch); client.add_entity_request_handler(Self::handle_rename_branch); + client.add_entity_request_handler(Self::handle_create_remote); + client.add_entity_request_handler(Self::handle_remove_remote); client.add_entity_request_handler(Self::handle_delete_branch); client.add_entity_request_handler(Self::handle_git_init); client.add_entity_request_handler(Self::handle_push); @@ -2274,6 +2276,25 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_create_remote( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let remote_name = envelope.payload.remote_name; + let remote_url = envelope.payload.remote_url; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.create_remote(remote_name, remote_url) + })? + .await??; + + Ok(proto::Ack {}) + } + async fn handle_delete_branch( this: Entity, envelope: TypedEnvelope, @@ -2292,6 +2313,24 @@ impl GitStore { Ok(proto::Ack {}) } + async fn handle_remove_remote( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); + let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; + let remote_name = envelope.payload.remote_name; + + repository_handle + .update(&mut cx, |repository_handle, _| { + repository_handle.remove_remote(remote_name) + })? + .await??; + + Ok(proto::Ack {}) + } + async fn handle_show( this: Entity, envelope: TypedEnvelope, @@ -4865,6 +4904,61 @@ impl Repository { ) } + pub fn create_remote( + &mut self, + remote_name: String, + remote_url: String, + ) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git remote add {remote_name} {remote_url}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_remote(remote_name, remote_url).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitCreateRemote { + project_id: project_id.0, + repository_id: id.to_proto(), + remote_name, + remote_url, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + + pub fn remove_remote(&mut self, remote_name: String) -> oneshot::Receiver> { + let id = self.id; + self.send_job( + Some(format!("git remove remote {remote_name}").into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.remove_remote(remote_name).await + } + RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { + client + .request(proto::GitRemoveRemote { + project_id: project_id.0, + repository_id: id.to_proto(), + remote_name, + }) + .await?; + + Ok(()) + } + } + }, + ) + } + pub fn get_remotes( &mut self, branch_name: Option, @@ -4902,7 +4996,7 @@ impl Repository { let remotes = response .remotes .into_iter() - .map(|remotes| git::repository::Remote { + .map(|remotes| Remote { name: remotes.name.into(), }) .collect(); diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index de6a5f676df7332d0673d4e5bd75130bf7f0c400..aa0668ceabddc7627fcc3593b86ad2f4e40a6ac7 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -190,6 +190,19 @@ message GitRenameBranch { string new_name = 4; } +message GitCreateRemote { + uint64 project_id = 1; + uint64 repository_id = 2; + string remote_name = 3; + string remote_url = 4; +} + +message GitRemoveRemote { + uint64 project_id = 1; + uint64 repository_id = 2; + string remote_name = 3; +} + message GitDeleteBranch { uint64 project_id = 1; uint64 repository_id = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 39faeeac88cfc49cbaba4a777da3fb8daa015a66..8e26a26a43ff8af5c1b676f5dc7f8fe49e67e19f 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -437,13 +437,18 @@ message Envelope { OpenImageResponse open_image_response = 392; CreateImageForPeer create_image_for_peer = 393; + GitFileHistory git_file_history = 397; GitFileHistoryResponse git_file_history_response = 398; RunGitHook run_git_hook = 399; GitDeleteBranch git_delete_branch = 400; - ExternalExtensionAgentsUpdated external_extension_agents_updated = 401; // current max + + ExternalExtensionAgentsUpdated external_extension_agents_updated = 401; + + GitCreateRemote git_create_remote = 402; + GitRemoveRemote git_remove_remote = 403;// current max } reserved 87 to 88, 396; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 38a994a37b6c62f7a1f078eb287f120c49b0ce82..455f94704663dcd96e37487b1a4243850634c18e 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -305,6 +305,8 @@ messages!( (RemoteMessageResponse, Background), (AskPassRequest, Background), (AskPassResponse, Background), + (GitCreateRemote, Background), + (GitRemoveRemote, Background), (GitCreateBranch, Background), (GitChangeBranch, Background), (GitRenameBranch, Background), @@ -504,6 +506,8 @@ request_messages!( (GetRemotes, GetRemotesResponse), (Pull, RemoteMessageResponse), (AskPassRequest, AskPassResponse), + (GitCreateRemote, Ack), + (GitRemoveRemote, Ack), (GitCreateBranch, Ack), (GitChangeBranch, Ack), (GitRenameBranch, Ack), @@ -676,6 +680,8 @@ entity_messages!( GitChangeBranch, GitRenameBranch, GitCreateBranch, + GitCreateRemote, + GitRemoveRemote, CheckForPushedCommits, GitDiff, GitInit, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 803fde3f8787b4f6489bd6390d289c35b1c96199..d4d28433d4c76dcab3df627789df82e99854fbc1 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -215,6 +215,10 @@ pub mod git { Switch, /// Selects a different repository. SelectRepo, + /// Filter remotes. + FilterRemotes, + /// Create a git remote. + CreateRemote, /// Opens the git branch selector. #[action(deprecated_aliases = ["branches::OpenRecent"])] Branch, From 2dad46c5c08dbe676c80a39cf9cf0ddac9562b64 Mon Sep 17 00:00:00 2001 From: Rawand Ahmed Shaswar Date: Thu, 4 Dec 2025 17:26:17 +0300 Subject: [PATCH 64/67] gpui: Fix division by zero when chars/sec = 0 on Wayland (#44151) Closes #44148 the existing rate == 0 check inside the timer callback already handles disabling repeat - it just drops the timer immediately. So the fix prevents the crash while preserving correct behavior. Release Notes: - Linux (Wayland): Fixed a crash that could occur when `characters_per_second` was zero --- crates/gpui/src/platform/linux/wayland/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index a2324648fbb332e75af7df74923806797d93a05a..2879925495e41fd37ea075f20a0de0b19625694e 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -1419,7 +1419,7 @@ impl Dispatch for WaylandClientStatePtr { state.repeat.current_keycode = Some(keycode); let rate = state.repeat.characters_per_second; - let repeat_interval = Duration::from_secs(1) / rate; + let repeat_interval = Duration::from_secs(1) / rate.max(1); let id = state.repeat.current_id; state .loop_handle From c978db8626584c96947405226b23483e50727bb0 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Thu, 4 Dec 2025 11:30:16 -0300 Subject: [PATCH 65/67] Fix background scanner deadlock (#44109) Fixes a deadlock in the background scanner that occurs on single-core Linux devices. This happens because the background scanner would `block` on a background thread waiting for a future, but on single-core Linux devices there would be no other thread to pick it up. This mostly affects SSH remoting use cases where it's common for servers to have 1 vCPU. Closes #43884 Closes #43809 Release Notes: - Fix SSH remoting hang when connecting to 1 vCPU servers --- crates/worktree/src/worktree.rs | 62 ++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 152277af2e3f626aa0da608af275505b04d0af32..942e692a020049b102a0d810bfbf1a9074962611 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -52,7 +52,7 @@ use std::{ fmt, future::Future, mem::{self}, - ops::{Deref, DerefMut}, + ops::{Deref, DerefMut, Range}, path::{Path, PathBuf}, pin::Pin, sync::{ @@ -3877,29 +3877,35 @@ impl BackgroundScanner { abs_paths.dedup_by(|a, b| a.starts_with(b)); { let snapshot = &self.state.lock().await.snapshot; - abs_paths.retain(|abs_path| { - let abs_path = &SanitizedPath::new(abs_path); + let mut ranges_to_drop = SmallVec::<[Range; 4]>::new(); - { - let mut is_git_related = false; + fn skip_ix(ranges: &mut SmallVec<[Range; 4]>, ix: usize) { + if let Some(last_range) = ranges.last_mut() + && last_range.end == ix + { + last_range.end += 1; + } else { + ranges.push(ix..ix + 1); + } + } - let dot_git_paths = self.executor.block(maybe!(async { - let mut path = None; - for ancestor in abs_path.as_path().ancestors() { + for (ix, abs_path) in abs_paths.iter().enumerate() { + let abs_path = &SanitizedPath::new(&abs_path); + let mut is_git_related = false; + let mut dot_git_paths = None; + + for ancestor in abs_path.as_path().ancestors() { if is_git_dir(ancestor, self.fs.as_ref()).await { let path_in_git_dir = abs_path .as_path() .strip_prefix(ancestor) .expect("stripping off the ancestor"); - path = Some((ancestor.to_owned(), path_in_git_dir.to_owned())); - break; - } + dot_git_paths = Some((ancestor.to_owned(), path_in_git_dir.to_owned())); + break; } - path - - })); + } if let Some((dot_git_abs_path, path_in_git_dir)) = dot_git_paths { if skipped_files_in_dot_git @@ -3909,8 +3915,11 @@ impl BackgroundScanner { path_in_git_dir.starts_with(skipped_git_subdir) }) { - log::debug!("ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories"); - return false; + log::debug!( + "ignoring event {abs_path:?} as it's in the .git directory among skipped files or directories" + ); + skip_ix(&mut ranges_to_drop, ix); + continue; } is_git_related = true; @@ -3919,8 +3928,7 @@ impl BackgroundScanner { } } - let relative_path = if let Ok(path) = - abs_path.strip_prefix(&root_canonical_path) + let relative_path = if let Ok(path) = abs_path.strip_prefix(&root_canonical_path) && let Ok(path) = RelPath::new(path, PathStyle::local()) { path @@ -3931,10 +3939,11 @@ impl BackgroundScanner { ); } else { log::error!( - "ignoring event {abs_path:?} outside of root path {root_canonical_path:?}", + "ignoring event {abs_path:?} outside of root path {root_canonical_path:?}", ); } - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; }; if abs_path.file_name() == Some(OsStr::new(GITIGNORE)) { @@ -3958,21 +3967,26 @@ impl BackgroundScanner { }); if !parent_dir_is_loaded { log::debug!("ignoring event {relative_path:?} within unloaded directory"); - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; } if self.settings.is_path_excluded(&relative_path) { if !is_git_related { log::debug!("ignoring FS event for excluded path {relative_path:?}"); } - return false; + skip_ix(&mut ranges_to_drop, ix); + continue; } relative_paths.push(relative_path.into_arc()); - true } - }); + + for range_to_drop in ranges_to_drop.into_iter().rev() { + abs_paths.drain(range_to_drop); + } } + if relative_paths.is_empty() && dot_git_abs_paths.is_empty() { return; } From a33e8819066331c12f2d025d1de789cb41375c8b Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 4 Dec 2025 15:42:26 +0100 Subject: [PATCH 66/67] remote: Recognize WSL interop to open browser for codex web login (#44136) Closes #41521 Release Notes: - Fixed codex web login not working on wsl remotes if no browser is installed Co-authored-by: Ben Brandt --- crates/client/src/client.rs | 4 ++++ crates/project/src/agent_server_store.rs | 10 ++++---- crates/remote/Cargo.toml | 1 - crates/remote/src/remote_client.rs | 30 ++++++++++++++++++++---- crates/remote/src/transport.rs | 6 +---- crates/remote/src/transport/ssh.rs | 4 ++++ crates/remote/src/transport/wsl.rs | 24 +++++++++++++++++++ crates/remote_server/src/unix.rs | 12 ++++++++-- crates/rpc/src/proto_client.rs | 5 ++++ 9 files changed, 79 insertions(+), 17 deletions(-) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 96b15dc9fb13deea3cdc706f1927c4d6f016b57a..6d6d229b940433ceac4c80f11891319550d269a2 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1723,6 +1723,10 @@ impl ProtoClient for Client { fn is_via_collab(&self) -> bool { true } + + fn has_wsl_interop(&self) -> bool { + false + } } /// prefix for the zed:// url scheme diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index ef12e222009a59430a3396cae7971ac7593e82c3..95afdd09c15b9970d7eb637e6df99502d3bc3b67 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -453,7 +453,9 @@ impl AgentServerStore { .clone() .and_then(|settings| settings.custom_command()), http_client: http_client.clone(), - is_remote: downstream_client.is_some(), + no_browser: downstream_client + .as_ref() + .is_some_and(|(_, client)| !client.has_wsl_interop()), }), ); self.external_agents.insert( @@ -1355,7 +1357,7 @@ struct LocalCodex { project_environment: Entity, http_client: Arc, custom_command: Option, - is_remote: bool, + no_browser: bool, } impl ExternalAgentServer for LocalCodex { @@ -1375,7 +1377,7 @@ impl ExternalAgentServer for LocalCodex { .map(|root_dir| Path::new(root_dir)) .unwrap_or(paths::home_dir()) .into(); - let is_remote = self.is_remote; + let no_browser = self.no_browser; cx.spawn(async move |cx| { let mut env = project_environment @@ -1388,7 +1390,7 @@ impl ExternalAgentServer for LocalCodex { })? .await .unwrap_or_default(); - if is_remote { + if no_browser { env.insert("NO_BROWSER".to_owned(), "1".to_owned()); } diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml index 07eb7d795e21c2f4b99817e301f6d8687c4aab60..ae32cd5cb10c2bf4c65b3b8ae51bf20e7e3ad15a 100644 --- a/crates/remote/Cargo.toml +++ b/crates/remote/Cargo.toml @@ -43,7 +43,6 @@ urlencoding.workspace = true util.workspace = true which.workspace = true - [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 85b19ba25ca7187dfb400eb4716234bb3716ba9c..b0f9914c90545263a830ec034512a7e423109409 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -328,8 +328,15 @@ impl RemoteClient { let (incoming_tx, incoming_rx) = mpsc::unbounded::(); let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1); - let client = - cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?; + let client = cx.update(|cx| { + ChannelClient::new( + incoming_rx, + outgoing_tx, + cx, + "client", + remote_connection.has_wsl_interop(), + ) + })?; let path_style = remote_connection.path_style(); let this = cx.new(|_| Self { @@ -420,8 +427,9 @@ impl RemoteClient { outgoing_tx: mpsc::UnboundedSender, cx: &App, name: &'static str, + has_wsl_interop: bool, ) -> AnyProtoClient { - ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into() + ChannelClient::new(incoming_rx, outgoing_tx, cx, name, has_wsl_interop).into() } pub fn shutdown_processes( @@ -921,8 +929,8 @@ impl RemoteClient { }); let (outgoing_tx, _) = mpsc::unbounded::(); let (_, incoming_rx) = mpsc::unbounded::(); - let server_client = - server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server")); + let server_client = server_cx + .update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server", false)); let connection: Arc = Arc::new(fake::FakeRemoteConnection { connection_options: opts.clone(), server_cx: fake::SendableCx::new(server_cx), @@ -1140,6 +1148,7 @@ pub trait RemoteConnection: Send + Sync { fn path_style(&self) -> PathStyle; fn shell(&self) -> String; fn default_system_shell(&self) -> String; + fn has_wsl_interop(&self) -> bool; #[cfg(any(test, feature = "test-support"))] fn simulate_disconnect(&self, _: &AsyncApp) {} @@ -1188,6 +1197,7 @@ struct ChannelClient { name: &'static str, task: Mutex>>, remote_started: Signal<()>, + has_wsl_interop: bool, } impl ChannelClient { @@ -1196,6 +1206,7 @@ impl ChannelClient { outgoing_tx: mpsc::UnboundedSender, cx: &App, name: &'static str, + has_wsl_interop: bool, ) -> Arc { Arc::new_cyclic(|this| Self { outgoing_tx: Mutex::new(outgoing_tx), @@ -1211,6 +1222,7 @@ impl ChannelClient { &cx.to_async(), )), remote_started: Signal::new(cx), + has_wsl_interop, }) } @@ -1489,6 +1501,10 @@ impl ProtoClient for ChannelClient { fn is_via_collab(&self) -> bool { false } + + fn has_wsl_interop(&self) -> bool { + self.has_wsl_interop + } } #[cfg(any(test, feature = "test-support"))] @@ -1652,6 +1668,10 @@ mod fake { fn default_system_shell(&self) -> String { "sh".to_owned() } + + fn has_wsl_interop(&self) -> bool { + false + } } pub(super) struct Delegate; diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs index 211851c0629c13f1f79ce425cafc582899d1b58f..7441ede609dbfe0e4c74c3f3738bd07d209a37ec 100644 --- a/crates/remote/src/transport.rs +++ b/crates/remote/src/transport.rs @@ -131,11 +131,7 @@ async fn build_remote_server_from_source( let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").unwrap_or("nocompress".into()); - if build_remote_server == "false" - || build_remote_server == "no" - || build_remote_server == "off" - || build_remote_server == "0" - { + if let "false" | "no" | "off" | "0" = &*build_remote_server { return Ok(None); } diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index bf537a3d6715eb8492fa87b802a26a111ec402b7..20cd0c5ff4b427d3a37882603ce2962db9e4e1e0 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -394,6 +394,10 @@ impl RemoteConnection for SshRemoteConnection { fn path_style(&self) -> PathStyle { self.ssh_path_style } + + fn has_wsl_interop(&self) -> bool { + false + } } impl SshRemoteConnection { diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index c6fa154ba09928efc04bb3ac15ad98b1db0671c0..670f122012ea1ab39b5905995f70c01d1dcf439c 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -47,6 +47,7 @@ pub(crate) struct WslRemoteConnection { shell: String, shell_kind: ShellKind, default_system_shell: String, + has_wsl_interop: bool, connection_options: WslConnectionOptions, } @@ -71,6 +72,7 @@ impl WslRemoteConnection { shell: String::new(), shell_kind: ShellKind::Posix, default_system_shell: String::from("/bin/sh"), + has_wsl_interop: false, }; delegate.set_status(Some("Detecting WSL environment"), cx); this.shell = this @@ -79,6 +81,15 @@ impl WslRemoteConnection { .context("failed detecting shell")?; log::info!("Remote shell discovered: {}", this.shell); this.shell_kind = ShellKind::new(&this.shell, false); + this.has_wsl_interop = this.detect_has_wsl_interop().await.unwrap_or_default(); + log::info!( + "Remote has wsl interop {}", + if this.has_wsl_interop { + "enabled" + } else { + "disabled" + } + ); this.platform = this .detect_platform() .await @@ -115,6 +126,14 @@ impl WslRemoteConnection { .unwrap_or_else(|| "/bin/sh".to_string())) } + async fn detect_has_wsl_interop(&self) -> Result { + Ok(self + .run_wsl_command_with_output("cat", &["/proc/sys/fs/binfmt_misc/WSLInterop"]) + .await + .inspect_err(|err| log::error!("Failed to detect wsl interop: {err}"))? + .contains("enabled")) + } + async fn windows_path_to_wsl_path(&self, source: &Path) -> Result { windows_path_to_wsl_path_impl(&self.connection_options, source).await } @@ -317,6 +336,7 @@ impl RemoteConnection for WslRemoteConnection { proxy_args.push(format!("{}={}", env_var, value)); } } + proxy_args.push(remote_binary_path.display(PathStyle::Posix).into_owned()); proxy_args.push("proxy".to_owned()); proxy_args.push("--identifier".to_owned()); @@ -489,6 +509,10 @@ impl RemoteConnection for WslRemoteConnection { fn default_system_shell(&self) -> String { self.default_system_shell.clone() } + + fn has_wsl_interop(&self) -> bool { + self.has_wsl_interop + } } /// `wslpath` is a executable available in WSL, it's a linux binary. diff --git a/crates/remote_server/src/unix.rs b/crates/remote_server/src/unix.rs index 0407539a4c131d92202e3177cc95137062b039ec..8adeaa594738aaddbcdd2dbe6454ead8485ca212 100644 --- a/crates/remote_server/src/unix.rs +++ b/crates/remote_server/src/unix.rs @@ -199,6 +199,7 @@ fn start_server( listeners: ServerListeners, log_rx: Receiver>, cx: &mut App, + is_wsl_interop: bool, ) -> AnyProtoClient { // This is the server idle timeout. If no connection comes in this timeout, the server will shut down. const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10 * 60); @@ -318,7 +319,7 @@ fn start_server( }) .detach(); - RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server") + RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server", is_wsl_interop) } fn init_paths() -> anyhow::Result<()> { @@ -407,8 +408,15 @@ pub fn execute_run( HeadlessProject::init(cx); + let is_wsl_interop = if cfg!(target_os = "linux") { + // See: https://learn.microsoft.com/en-us/windows/wsl/filesystems#disable-interoperability + matches!(std::fs::read_to_string("/proc/sys/fs/binfmt_misc/WSLInterop"), Ok(s) if s.contains("enabled")) + } else { + false + }; + log::info!("gpui app started, initializing server"); - let session = start_server(listeners, log_rx, cx); + let session = start_server(listeners, log_rx, cx, is_wsl_interop); GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx); git_hosting_providers::init(cx); diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index d7e3ba1e461b28ac264afcc05a8ae941e6da0c32..3850ff5820e6d73289b5714d6b880ecb584bf8d9 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -59,6 +59,7 @@ pub trait ProtoClient: Send + Sync { fn message_handler_set(&self) -> &parking_lot::Mutex; fn is_via_collab(&self) -> bool; + fn has_wsl_interop(&self) -> bool; } #[derive(Default)] @@ -510,6 +511,10 @@ impl AnyProtoClient { }, ); } + + pub fn has_wsl_interop(&self) -> bool { + self.0.client.has_wsl_interop() + } } fn to_any_envelope( From 93bc6616c697bee1ecf178e41597f0b294b0826b Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 4 Dec 2025 16:41:48 +0100 Subject: [PATCH 67/67] editor: Improve performance of `update_visible_edit_prediction` (#44161) One half of https://github.com/zed-industries/zed/issues/42861 This basically reduces the main thread work for large enough json (and other) files from multiple milliseconds (15ms was observed in that test case) down to microseconds (100ms here). Release Notes: - Improved cursor movement performance when edit predictions are enabled --- crates/editor/src/editor.rs | 15 ++++++++---- crates/multi_buffer/src/multi_buffer.rs | 32 +++++++++++++++++++------ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 05287847190691221e6f948ba53efecc7269e9be..4b352e2d8298f3c9ae2c0d38bd6b443d62a61996 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -182,7 +182,7 @@ use std::{ iter::{self, Peekable}, mem, num::NonZeroU32, - ops::{Deref, DerefMut, Not, Range, RangeInclusive}, + ops::{ControlFlow, Deref, DerefMut, Not, Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, sync::Arc, @@ -8073,10 +8073,17 @@ impl Editor { if self.edit_prediction_indent_conflict { let cursor_point = cursor.to_point(&multibuffer); + let mut suggested_indent = None; + multibuffer.suggested_indents_callback( + cursor_point.row..cursor_point.row + 1, + |_, indent| { + suggested_indent = Some(indent); + ControlFlow::Break(()) + }, + cx, + ); - let indents = multibuffer.suggested_indents(cursor_point.row..cursor_point.row + 1, cx); - - if let Some((_, indent)) = indents.iter().next() + if let Some(indent) = suggested_indent && indent.len == cursor_point.column { self.edit_prediction_indent_conflict = false; diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 02adb79e70452a524152d62a71138b75561f9f33..af36aaadf02b53224c4ef0bcf0a17d3643ab8f0f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -43,7 +43,7 @@ use std::{ io, iter::{self, FromIterator}, mem, - ops::{self, AddAssign, Range, RangeBounds, Sub, SubAssign}, + ops::{self, AddAssign, ControlFlow, Range, RangeBounds, Sub, SubAssign}, rc::Rc, str, sync::Arc, @@ -4618,7 +4618,24 @@ impl MultiBufferSnapshot { cx: &App, ) -> BTreeMap { let mut result = BTreeMap::new(); + self.suggested_indents_callback( + rows, + |row, indent| { + result.insert(row, indent); + ControlFlow::Continue(()) + }, + cx, + ); + result + } + // move this to be a generator once those are a thing + pub fn suggested_indents_callback( + &self, + rows: impl IntoIterator, + mut cb: impl FnMut(MultiBufferRow, IndentSize) -> ControlFlow<()>, + cx: &App, + ) { let mut rows_for_excerpt = Vec::new(); let mut cursor = self.cursor::(); let mut rows = rows.into_iter().peekable(); @@ -4662,16 +4679,17 @@ impl MultiBufferSnapshot { let buffer_indents = region .buffer .suggested_indents(buffer_rows, single_indent_size); - let multibuffer_indents = buffer_indents.into_iter().map(|(row, indent)| { - ( + for (row, indent) in buffer_indents { + if cb( MultiBufferRow(start_multibuffer_row + row - start_buffer_row), indent, ) - }); - result.extend(multibuffer_indents); + .is_break() + { + return; + } + } } - - result } pub fn indent_size_for_line(&self, row: MultiBufferRow) -> IndentSize {