Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
//! Module containing the definition of [`ActiveFileModel`],
|
||||
//! which tracks the currently focused file across an entire PaneGroup.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
/// Events emitted by the ActiveFileModel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ActiveFileEvent {
|
||||
/// A new file became focused.
|
||||
ActiveFileChanged { file_info: PathBuf },
|
||||
}
|
||||
|
||||
/// Model that tracks the currently focused file.
|
||||
#[derive(Default)]
|
||||
pub struct ActiveFileModel {
|
||||
/// The currently focused file, if any.
|
||||
active_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Entity for ActiveFileModel {
|
||||
type Event = ActiveFileEvent;
|
||||
}
|
||||
|
||||
impl ActiveFileModel {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get the currently active file, if any.
|
||||
pub fn active_file(&self) -> Option<&PathBuf> {
|
||||
self.active_file.as_ref()
|
||||
}
|
||||
|
||||
/// Set the currently active file.
|
||||
pub fn active_file_changed(&mut self, path: PathBuf, ctx: &mut ModelContext<Self>) {
|
||||
// Only emit event if the active file changed.
|
||||
if self.active_file.as_ref() != Some(&path) {
|
||||
self.active_file = Some(path.clone());
|
||||
ctx.emit(ActiveFileEvent::ActiveFileChanged { file_info: path });
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use ai::diff_validation::DiffType;
|
||||
use warp_editor::render::element::VerticalExpansionBehavior;
|
||||
use warpui::elements::new_scrollable::ScrollableAppearance;
|
||||
use warpui::elements::ScrollbarWidth;
|
||||
use warpui::{AppContext, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::editor::scroll::ScrollWheelBehavior;
|
||||
use super::editor::view::CodeEditorView;
|
||||
use super::editor::NavBarBehavior;
|
||||
use crate::editor::InteractionState;
|
||||
|
||||
/// Whether a view is displayed in a full pane or embedded in another view, like the blocklist.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum DisplayMode {
|
||||
/// The code diff view takes up its own pane.
|
||||
FullPane,
|
||||
/// The code diff view is embedded inside an AI block.
|
||||
Embedded { max_height: f32 },
|
||||
/// The code diff view is its own element in the blocklist,
|
||||
/// instead of being nested inside an existing block.
|
||||
InlineBanner {
|
||||
max_height: f32,
|
||||
is_expanded: bool,
|
||||
is_dismissed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl DisplayMode {
|
||||
pub fn with_embedded(max_height: f32) -> Self {
|
||||
DisplayMode::Embedded { max_height }
|
||||
}
|
||||
|
||||
pub fn with_inline_banner(max_height: f32) -> Self {
|
||||
DisplayMode::InlineBanner {
|
||||
max_height,
|
||||
is_expanded: false,
|
||||
is_dismissed: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_height(&self) -> Option<f32> {
|
||||
match self {
|
||||
DisplayMode::FullPane => None,
|
||||
DisplayMode::Embedded { max_height } => Some(*max_height),
|
||||
DisplayMode::InlineBanner { max_height, .. } => Some(*max_height),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_wheel_behavior(&self) -> ScrollWheelBehavior {
|
||||
match self {
|
||||
DisplayMode::InlineBanner {
|
||||
is_expanded: false, ..
|
||||
} => ScrollWheelBehavior::NeverHandle,
|
||||
_ => ScrollWheelBehavior::AlwaysHandle,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scrollbar_appearance(&self) -> ScrollableAppearance {
|
||||
match self {
|
||||
DisplayMode::InlineBanner {
|
||||
is_expanded: false, ..
|
||||
} => ScrollableAppearance::new(ScrollbarWidth::None, true),
|
||||
_ => ScrollableAppearance::new(ScrollbarWidth::Auto, false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn vertical_expansion_behavior(&self) -> VerticalExpansionBehavior {
|
||||
match self {
|
||||
DisplayMode::FullPane => VerticalExpansionBehavior::FillMaxHeight,
|
||||
DisplayMode::Embedded { .. } => VerticalExpansionBehavior::GrowToMaxHeight,
|
||||
DisplayMode::InlineBanner { .. } => VerticalExpansionBehavior::GrowToMaxHeight,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn interaction_state(&self, is_delete: bool) -> InteractionState {
|
||||
if is_delete {
|
||||
return InteractionState::Selectable;
|
||||
}
|
||||
match self {
|
||||
DisplayMode::FullPane => InteractionState::Editable,
|
||||
DisplayMode::Embedded { .. } => InteractionState::Selectable,
|
||||
DisplayMode::InlineBanner { .. } => InteractionState::Selectable,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_nav_bar(&self) -> bool {
|
||||
!matches!(self, DisplayMode::InlineBanner { .. })
|
||||
}
|
||||
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
match self {
|
||||
DisplayMode::InlineBanner { .. } => Some("Suggested fixes based on your last command:"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_full_pane(&self) -> bool {
|
||||
matches!(self, DisplayMode::FullPane)
|
||||
}
|
||||
|
||||
pub fn is_embedded(&self) -> bool {
|
||||
matches!(self, DisplayMode::Embedded { .. })
|
||||
}
|
||||
|
||||
pub fn is_inline_banner(&self) -> bool {
|
||||
matches!(self, DisplayMode::InlineBanner { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared trait for views that display an inline diff.
|
||||
/// Implemented by both `LocalCodeEditorView` (for native file-backed diffs)
|
||||
/// and `InlineDiffView` (for mocked/WASM diffs).
|
||||
pub trait DiffViewer
|
||||
where
|
||||
Self: Sized + View,
|
||||
{
|
||||
fn editor(&self) -> &ViewHandle<CodeEditorView>;
|
||||
fn diff(&self) -> Option<&DiffType>;
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
fn was_edited(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
fn changed_lines(&self, ctx: &AppContext) -> Vec<Range<usize>> {
|
||||
self.editor().as_ref(ctx).changed_lines(ctx)
|
||||
}
|
||||
|
||||
fn set_display_mode(&self, mode: DisplayMode, ctx: &mut ViewContext<Self>) {
|
||||
let is_delete = matches!(self.diff(), Some(DiffType::Delete { .. }));
|
||||
self.editor().update(ctx, |editor, ctx| {
|
||||
editor.set_scroll_wheel_behavior(mode.scroll_wheel_behavior());
|
||||
editor.set_vertical_expansion_behavior(mode.vertical_expansion_behavior(), ctx);
|
||||
editor.set_vertical_scrollbar_appearance(mode.scrollbar_appearance());
|
||||
editor.set_horizontal_scrollbar_appearance(mode.scrollbar_appearance());
|
||||
editor.set_interaction_state(mode.interaction_state(is_delete), ctx);
|
||||
editor.set_show_nav_bar(mode.show_nav_bar());
|
||||
editor.set_nav_bar_behavior(NavBarBehavior::NotClosable, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn navigate_next_diff_hunk(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.editor()
|
||||
.update(ctx, |editor, ctx| editor.navigate_next_diff_hunk(ctx));
|
||||
}
|
||||
|
||||
fn navigate_previous_diff_hunk(&self, ctx: &mut ViewContext<Self>) {
|
||||
self.editor()
|
||||
.update(ctx, |editor, ctx| editor.navigate_previous_diff_hunk(ctx));
|
||||
}
|
||||
|
||||
fn accept_and_save_diff(&self, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
fn reject_diff(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
fn restore_diff_base(&mut self, _ctx: &mut ViewContext<Self>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
use crate::code::editor::comments::{EditorCommentsModel, PendingCommentEvent};
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code_review::comments::{CommentId, CommentOrigin};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::notebooks::editor::{
|
||||
model::NotebooksEditorModel,
|
||||
rich_text_styles,
|
||||
view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView},
|
||||
};
|
||||
use crate::notebooks::link::{NotebookLinks, SessionSource};
|
||||
use crate::settings::FontSettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, DangerNakedTheme, KeystrokeSource, NakedTheme, PrimaryTheme,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::cell::RefCell;
|
||||
use warp_core::ui::{appearance::Appearance, theme::Fill};
|
||||
use warp_editor::render::element::VerticalExpansionBehavior;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildView, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
Flex, MainAxisAlignment, MainAxisSize, ParentElement, Radius, Shrinkable, Text,
|
||||
},
|
||||
keymap::Keystroke,
|
||||
text_layout::ClipConfig,
|
||||
units::Pixels,
|
||||
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
/// Default width of the comment editor, in pixels.
|
||||
pub(crate) const DEFAULT_COMMENT_MAX_WIDTH: f32 = 750.0;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommentEditorEvent {
|
||||
ContentChanged,
|
||||
CommentSaved {
|
||||
id: Option<CommentId>,
|
||||
comment_text: String,
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
line: Option<EditorLineLocation>,
|
||||
},
|
||||
CloseEditor,
|
||||
DeleteComment {
|
||||
id: CommentId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommentEditorAction {
|
||||
SaveComment,
|
||||
CloseEditor,
|
||||
RemoveComment,
|
||||
}
|
||||
|
||||
pub struct CommentEditor {
|
||||
/// Comment ID if editing an existing comment, None for new comments.
|
||||
comment_id: Option<CommentId>,
|
||||
editor: ViewHandle<RichTextEditorView>,
|
||||
save_button: ViewHandle<ActionButton>,
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
remove_button: ViewHandle<ActionButton>,
|
||||
line: Option<EditorLineLocation>,
|
||||
show_remove_button: bool,
|
||||
save_button_disabled: bool,
|
||||
laid_out_size: RefCell<Option<Vector2F>>,
|
||||
is_imported_comment: bool,
|
||||
}
|
||||
|
||||
impl CommentEditor {
|
||||
pub fn new(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
comment_model: ModelHandle<EditorCommentsModel>,
|
||||
) -> Self {
|
||||
let editor = create_editable_comment_markdown_editor(None, ctx);
|
||||
|
||||
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
|
||||
me.handle_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&comment_model, |me, _, event, ctx| {
|
||||
me.handle_comment_model_event(event, ctx);
|
||||
});
|
||||
|
||||
let (save_button, close_button, remove_button) = Self::create_buttons(ctx);
|
||||
|
||||
let mut me = Self {
|
||||
comment_id: None,
|
||||
editor,
|
||||
save_button,
|
||||
close_button,
|
||||
remove_button,
|
||||
line: None,
|
||||
show_remove_button: false,
|
||||
save_button_disabled: true,
|
||||
laid_out_size: RefCell::new(None),
|
||||
is_imported_comment: false,
|
||||
};
|
||||
me.update_save_button_state(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
#[allow(unused)] // TODO(CODE-1464): use this
|
||||
pub fn new_embedded(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
comment_model: ModelHandle<EditorCommentsModel>,
|
||||
comment_id: Option<CommentId>,
|
||||
line: EditorLineLocation,
|
||||
) -> Self {
|
||||
let editor = create_editable_comment_markdown_editor(None, ctx);
|
||||
|
||||
ctx.subscribe_to_view(&editor, |me, _, event, ctx| {
|
||||
me.handle_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&comment_model, |me, _, event, ctx| {
|
||||
me.handle_comment_model_event(event, ctx);
|
||||
});
|
||||
|
||||
let (save_button, close_button, remove_button) = Self::create_buttons(ctx);
|
||||
|
||||
let show_remove_button = comment_id.is_some();
|
||||
|
||||
let mut me = Self {
|
||||
comment_id,
|
||||
editor,
|
||||
save_button,
|
||||
close_button,
|
||||
remove_button,
|
||||
line: Some(line),
|
||||
show_remove_button,
|
||||
save_button_disabled: true,
|
||||
laid_out_size: RefCell::new(None),
|
||||
is_imported_comment: false,
|
||||
};
|
||||
me.update_save_button_state(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused))]
|
||||
pub fn comment_text(&self, app: &AppContext) -> String {
|
||||
self.editor.as_ref(app).model().as_ref(app).markdown(app)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused))]
|
||||
pub fn get_laid_out_size(&self) -> Option<Vector2F> {
|
||||
self.laid_out_size.borrow().as_ref().cloned()
|
||||
}
|
||||
|
||||
#[allow(unused)] // TODO(CODE-1464): use this
|
||||
pub fn set_laid_out_size(&self, value: Vector2F) {
|
||||
self.laid_out_size.replace(Some(value));
|
||||
}
|
||||
|
||||
fn create_buttons(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> (
|
||||
ViewHandle<ActionButton>,
|
||||
ViewHandle<ActionButton>,
|
||||
ViewHandle<ActionButton>,
|
||||
) {
|
||||
let save_button = ctx.add_typed_action_view(|ctx| {
|
||||
ActionButton::new("Comment", PrimaryTheme)
|
||||
.with_keybinding(
|
||||
KeystrokeSource::Fixed(Keystroke::parse("cmdorctrl-enter").unwrap_or_default()),
|
||||
ctx,
|
||||
)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(CommentEditorAction::SaveComment);
|
||||
})
|
||||
.with_size(ButtonSize::Small)
|
||||
});
|
||||
|
||||
save_button.update(ctx, |button, ctx| {
|
||||
button.set_disabled(true, ctx);
|
||||
});
|
||||
|
||||
let close_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Cancel", NakedTheme)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(CommentEditorAction::CloseEditor);
|
||||
})
|
||||
.with_size(ButtonSize::Small)
|
||||
});
|
||||
|
||||
let remove_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Remove", DangerNakedTheme)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(CommentEditorAction::RemoveComment);
|
||||
})
|
||||
.with_size(ButtonSize::Small)
|
||||
});
|
||||
|
||||
(save_button, close_button, remove_button)
|
||||
}
|
||||
|
||||
fn update_save_button_state(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let is_empty = self.editor.as_ref(ctx).model().as_ref(ctx).is_empty(ctx);
|
||||
if is_empty != self.save_button_disabled {
|
||||
self.save_button_disabled = is_empty;
|
||||
self.save_button.update(ctx, |button, ctx| {
|
||||
button.set_disabled(is_empty, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_comment_model_event(
|
||||
&mut self,
|
||||
event: &PendingCommentEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
PendingCommentEvent::NewPendingComment(line) => self.attach_to_line(line, ctx),
|
||||
PendingCommentEvent::ReopenPendingComment {
|
||||
id,
|
||||
line,
|
||||
comment_text,
|
||||
origin,
|
||||
} => {
|
||||
self.reopen_saved_comment(id, Some(line.clone()), comment_text, origin, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_editor_event(&mut self, event: &EditorViewEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorViewEvent::Edited => {
|
||||
self.update_save_button_state(ctx);
|
||||
ctx.emit(CommentEditorEvent::ContentChanged);
|
||||
}
|
||||
EditorViewEvent::CmdEnter => {
|
||||
self.save_comment(ctx);
|
||||
}
|
||||
EditorViewEvent::EscapePressed => {
|
||||
// Dismiss the comment composer when pressing Escape on an empty draft.
|
||||
if self.editor.as_ref(ctx).model().as_ref(ctx).is_empty(ctx) {
|
||||
self.reset(ctx);
|
||||
ctx.emit(CommentEditorEvent::CloseEditor);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_to_line(&mut self, line: &EditorLineLocation, ctx: &mut ViewContext<Self>) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
// TODO: clear_buffer doesn't properly clear code blocks.
|
||||
// The `reset_with_markdown` call below is a band-aid fix.
|
||||
editor.reset_with_markdown("", ctx);
|
||||
});
|
||||
self.line = Some(line.clone());
|
||||
self.update_save_button_state(ctx);
|
||||
}
|
||||
|
||||
pub fn reopen_saved_comment(
|
||||
&mut self,
|
||||
id: &CommentId,
|
||||
line: Option<EditorLineLocation>,
|
||||
comment_text: &str,
|
||||
origin: &CommentOrigin,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.model().update(ctx, |model, ctx| {
|
||||
model.reset_with_markdown(comment_text, ctx);
|
||||
});
|
||||
});
|
||||
|
||||
self.comment_id = Some(*id);
|
||||
self.line = line;
|
||||
self.show_remove_button = true;
|
||||
self.is_imported_comment = origin.is_imported_from_github();
|
||||
|
||||
self.save_button.update(ctx, |button, ctx| {
|
||||
button.set_label("Update", ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
|
||||
self.update_save_button_state(ctx);
|
||||
}
|
||||
|
||||
fn reset(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
// TODO: system_clear_buffer doesn't properly clear code blocks.
|
||||
// The `reset_with_markdown` call below is a band-aid fix.
|
||||
editor.reset_with_markdown("", ctx);
|
||||
});
|
||||
self.comment_id = None;
|
||||
self.line = None;
|
||||
self.show_remove_button = false;
|
||||
self.is_imported_comment = false;
|
||||
|
||||
self.save_button.update(ctx, |button, ctx| {
|
||||
button.set_label("Comment", ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
|
||||
self.update_save_button_state(ctx);
|
||||
}
|
||||
|
||||
pub fn save_comment(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let comment_text = self.editor.as_ref(ctx).model().as_ref(ctx).markdown(ctx);
|
||||
|
||||
if comment_text.trim().is_empty() {
|
||||
log::debug!("CommentEditor attempted to save empty comment, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.emit(CommentEditorEvent::CommentSaved {
|
||||
id: self.comment_id,
|
||||
comment_text: comment_text.clone(),
|
||||
line: self.line.clone(),
|
||||
});
|
||||
self.reset(ctx);
|
||||
ctx.emit(CommentEditorEvent::CloseEditor);
|
||||
}
|
||||
|
||||
fn render_github_import_indicator(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
background: ColorU,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let sub_text_color = theme.sub_text_color(Fill::Solid(background)).into_solid();
|
||||
let icon = Icon::Github
|
||||
.to_warpui_icon(Fill::Solid(sub_text_color))
|
||||
.finish();
|
||||
|
||||
let label = Text::new(
|
||||
"Comment imported from GitHub".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.with_clip(ClipConfig::end())
|
||||
.with_color(sub_text_color)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(4.)
|
||||
.with_child(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., label).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_action_buttons(&self) -> Box<dyn Element> {
|
||||
let mut action_buttons = vec![ChildView::new(&self.close_button).finish()];
|
||||
if self.show_remove_button {
|
||||
action_buttons.push(ChildView::new(&self.remove_button).finish());
|
||||
}
|
||||
action_buttons.push(ChildView::new(&self.save_button).finish());
|
||||
|
||||
Flex::row()
|
||||
.with_spacing(4.)
|
||||
.with_children(action_buttons)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_footer_row(&self, appearance: &Appearance, background: ColorU) -> Box<dyn Element> {
|
||||
let action_buttons = self.render_action_buttons();
|
||||
let footer_row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
||||
if self.is_imported_comment {
|
||||
footer_row
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
self.render_github_import_indicator(appearance, background),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(action_buttons)
|
||||
.finish()
|
||||
} else {
|
||||
footer_row
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_child(action_buttons)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for CommentEditor {
|
||||
fn ui_name() -> &'static str {
|
||||
"CommentEditor"
|
||||
}
|
||||
|
||||
fn render(&self, ctx: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(ctx).as_ref(ctx);
|
||||
let theme = appearance.theme();
|
||||
let background = blended_colors::neutral_2(theme);
|
||||
let border_color = blended_colors::neutral_4(theme);
|
||||
|
||||
let footer_row = self.render_footer_row(appearance, background);
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(
|
||||
Clipped::new(ChildView::new(&self.editor).finish()).finish(),
|
||||
)
|
||||
.with_padding_bottom(4.)
|
||||
.with_padding_top(8.)
|
||||
.with_horizontal_padding(12.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(footer_row)
|
||||
.with_vertical_padding(8.)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_border(Border::top(1.).with_border_fill(border_color))
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(200.)
|
||||
.with_max_width(DEFAULT_COMMENT_MAX_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_background_color(background)
|
||||
.with_border(Border::all(1.).with_border_fill(border_color))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.focus(&self.editor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for CommentEditor {
|
||||
type Action = CommentEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &CommentEditorAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
CommentEditorAction::SaveComment => self.save_comment(ctx),
|
||||
CommentEditorAction::CloseEditor => {
|
||||
self.reset(ctx);
|
||||
ctx.emit(CommentEditorEvent::CloseEditor);
|
||||
}
|
||||
CommentEditorAction::RemoveComment => {
|
||||
if let Some(comment_id) = self.comment_id {
|
||||
self.reset(ctx);
|
||||
ctx.emit(CommentEditorEvent::DeleteComment { id: comment_id });
|
||||
ctx.emit(CommentEditorEvent::CloseEditor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CommentEditor {
|
||||
type Event = CommentEditorEvent;
|
||||
}
|
||||
|
||||
pub(crate) fn create_editable_comment_markdown_editor<V>(
|
||||
markdown_content: Option<&str>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) -> ViewHandle<RichTextEditorView>
|
||||
where
|
||||
V: View,
|
||||
{
|
||||
create_comment_markdown_editor_inner(
|
||||
markdown_content,
|
||||
false,
|
||||
Some(Pixels::new(DEFAULT_COMMENT_MAX_WIDTH)),
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn create_readonly_comment_markdown_editor<V>(
|
||||
markdown_content: &str,
|
||||
disable_scrolling: bool,
|
||||
max_width: Option<Pixels>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) -> ViewHandle<RichTextEditorView>
|
||||
where
|
||||
V: View,
|
||||
{
|
||||
let editor = create_comment_markdown_editor_inner(
|
||||
Some(markdown_content),
|
||||
disable_scrolling,
|
||||
max_width,
|
||||
ctx,
|
||||
);
|
||||
editor.update(ctx, |editor, ctx| {
|
||||
editor.set_interaction_state(InteractionState::Selectable, ctx);
|
||||
});
|
||||
editor
|
||||
}
|
||||
|
||||
fn create_comment_markdown_editor_inner<V>(
|
||||
markdown_content: Option<&str>,
|
||||
disable_scrolling: bool,
|
||||
max_width: Option<Pixels>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) -> ViewHandle<RichTextEditorView>
|
||||
where
|
||||
V: View,
|
||||
{
|
||||
let rich_text_styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
|
||||
let window_id = ctx.window_id();
|
||||
let parent_view_id = ctx.view_id();
|
||||
|
||||
let model = ctx.add_model(|ctx| NotebooksEditorModel::new(rich_text_styles, window_id, ctx));
|
||||
let links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
|
||||
|
||||
let parent_view_name = ctx.view_name(window_id, parent_view_id).unwrap_or_default();
|
||||
let parent_position_id = format!("{}_{}", parent_view_name, parent_view_id);
|
||||
|
||||
// Embedded objects (notebooks, workflows) are disabled since comments don't support them.
|
||||
// Shell command execution is disabled so Cmd/Ctrl+Enter submits the comment instead.
|
||||
// Block insertion menu (slash menu) is disabled since the comment editor is small.
|
||||
let editor = ctx.add_typed_action_view(|ctx| {
|
||||
RichTextEditorView::new(
|
||||
parent_position_id,
|
||||
model.clone(),
|
||||
links,
|
||||
RichTextEditorConfig {
|
||||
gutter_width: Some(0.0),
|
||||
embedded_objects_enabled: Some(false),
|
||||
vertical_expansion_behavior: Some(VerticalExpansionBehavior::GrowToMaxHeight),
|
||||
max_width,
|
||||
can_execute_shell_commands: Some(false),
|
||||
disable_block_insertion_menu: true,
|
||||
disable_scrolling,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
if let Some(comment_content) = markdown_content {
|
||||
model.update(ctx, |m, ctx| {
|
||||
m.reset_with_markdown(comment_content, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "comment_editor_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use warpui::{
|
||||
platform::WindowStyle, presenter::ChildView, App, Element, Entity, TypedActionView, View,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::{create_editable_comment_markdown_editor, create_readonly_comment_markdown_editor};
|
||||
use crate::notebooks::editor::view::RichTextEditorView;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
auth::AuthStateProvider,
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
notebooks::{
|
||||
editor::keys::NotebookKeybindings,
|
||||
link::{NotebookLinks, SessionSource},
|
||||
},
|
||||
search::files::model::FileSearchModel,
|
||||
server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::keys::TerminalKeybindings,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
workspace::ActiveSession,
|
||||
GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces,
|
||||
};
|
||||
|
||||
struct TestView {
|
||||
editor: ViewHandle<RichTextEditorView>,
|
||||
}
|
||||
|
||||
enum CommentEditorMode {
|
||||
Editable,
|
||||
Readonly,
|
||||
}
|
||||
|
||||
impl Entity for TestView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for TestView {
|
||||
fn ui_name() -> &'static str {
|
||||
"CommentEditorTestView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
ChildView::new(&self.editor).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for TestView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
fn initialize_editor(
|
||||
app: &mut App,
|
||||
mode: CommentEditorMode,
|
||||
) -> (
|
||||
WindowId,
|
||||
ViewHandle<RichTextEditorView>,
|
||||
ViewHandle<TestView>,
|
||||
) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
let global_resources = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources));
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
app.add_singleton_model(TerminalKeybindings::new);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
|
||||
let team_client_mock = Arc::new(MockTeamClient::new());
|
||||
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
team_client_mock.clone(),
|
||||
workspace_client_mock.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let (window, test_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
let window_id = ctx.window_id();
|
||||
let _links = ctx.add_model(|ctx| NotebookLinks::new(SessionSource::Active(window_id), ctx));
|
||||
let editor = match mode {
|
||||
CommentEditorMode::Editable => create_editable_comment_markdown_editor(None, ctx),
|
||||
CommentEditorMode::Readonly => create_readonly_comment_markdown_editor(
|
||||
"```rust\nfn main() {}\n```",
|
||||
false,
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
};
|
||||
TestView { editor }
|
||||
});
|
||||
|
||||
let editor_view = app.read(|ctx| test_view.as_ref(ctx).editor.clone());
|
||||
(window, editor_view, test_view)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_editable_comment_editor_keeps_final_trailing_newline_for_non_empty_code_blocks() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_window, editor_view, _test_view) =
|
||||
initialize_editor(&mut app, CommentEditorMode::Editable);
|
||||
let render_model = editor_view.read(&app, |editor, ctx| {
|
||||
editor.model().as_ref(ctx).render_state().clone()
|
||||
});
|
||||
|
||||
let pending_layout =
|
||||
render_model.read(&app, |render_state, _| render_state.layout_complete());
|
||||
pending_layout.await;
|
||||
assert_eq!(
|
||||
render_model.read(&app, |render_state, _| render_state.blocks()),
|
||||
1
|
||||
);
|
||||
|
||||
editor_view.update(&mut app, |editor, ctx| {
|
||||
editor.reset_with_markdown("```rust\nfn main() {}\n```", ctx);
|
||||
});
|
||||
|
||||
let pending_layout =
|
||||
render_model.read(&app, |render_state, _| render_state.layout_complete());
|
||||
pending_layout.await;
|
||||
assert_eq!(
|
||||
render_model.read(&app, |render_state, _| render_state.blocks()),
|
||||
2
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_readonly_comment_editor_hides_final_trailing_newline_for_non_empty_code_blocks() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_window, editor_view, _test_view) =
|
||||
initialize_editor(&mut app, CommentEditorMode::Readonly);
|
||||
let render_model = editor_view.read(&app, |editor, ctx| {
|
||||
editor.model().as_ref(ctx).render_state().clone()
|
||||
});
|
||||
|
||||
let pending_layout =
|
||||
render_model.read(&app, |render_state, _| render_state.layout_complete());
|
||||
pending_layout.await;
|
||||
assert_eq!(
|
||||
render_model.read(&app, |render_state, _| render_state.blocks()),
|
||||
1
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use chrono::{DateTime, Local};
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::code::editor::line::EditorLineLocation;
|
||||
use crate::code_review::comments::{
|
||||
AttachedReviewComment, AttachedReviewCommentTarget, CommentId, CommentOrigin, LineDiffContent,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PendingCommentEvent {
|
||||
NewPendingComment(EditorLineLocation),
|
||||
ReopenPendingComment {
|
||||
id: CommentId,
|
||||
line: EditorLineLocation,
|
||||
comment_text: String,
|
||||
origin: CommentOrigin,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum PendingComment {
|
||||
Closed,
|
||||
Open { line: EditorLineLocation },
|
||||
}
|
||||
|
||||
pub struct EditorCommentsModel {
|
||||
pub pending_comment: PendingComment,
|
||||
}
|
||||
|
||||
impl EditorCommentsModel {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
pending_comment: PendingComment::Closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for EditorCommentsModel {
|
||||
type Event = PendingCommentEvent;
|
||||
}
|
||||
|
||||
/// Used solely at the CodeEditorView level, when we don't know
|
||||
/// the file path, and later converted to a full `AttachedReviewComment`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EditorReviewComment {
|
||||
pub id: CommentId,
|
||||
pub line: EditorLineLocation,
|
||||
pub diff_content: LineDiffContent,
|
||||
pub comment_content: String,
|
||||
pub last_update_time: DateTime<Local>,
|
||||
}
|
||||
|
||||
impl EditorReviewComment {
|
||||
pub(crate) fn new(
|
||||
line: EditorLineLocation,
|
||||
diff_content: LineDiffContent,
|
||||
comment_content: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: CommentId::new(),
|
||||
line,
|
||||
diff_content,
|
||||
comment_content,
|
||||
last_update_time: Local::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_id(
|
||||
id: CommentId,
|
||||
line: EditorLineLocation,
|
||||
diff_content: LineDiffContent,
|
||||
comment_content: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
line,
|
||||
diff_content,
|
||||
comment_content,
|
||||
last_update_time: Local::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<AttachedReviewComment> for EditorReviewComment {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(comment: AttachedReviewComment) -> Result<Self, Self::Error> {
|
||||
match comment.target {
|
||||
AttachedReviewCommentTarget::Line { content, line, .. } => Ok(EditorReviewComment {
|
||||
id: comment.id,
|
||||
line,
|
||||
diff_content: content,
|
||||
comment_content: comment.content,
|
||||
last_update_time: comment.last_update_time,
|
||||
}),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
|
||||
use std::{collections::HashMap, ops::Range, rc::Rc, sync::Arc};
|
||||
|
||||
use futures::stream::AbortHandle;
|
||||
use itertools::Itertools;
|
||||
use pathfinder_color::ColorU;
|
||||
use rangemap::RangeMap;
|
||||
use similar::{ChangeTag, DiffOp, TextDiff};
|
||||
use string_offset::CharOffset;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_editor::{
|
||||
content::{edit::TemporaryBlock, version::BufferVersion},
|
||||
multiline::{AnyMultilineString, MultilineStr, MultilineString, LF},
|
||||
render::model::{Decoration, LineCount, LineDecoration},
|
||||
};
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use super::super::DiffResult;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
code::editor::{line::EditorLineLocation, line_iterator::LineIterator},
|
||||
};
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
|
||||
const OVERLAY_ALPHA: u8 = 56;
|
||||
const INLINE_OVERLAY_ALPHA: u8 = 71;
|
||||
|
||||
/// Get the theme-appropriate add color
|
||||
pub(crate) fn add_color(appearance: &Appearance) -> ColorU {
|
||||
AnsiColorIdentifier::Green
|
||||
.to_ansi_color(&appearance.theme().terminal_colors().normal)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Get the theme-appropriate remove color
|
||||
pub(crate) fn remove_color(appearance: &Appearance) -> ColorU {
|
||||
AnsiColorIdentifier::Red
|
||||
.to_ansi_color(&appearance.theme().terminal_colors().normal)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Get the theme-appropriate replace color
|
||||
pub(crate) fn replace_color(appearance: &Appearance) -> ColorU {
|
||||
AnsiColorIdentifier::Yellow
|
||||
.to_ansi_color(&appearance.theme().terminal_colors().normal)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Get the theme-appropriate remove overlay color
|
||||
pub(crate) fn remove_overlay_color(appearance: &Appearance) -> ColorU {
|
||||
let ansi_color =
|
||||
AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal);
|
||||
let mut color: ColorU = ansi_color.into();
|
||||
color.a = OVERLAY_ALPHA;
|
||||
color
|
||||
}
|
||||
|
||||
/// Get the theme-appropriate add overlay color
|
||||
pub(crate) fn add_overlay_color(appearance: &Appearance) -> ColorU {
|
||||
let ansi_color =
|
||||
AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal);
|
||||
let mut color: ColorU = ansi_color.into();
|
||||
color.a = OVERLAY_ALPHA;
|
||||
color
|
||||
}
|
||||
|
||||
/// Get the theme-appropriate add inline overlay color
|
||||
pub(crate) fn add_inline_overlay_color(appearance: &Appearance) -> ColorU {
|
||||
let ansi_color =
|
||||
AnsiColorIdentifier::Green.to_ansi_color(&appearance.theme().terminal_colors().normal);
|
||||
let mut color: ColorU = ansi_color.into();
|
||||
color.a = INLINE_OVERLAY_ALPHA;
|
||||
color
|
||||
}
|
||||
|
||||
/// Get the theme-appropriate remove inline overlay color
|
||||
pub(crate) fn remove_inline_overlay_color(appearance: &Appearance) -> ColorU {
|
||||
let ansi_color =
|
||||
AnsiColorIdentifier::Red.to_ansi_color(&appearance.theme().terminal_colors().normal);
|
||||
let mut color: ColorU = ansi_color.into();
|
||||
color.a = INLINE_OVERLAY_ALPHA;
|
||||
color
|
||||
}
|
||||
|
||||
pub enum DiffModelEvent {
|
||||
DiffUpdated {
|
||||
version: BufferVersion,
|
||||
should_recalculate_hidden_lines: bool,
|
||||
},
|
||||
UnifiedDiffComputed(Rc<DiffResult>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub enum ChangeType {
|
||||
Replacement {
|
||||
replaced_range: Range<usize>,
|
||||
insertion: Vec<Range<usize>>,
|
||||
deletion: Vec<Range<usize>>,
|
||||
},
|
||||
Addition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DiffStatus {
|
||||
/// A non-deletion change that maps the current range of content to an old range in base.
|
||||
change_mapping: RangeMap<usize, ChangeType>,
|
||||
/// A deletion that maps a line index in current content to an old range in base.
|
||||
deletion_mapping: HashMap<usize, Range<usize>>,
|
||||
}
|
||||
|
||||
impl DiffStatus {
|
||||
/// Returns the number of lines added and removed in the current diff.
|
||||
pub fn get_diff_lines(&self) -> (usize, usize) {
|
||||
let mut lines_added = 0;
|
||||
let mut lines_removed = 0;
|
||||
|
||||
// Count changes/additions
|
||||
for (new_range, change_type) in self.change_mapping.iter() {
|
||||
let new_range_lines =
|
||||
(LineCount::from(new_range.end) - LineCount::from(new_range.start)).as_usize();
|
||||
match change_type {
|
||||
ChangeType::Addition => lines_added += new_range_lines,
|
||||
ChangeType::Replacement { replaced_range, .. } => {
|
||||
let old_range_lines = (LineCount::from(replaced_range.end)
|
||||
- LineCount::from(replaced_range.start))
|
||||
.as_usize();
|
||||
lines_added += new_range_lines;
|
||||
lines_removed += old_range_lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count deletions
|
||||
for range in self.deletion_mapping.values() {
|
||||
lines_removed += (LineCount::from(range.end) - LineCount::from(range.start)).as_usize();
|
||||
}
|
||||
|
||||
(lines_added, lines_removed)
|
||||
}
|
||||
|
||||
/// Retrieve the hunk to render for a given line number.
|
||||
pub fn diff_hunk(
|
||||
&self,
|
||||
line_num: LineCount,
|
||||
appearance: &Appearance,
|
||||
) -> Option<DiffHunkDisplay> {
|
||||
let line_num = line_num.as_usize();
|
||||
if self.deletion_mapping.contains_key(&line_num) {
|
||||
return Some(DiffHunkDisplay::Remove(remove_color(appearance)));
|
||||
}
|
||||
|
||||
match self.change_mapping.get(&line_num) {
|
||||
Some(ChangeType::Replacement { .. }) => Some(DiffHunkDisplay::Replacement {
|
||||
collapsed_color: replace_color(appearance),
|
||||
add_color: add_color(appearance),
|
||||
remove_color: remove_color(appearance),
|
||||
}),
|
||||
Some(ChangeType::Addition) => Some(DiffHunkDisplay::Add(add_color(appearance))),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the range of diff hunk lines (if any) containing the given line.
|
||||
/// line_count is the line count assigned in the EditorWrapper element.
|
||||
/// This is 0-indexed.
|
||||
/// For deleted diff hunks, all lines will have the line_count of the line directly after
|
||||
/// the deleted section in the new current state of the file.
|
||||
/// For changed diff hunks, all lines in the old version (removed section of the hunk)
|
||||
/// will have the line number of the first line in the new version of the hunk.
|
||||
pub fn removed_diff_range(&self, line_count: LineCount) -> Option<Range<LineCount>> {
|
||||
let line_num = line_count.as_usize();
|
||||
if self.deletion_mapping.contains_key(&(line_num)) {
|
||||
return Some(LineCount::from(line_num)..LineCount::from(line_num));
|
||||
}
|
||||
|
||||
// Check if this is a replacement (change with removed lines)
|
||||
self.added_diff_range(line_count)
|
||||
}
|
||||
|
||||
/// Return the range of diff hunk lines (if any) containing the given line.
|
||||
/// line_count is the line count assigned in the EditorWrapper element.
|
||||
/// This is 0-indexed.
|
||||
pub fn added_diff_range(&self, line_num: LineCount) -> Option<Range<LineCount>> {
|
||||
let line_num = line_num.as_usize();
|
||||
self.change_mapping
|
||||
.get_key_value(&line_num)
|
||||
.map(|(key, _)| LineCount::from(key.start)..LineCount::from(key.end))
|
||||
}
|
||||
}
|
||||
|
||||
/// The colors used to represent the diff hunks in the editor.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum DiffHunkDisplay {
|
||||
Add(ColorU),
|
||||
Replacement {
|
||||
collapsed_color: ColorU,
|
||||
add_color: ColorU,
|
||||
remove_color: ColorU,
|
||||
},
|
||||
Remove(ColorU),
|
||||
}
|
||||
|
||||
/// A single renderable diff hunk. It contains the information needed to decorate the render model.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RenderableDiffHunk {
|
||||
Add {
|
||||
line_decoration: LineDecoration,
|
||||
},
|
||||
Replace {
|
||||
line_decoration: LineDecoration,
|
||||
inline_highlights: Vec<(usize, Range<usize>)>,
|
||||
removed_lines: Vec<TemporaryBlock>,
|
||||
},
|
||||
Deletion {
|
||||
removed_lines: Vec<TemporaryBlock>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Model that tracks the line-by-line diff status of the editor content.
|
||||
pub struct DiffModel {
|
||||
/// Store base in an Arc to avoid cloning the underlying data on every content change.
|
||||
base: Option<Arc<MultilineString<LF>>>,
|
||||
status: DiffStatus,
|
||||
abort_handle: Option<(AbortHandle, BufferVersion)>,
|
||||
}
|
||||
|
||||
impl DiffModel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base: None,
|
||||
status: DiffStatus::default(),
|
||||
abort_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total number of diff hunks in the current diff model.
|
||||
pub fn diff_hunk_count(&self) -> usize {
|
||||
self.status.change_mapping.len() + self.status.deletion_mapping.len()
|
||||
}
|
||||
|
||||
/// Given an index of a diff hunk, expand it to the range of lines the hunk describes
|
||||
/// in the current buffer.
|
||||
pub fn line_range_by_diff_hunk_index(&self, index: usize) -> Option<Range<usize>> {
|
||||
self.added_or_changed_lines()
|
||||
.chain(
|
||||
self.status
|
||||
.deletion_mapping
|
||||
.keys()
|
||||
.map(|index| *index..*index),
|
||||
)
|
||||
.sorted_by(|a, b| Ord::cmp(&a.start, &b.start))
|
||||
.nth(index)
|
||||
}
|
||||
|
||||
/// Get a single renderable diff hunk by its index.
|
||||
pub fn renderable_diff_hunk_by_index<'a>(
|
||||
&self,
|
||||
index: usize,
|
||||
lines: &mut LineIterator<'a, impl Iterator<Item = &'a str>>,
|
||||
appearance: &Appearance,
|
||||
) -> Option<RenderableDiffHunk> {
|
||||
let (range, is_addition) = self.diff_by_index(index)?;
|
||||
|
||||
if !is_addition {
|
||||
let deleted_range = self.status.deletion_mapping.get(&range.start)?;
|
||||
let mut removed_lines = Vec::with_capacity(deleted_range.len());
|
||||
if let Ok(lines_in_range) = lines.lines_in_range(deleted_range) {
|
||||
for line in lines_in_range {
|
||||
let mut content = line.to_string();
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
removed_lines.push(TemporaryBlock {
|
||||
content,
|
||||
insert_before: LineCount::from(range.start),
|
||||
line_decoration: Some(remove_overlay_color(appearance).into()),
|
||||
inline_text_decorations: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some(RenderableDiffHunk::Deletion { removed_lines })
|
||||
} else {
|
||||
match self.status.change_mapping.get(&range.start)? {
|
||||
ChangeType::Replacement {
|
||||
replaced_range,
|
||||
insertion,
|
||||
deletion,
|
||||
} => {
|
||||
let mut removed_lines = Vec::new();
|
||||
|
||||
// Inline highlight indices are given over the entire multiline range.
|
||||
// We need to split them into per-line decorations.
|
||||
let mut start_char = 0;
|
||||
if let Ok(lines_in_range) = lines.lines_in_range(replaced_range) {
|
||||
for line in lines_in_range {
|
||||
let mut content = line.to_string();
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
let inline_text_decorations = deletion
|
||||
.iter()
|
||||
.filter_map(|inline| {
|
||||
let line_start = start_char;
|
||||
let line_end = start_char + line.chars().count();
|
||||
|
||||
let overlap_start = inline.start.max(line_start);
|
||||
let overlap_end = inline.end.min(line_end);
|
||||
|
||||
if overlap_start < overlap_end {
|
||||
Some(
|
||||
Decoration::new(
|
||||
CharOffset::from(overlap_start - line_start),
|
||||
CharOffset::from(overlap_end - line_start),
|
||||
)
|
||||
.with_background(Fill::Solid(
|
||||
remove_inline_overlay_color(appearance),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect_vec();
|
||||
|
||||
removed_lines.push(TemporaryBlock {
|
||||
content,
|
||||
insert_before: LineCount::from(range.start),
|
||||
line_decoration: Some(remove_overlay_color(appearance).into()),
|
||||
inline_text_decorations,
|
||||
});
|
||||
|
||||
start_char += line.chars().count() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
Some(RenderableDiffHunk::Replace {
|
||||
line_decoration: LineDecoration {
|
||||
start: LineCount::from(range.start),
|
||||
end: LineCount::from(range.end),
|
||||
overlay: add_overlay_color(appearance).into(),
|
||||
},
|
||||
inline_highlights: insertion
|
||||
.iter()
|
||||
.map(|inline_change| (range.start, inline_change.clone()))
|
||||
.collect(),
|
||||
removed_lines,
|
||||
})
|
||||
}
|
||||
ChangeType::Addition => Some(RenderableDiffHunk::Add {
|
||||
line_decoration: LineDecoration {
|
||||
start: LineCount::from(range.start),
|
||||
end: LineCount::from(range.end),
|
||||
overlay: add_overlay_color(appearance).into(),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of diff hunks before the given line number.
|
||||
pub fn diff_hunk_count_before_line(&self, line: usize) -> usize {
|
||||
self.added_or_changed_lines()
|
||||
.chain(
|
||||
self.status
|
||||
.deletion_mapping
|
||||
.keys()
|
||||
.map(|index| *index..*index + 1),
|
||||
)
|
||||
.filter(|range| range.start < line)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Given a diff hunk index, calculate what is the reverse action that undo this diff.
|
||||
pub fn reverse_action_by_diff_hunk_index(
|
||||
&self,
|
||||
index: usize,
|
||||
) -> Option<(Range<usize>, String)> {
|
||||
let line_range = self.line_range_by_diff_hunk_index(index)?;
|
||||
|
||||
if let Some(replaced_range) = self.status.deletion_mapping.get(&line_range.start) {
|
||||
let text = self.base_text_by_line_range(replaced_range)?;
|
||||
return Some((line_range, text));
|
||||
}
|
||||
|
||||
if let Some(change) = self.status.change_mapping.get(&line_range.start) {
|
||||
return match change {
|
||||
ChangeType::Addition => Some((line_range, "".to_string())),
|
||||
ChangeType::Replacement { replaced_range, .. } => {
|
||||
let text = self.base_text_by_line_range(replaced_range)?;
|
||||
return Some((line_range, text));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Given a range of lines, return the corresponding substring in the base text.
|
||||
/// Note that all lines will end with a trailing newline.
|
||||
fn base_text_by_line_range(&self, replaced_range: &Range<usize>) -> Option<String> {
|
||||
let base_text = self.base.as_ref()?;
|
||||
let mut text = base_text
|
||||
.lines()
|
||||
.skip(replaced_range.start)
|
||||
.take(replaced_range.len())
|
||||
.join("\n");
|
||||
|
||||
text.push('\n');
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// Returns the content of a deleted line given the location of a removed line.
|
||||
/// Returns None if the info is not for a removed line or if any lookup fails.
|
||||
pub fn deleted_line_content(&self, info: &EditorLineLocation) -> Option<String> {
|
||||
let index = self.deleted_line_to_base_line_index(info)?;
|
||||
self.base_line(index)
|
||||
}
|
||||
|
||||
/// Convert a deleted line location to a line index in the base version of the text.
|
||||
pub fn deleted_line_to_base_line_index(&self, info: &EditorLineLocation) -> Option<usize> {
|
||||
// Extract line_number and index from Removed variant
|
||||
let (line_number, index) = match info {
|
||||
EditorLineLocation::Removed {
|
||||
line_number, index, ..
|
||||
} => (line_number.as_usize(), *index),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// First check if this is a pure deletion. Note that deletion maps to line AFTER the removed line.
|
||||
// CODE-1638: Use line_number + 1 to align with deletion_mapping's off-by-one convention.
|
||||
if let Some(removed_range) = self.status.deletion_mapping.get(&line_number) {
|
||||
return Some(removed_range.start + index);
|
||||
}
|
||||
|
||||
// Check if this is a replacement (change with removed lines)
|
||||
if let Some(ChangeType::Replacement { replaced_range, .. }) =
|
||||
self.status.change_mapping.get(&line_number)
|
||||
{
|
||||
return Some(replaced_range.start + index);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Convert a line index in the base version of the text to an editor line location.
|
||||
pub fn base_line_index_to_line_location(&self, index: usize) -> Option<EditorLineLocation> {
|
||||
for (line_range, change) in self.status.change_mapping.iter() {
|
||||
if let ChangeType::Replacement { replaced_range, .. } = change {
|
||||
if replaced_range.contains(&index) {
|
||||
return Some(EditorLineLocation::Removed {
|
||||
// Subtracting 1 as the diff is currently represented as attaching to the _previous line_.
|
||||
line_number: LineCount::from(line_range.start),
|
||||
line_range: LineCount::from(line_range.start)
|
||||
..LineCount::from(line_range.end),
|
||||
index: index - replaced_range.start,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (line_range, replaced_range) in self.status.deletion_mapping.iter() {
|
||||
if replaced_range.contains(&index) {
|
||||
return Some(EditorLineLocation::Removed {
|
||||
line_number: LineCount::from(*line_range),
|
||||
line_range: LineCount::from(*line_range)..LineCount::from(*line_range),
|
||||
index: index - replaced_range.start,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_line_added_or_changed(&self, line_num: &LineCount) -> bool {
|
||||
let line_num = line_num.as_usize();
|
||||
self.status.change_mapping.get(&line_num).is_some()
|
||||
}
|
||||
|
||||
pub fn base_line(&self, line_index: usize) -> Option<String> {
|
||||
// Extract the line content
|
||||
let line = self.base.as_ref()?.lines().nth(line_index)?;
|
||||
Some(line.to_string())
|
||||
}
|
||||
|
||||
pub fn base_line_count(&self) -> usize {
|
||||
self.base
|
||||
.as_ref()
|
||||
.map(|base| base.lines().count())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Returns an iterator over all the line numbers in the current buffer that are
|
||||
/// added or changed from the base buffer.
|
||||
pub fn added_or_changed_lines(&self) -> impl Iterator<Item = Range<usize>> + '_ {
|
||||
self.status
|
||||
.change_mapping
|
||||
.iter()
|
||||
.map(|(row_range, _)| row_range.clone())
|
||||
}
|
||||
|
||||
pub fn modified_lines(&self) -> impl Iterator<Item = Range<usize>> + '_ {
|
||||
self.added_or_changed_lines().chain(
|
||||
self.status
|
||||
.deletion_mapping
|
||||
.keys()
|
||||
.map(|index| *index..*index + 1),
|
||||
)
|
||||
}
|
||||
|
||||
fn diff_by_index(&self, index: usize) -> Option<(Range<usize>, bool)> {
|
||||
self.added_or_changed_lines()
|
||||
.map(|range| (range, true))
|
||||
.chain(
|
||||
self.status
|
||||
.deletion_mapping
|
||||
.keys()
|
||||
.map(|index| (*index..*index + 1, false)),
|
||||
)
|
||||
.sorted_by(|a, b| Ord::cmp(&a.0.start, &b.0.start))
|
||||
.nth(index)
|
||||
}
|
||||
|
||||
pub fn diff_status(&self) -> &DiffStatus {
|
||||
&self.status
|
||||
}
|
||||
|
||||
pub fn set_base(&mut self, base: MultilineString<LF>) {
|
||||
self.base = Some(Arc::new(base));
|
||||
if let Some((abort_handle, _)) = self.abort_handle.take() {
|
||||
abort_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base(&self) -> Option<Arc<MultilineString<LF>>> {
|
||||
self.base.clone()
|
||||
}
|
||||
|
||||
/// Given the new content, compute the new set of diff contents.
|
||||
pub fn compute_diff(
|
||||
&mut self,
|
||||
new: MultilineString<LF>,
|
||||
should_recalculate_hidden_lines: bool,
|
||||
version: BufferVersion,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some((abort_handle, current_version)) = self.abort_handle.take() {
|
||||
// Do not abort a diff computation for the same buffer version. Early return instead.
|
||||
if current_version != version {
|
||||
abort_handle.abort();
|
||||
} else {
|
||||
self.abort_handle = Some((abort_handle, version));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(base_text) = self.base.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let handle = ctx
|
||||
.spawn(
|
||||
async move { Self::compute_diff_internal(&base_text, &new).await },
|
||||
move |model, (change_mapping, deletion_mapping), ctx| {
|
||||
model.status.change_mapping = change_mapping;
|
||||
model.status.deletion_mapping = deletion_mapping;
|
||||
log::debug!("diff status updated: {:#?}", &model.status);
|
||||
ctx.emit(DiffModelEvent::DiffUpdated {
|
||||
should_recalculate_hidden_lines,
|
||||
version,
|
||||
});
|
||||
},
|
||||
)
|
||||
.abort_handle();
|
||||
|
||||
self.abort_handle = Some((handle, version));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn compute_diff_for_test(&mut self, new: String) {
|
||||
let Some(base_text) = self.base.clone() else {
|
||||
return;
|
||||
};
|
||||
let new = AnyMultilineString::infer(new);
|
||||
|
||||
let (change_mapping, deletion_mapping) =
|
||||
Self::compute_diff_internal(&base_text, new.to_format().as_ref()).await;
|
||||
self.status.change_mapping = change_mapping;
|
||||
self.status.deletion_mapping = deletion_mapping;
|
||||
}
|
||||
|
||||
pub fn retrieve_unified_diff(
|
||||
&mut self,
|
||||
new: AnyMultilineString,
|
||||
file_name: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(base_text) = self.base.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let new = new.to_format();
|
||||
Self::retrieve_unified_diff_internal(&base_text, new.as_ref(), file_name.as_str())
|
||||
.await
|
||||
},
|
||||
|_, unified_diff, ctx| {
|
||||
ctx.emit(DiffModelEvent::UnifiedDiffComputed(Rc::new(unified_diff)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async fn retrieve_unified_diff_internal(
|
||||
base: &MultilineStr<LF>,
|
||||
new: &MultilineStr<LF>,
|
||||
file_name: &str,
|
||||
) -> DiffResult {
|
||||
if base == new {
|
||||
return DiffResult {
|
||||
unified_diff: String::new(),
|
||||
lines_added: 0,
|
||||
lines_removed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Show 3 context lines (standard of git diff).
|
||||
let text_diff = TextDiff::from_lines(base.as_str(), new.as_str());
|
||||
|
||||
// Calculate diff statistics.
|
||||
let mut lines_added = 0;
|
||||
let mut lines_removed = 0;
|
||||
|
||||
for op in text_diff.ops() {
|
||||
match op {
|
||||
DiffOp::Equal { .. } => (),
|
||||
DiffOp::Delete { old_len, .. } => lines_removed += old_len,
|
||||
DiffOp::Insert { new_len, .. } => lines_added += new_len,
|
||||
DiffOp::Replace {
|
||||
old_len, new_len, ..
|
||||
} => {
|
||||
lines_added += new_len;
|
||||
lines_removed += old_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DiffResult {
|
||||
unified_diff: text_diff
|
||||
.unified_diff()
|
||||
.context_radius(3)
|
||||
.header(file_name, file_name)
|
||||
.missing_newline_hint(false)
|
||||
.to_string(),
|
||||
lines_added,
|
||||
lines_removed,
|
||||
}
|
||||
}
|
||||
|
||||
async fn compute_diff_internal(
|
||||
base: &MultilineStr<LF>,
|
||||
new: &MultilineStr<LF>,
|
||||
) -> (RangeMap<usize, ChangeType>, HashMap<usize, Range<usize>>) {
|
||||
let diffs = TextDiff::configure()
|
||||
.algorithm(similar::Algorithm::Patience)
|
||||
.diff_lines(base.as_str(), new.as_str());
|
||||
let mut deletion_mapping = HashMap::new();
|
||||
let mut change_mapping = RangeMap::new();
|
||||
|
||||
for change in diffs.ops() {
|
||||
futures_lite::future::yield_now().await;
|
||||
match change {
|
||||
DiffOp::Equal { .. } => continue,
|
||||
DiffOp::Delete {
|
||||
old_index,
|
||||
old_len,
|
||||
new_index,
|
||||
} => {
|
||||
deletion_mapping.insert(*new_index, *old_index..*old_index + *old_len);
|
||||
}
|
||||
DiffOp::Insert {
|
||||
new_index, new_len, ..
|
||||
} => change_mapping.insert(*new_index..*new_index + *new_len, ChangeType::Addition),
|
||||
DiffOp::Replace {
|
||||
old_index,
|
||||
old_len,
|
||||
new_index,
|
||||
new_len,
|
||||
} => {
|
||||
let (inline_deletion, inline_insertion) = record_replacement(change, &diffs);
|
||||
|
||||
change_mapping.insert(
|
||||
*new_index..*new_index + *new_len,
|
||||
ChangeType::Replacement {
|
||||
replaced_range: *old_index..*old_index + *old_len,
|
||||
insertion: inline_insertion,
|
||||
deletion: inline_deletion,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coalesce_replacements(&diffs, &mut deletion_mapping, &mut change_mapping);
|
||||
|
||||
(change_mapping, deletion_mapping)
|
||||
}
|
||||
}
|
||||
|
||||
/// `similar` can represent a logical replacement as separate Delete + Insert ops at the same
|
||||
/// `new_index`. When that happens, we end up with a deletion hunk and an addition hunk for
|
||||
/// the same logical change. Coalesce those into a single Replacement entry.
|
||||
fn coalesce_replacements<'a>(
|
||||
diffs: &'a TextDiff<'a, 'a, 'a, str>,
|
||||
deletion_mapping: &mut HashMap<usize, Range<usize>>,
|
||||
change_mapping: &mut RangeMap<usize, ChangeType>,
|
||||
) {
|
||||
let mut deletions_to_remove = Vec::new();
|
||||
let mut additions_to_remove = Vec::new();
|
||||
let mut replacements_to_insert = Vec::new();
|
||||
|
||||
for (&new_index, old_range) in deletion_mapping.iter() {
|
||||
let Some((new_range, change)) = change_mapping.get_key_value(&new_index) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if new_range.start != new_index {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !matches!(change, ChangeType::Addition) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let old_len = old_range.len();
|
||||
let new_len = new_range.len();
|
||||
|
||||
let replace_op = DiffOp::Replace {
|
||||
old_index: old_range.start,
|
||||
old_len,
|
||||
new_index,
|
||||
new_len,
|
||||
};
|
||||
let (inline_deletion, inline_insertion) = record_replacement(&replace_op, diffs);
|
||||
|
||||
deletions_to_remove.push(new_index);
|
||||
additions_to_remove.push(new_range.clone());
|
||||
replacements_to_insert.push((
|
||||
new_range.clone(),
|
||||
ChangeType::Replacement {
|
||||
replaced_range: old_range.clone(),
|
||||
insertion: inline_insertion,
|
||||
deletion: inline_deletion,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
for new_range in additions_to_remove {
|
||||
change_mapping.remove(new_range);
|
||||
}
|
||||
deletion_mapping.retain(|new_index, _| !deletions_to_remove.contains(new_index));
|
||||
change_mapping.extend(replacements_to_insert);
|
||||
}
|
||||
|
||||
/// Given a replace operation, iterate through its associated diff hunks and collect
|
||||
/// the deletions and insertions from the generated diff.
|
||||
/// Return order: (deletions, insertions)
|
||||
fn record_replacement<'a>(
|
||||
replace_op: &DiffOp,
|
||||
diffs: &'a TextDiff<'a, 'a, 'a, str>,
|
||||
) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
|
||||
let mut old_offset = 0;
|
||||
let mut new_offset = 0;
|
||||
let mut inline_deletion = Vec::new();
|
||||
let mut inline_insertion = Vec::new();
|
||||
|
||||
for inline_diff in diffs.iter_inline_changes(replace_op) {
|
||||
match inline_diff.tag() {
|
||||
ChangeTag::Equal => {
|
||||
log::warn!("Unexpected equal change tag in a replace op");
|
||||
continue;
|
||||
}
|
||||
ChangeTag::Delete => {
|
||||
record_inline_diff_as_changes(&mut inline_deletion, &mut old_offset, inline_diff);
|
||||
}
|
||||
ChangeTag::Insert => {
|
||||
record_inline_diff_as_changes(&mut inline_insertion, &mut new_offset, inline_diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(inline_deletion, inline_insertion)
|
||||
}
|
||||
|
||||
fn record_inline_diff_as_changes(
|
||||
changes: &mut Vec<Range<usize>>,
|
||||
offset: &mut usize,
|
||||
inline_diff: similar::InlineChange<str>,
|
||||
) {
|
||||
for (highlight, val) in inline_diff.values() {
|
||||
let char_len = val.chars().count();
|
||||
|
||||
if *highlight {
|
||||
changes.push(*offset..*offset + char_len);
|
||||
}
|
||||
*offset += char_len;
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DiffModel {
|
||||
type Event = DiffModelEvent;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "diff_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,456 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rangemap::RangeMap;
|
||||
use unindent::Unindent as _;
|
||||
use warp_editor::multiline::{MultilineStr, MultilineString};
|
||||
|
||||
use crate::code::editor::diff::ChangeType;
|
||||
|
||||
use super::DiffModel;
|
||||
|
||||
#[test]
|
||||
fn test_diff_generation() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let (change_mapping, deletion_mapping) = DiffModel::compute_diff_internal(
|
||||
MultilineStr::try_new("Hello World\nThis is the second line.\nThis is the third.")
|
||||
.unwrap(),
|
||||
MultilineStr::try_new(
|
||||
"Hallo Welt\nThis is the second line.\nThis is life.\nMoar and more",
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
change_mapping,
|
||||
RangeMap::from_iter([
|
||||
(
|
||||
0..1,
|
||||
ChangeType::Replacement {
|
||||
replaced_range: 0..1,
|
||||
insertion: vec![0..5, 6..10],
|
||||
deletion: vec![0..5, 6..11]
|
||||
}
|
||||
),
|
||||
(
|
||||
2..4,
|
||||
ChangeType::Replacement {
|
||||
replaced_range: 2..3,
|
||||
insertion: vec![8..13, 14..22, 23..27,],
|
||||
deletion: vec![8..11, 12..18]
|
||||
}
|
||||
)
|
||||
])
|
||||
);
|
||||
assert!(deletion_mapping.is_empty());
|
||||
|
||||
let (change_mapping, deletion_mapping) = DiffModel::compute_diff_internal(
|
||||
MultilineStr::try_new("Hello World\nThis is the second line.\nThis is the third.")
|
||||
.unwrap(),
|
||||
MultilineStr::try_new("Hello World\nThis is the third.").unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert!(change_mapping.is_empty());
|
||||
assert_eq!(deletion_mapping, HashMap::from([(1, 1..2)]));
|
||||
|
||||
let (change_mapping, deletion_mapping) = DiffModel::compute_diff_internal(
|
||||
MultilineStr::try_new("Hello\nWorld\n").unwrap(),
|
||||
MultilineStr::try_new("Hallo\nWorlds\n").unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
change_mapping,
|
||||
RangeMap::from_iter([(
|
||||
0..2,
|
||||
ChangeType::Replacement {
|
||||
replaced_range: 0..2,
|
||||
insertion: vec![0..5, 6..12],
|
||||
deletion: vec![0..5, 6..11]
|
||||
}
|
||||
),])
|
||||
);
|
||||
assert!(deletion_mapping.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
diff_model.set_base(MultilineString::apply(
|
||||
"Hello World\nThis is the second line\n",
|
||||
));
|
||||
diff_model
|
||||
.compute_diff_for_test("Hallo World\nThis is the second line\nNew".to_string())
|
||||
.await;
|
||||
|
||||
assert_eq!(diff_model.diff_hunk_count(), 2);
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(0),
|
||||
Some((0..1, "Hello World\n".to_string()))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(1),
|
||||
Some((2..3, "".to_string()))
|
||||
);
|
||||
|
||||
diff_model
|
||||
.compute_diff_for_test("Hello World\n".to_string())
|
||||
.await;
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(0),
|
||||
Some((1..1, "This is the second line\n".to_string()))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action_replaced_newlines() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
|
||||
abc
|
||||
def
|
||||
|
||||
ghi
|
||||
|
||||
jkl
|
||||
mno
|
||||
|
||||
|
||||
pqr
|
||||
|
||||
stu
|
||||
|
||||
"
|
||||
.unindent();
|
||||
diff_model.set_base(MultilineString::apply(&base_text));
|
||||
|
||||
// Replace with text:
|
||||
// * Leading newline before "abc"
|
||||
// * "def" and the following line
|
||||
// * Newline between "def" and "ghi"
|
||||
// * "pqr" and the preceding line
|
||||
// * Trailing newline at end of file
|
||||
let modified_text = r"
|
||||
replaced first empty line
|
||||
abc
|
||||
changed def
|
||||
changed line after def
|
||||
ghi
|
||||
changed line between ghi and jkl
|
||||
jkl
|
||||
mno
|
||||
|
||||
changed line before pqr
|
||||
changed pqr
|
||||
|
||||
stu
|
||||
replaced last empty line
|
||||
"
|
||||
.unindent();
|
||||
diff_model.compute_diff_for_test(modified_text).await;
|
||||
|
||||
assert_eq!(diff_model.diff_hunk_count(), 5);
|
||||
|
||||
// Reversing the leading newline change
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(0),
|
||||
Some((0..1, "\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing "def" and following newline
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(1),
|
||||
Some((2..4, "def\n\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing the line changed between "ghi" and "jkl"
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(2),
|
||||
Some((5..6, "\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing changing "pqr" and the line before it
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(3),
|
||||
Some((9..11, "\npqr\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing changing the last line
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(4),
|
||||
Some((13..14, "\n".to_string()))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action_replaced_text() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
abc
|
||||
def
|
||||
ghi
|
||||
|
||||
jkl
|
||||
mno
|
||||
pqr
|
||||
|
||||
stu
|
||||
vwx
|
||||
yz
|
||||
"
|
||||
.unindent();
|
||||
diff_model.set_base(MultilineString::apply(&base_text));
|
||||
|
||||
// Replace with a newline:
|
||||
// * First line "abc"
|
||||
// * "ghi", which is followed by a newline
|
||||
// * "mno"
|
||||
// * "stu", which is preceded by a newline
|
||||
// * Last line "yz"
|
||||
let modified_text = r"
|
||||
|
||||
def
|
||||
|
||||
|
||||
jkl
|
||||
|
||||
pqr
|
||||
|
||||
|
||||
vwx
|
||||
|
||||
"
|
||||
.unindent();
|
||||
diff_model.compute_diff_for_test(modified_text).await;
|
||||
|
||||
assert_eq!(diff_model.diff_hunk_count(), 5);
|
||||
|
||||
// Reversing the leading newline change
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(0),
|
||||
Some((0..1, "abc\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing "ghi"
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(1),
|
||||
Some((3..4, "ghi\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing "mno"
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(2),
|
||||
Some((5..6, "mno\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing "stu"
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(3),
|
||||
Some((8..9, "stu\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing changing the last line
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(4),
|
||||
Some((10..11, "yz\n".to_string()))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reverse_action_deleted_lines() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
|
||||
abc
|
||||
def
|
||||
|
||||
ghi
|
||||
|
||||
jkl
|
||||
mno
|
||||
|
||||
|
||||
pqr
|
||||
|
||||
|
||||
stu
|
||||
vwx
|
||||
|
||||
yz
|
||||
|
||||
"
|
||||
.unindent();
|
||||
diff_model.set_base(MultilineString::apply(&base_text));
|
||||
|
||||
// Delete:
|
||||
// * Leading newline before "abc"
|
||||
// * Newline between "def" and "ghi"
|
||||
// * "mno" followed by a newline
|
||||
// * newline followed by "stu"
|
||||
// * Trailing newline after "yz"
|
||||
let modified_text = r"
|
||||
abc
|
||||
def
|
||||
ghi
|
||||
|
||||
jkl
|
||||
|
||||
pqr
|
||||
|
||||
vwx
|
||||
|
||||
yz
|
||||
"
|
||||
.unindent();
|
||||
diff_model.compute_diff_for_test(modified_text).await;
|
||||
|
||||
assert_eq!(diff_model.diff_hunk_count(), 5);
|
||||
|
||||
// Reversing the leading newline deletion
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(0),
|
||||
Some((0..0, "\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing the newline deletion between "def" and "ghi"
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(1),
|
||||
Some((2..2, "\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing the deletion of "mno" followed by a newline
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(2),
|
||||
Some((5..5, "mno\n\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing the deletion of a newline followed by "stu"
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(3),
|
||||
Some((8..8, "\nstu\n".to_string()))
|
||||
);
|
||||
|
||||
// Reversing the trailing newline deletion
|
||||
assert_eq!(
|
||||
diff_model.reverse_action_by_diff_hunk_index(4),
|
||||
Some((11..11, "\n".to_string()))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_count_before_line() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
diff_model.set_base(
|
||||
"Hello World\nThis is the second line\n"
|
||||
.to_owned()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
diff_model
|
||||
.compute_diff_for_test("Hallo World\nThis is the second line\nNew".to_string())
|
||||
.await;
|
||||
assert_eq!(diff_model.diff_hunk_count_before_line(0), 0);
|
||||
assert_eq!(diff_model.diff_hunk_count_before_line(1), 1);
|
||||
assert_eq!(diff_model.diff_hunk_count_before_line(2), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unified_diff() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let diff = DiffModel::retrieve_unified_diff_internal(
|
||||
MultilineStr::try_new("Hello World\nThis is the second line.\nThis is the third.")
|
||||
.unwrap(),
|
||||
MultilineStr::try_new(
|
||||
"Hallo Welt\nThis is the second line.\nThis is life.\nMoar and more",
|
||||
)
|
||||
.unwrap(),
|
||||
"test.rs",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(diff.unified_diff, "--- test.rs\n+++ test.rs\n@@ -1,3 +1,4 @@\n-Hello World\n+Hallo Welt\n This is the second line.\n-This is the third.\n+This is life.\n+Moar and more\n");
|
||||
assert_eq!(diff.lines_added, 3);
|
||||
assert_eq!(diff.lines_removed, 2);
|
||||
});
|
||||
}
|
||||
|
||||
/// Test coalesce_replacements with a case where the `similar` library is known
|
||||
/// to produce duplicate deletion and insertion hunks for what is logically a replacement.
|
||||
#[test]
|
||||
fn test_coalesce_replacements() {
|
||||
use warpui::App;
|
||||
App::test((), |_| async move {
|
||||
let mut diff_model = DiffModel::new();
|
||||
let base_text = r"
|
||||
abc
|
||||
def
|
||||
ghi
|
||||
|
||||
jkl
|
||||
mno
|
||||
pqr
|
||||
|
||||
stu
|
||||
vwx
|
||||
yz
|
||||
"
|
||||
.unindent();
|
||||
diff_model.set_base(MultilineString::apply(&base_text));
|
||||
|
||||
// Replace with a newline:
|
||||
// * First line "abc"
|
||||
// * "ghi", which is followed by a newline
|
||||
// * "mno"
|
||||
// * "stu", which is preceded by a newline
|
||||
// * Last line "yz"
|
||||
let modified_text = r"
|
||||
|
||||
def
|
||||
|
||||
|
||||
jkl
|
||||
|
||||
pqr
|
||||
|
||||
|
||||
vwx
|
||||
|
||||
"
|
||||
.unindent();
|
||||
diff_model.compute_diff_for_test(modified_text).await;
|
||||
|
||||
assert_eq!(diff_model.diff_hunk_count(), 5);
|
||||
|
||||
// Replacing "abc"
|
||||
assert_eq!(diff_model.diff_by_index(0), Some((0..1, true)));
|
||||
|
||||
// Replacing "ghi"
|
||||
assert_eq!(diff_model.diff_by_index(1), Some((3..4, true)));
|
||||
|
||||
// Replacing "mno"
|
||||
assert_eq!(diff_model.diff_by_index(2), Some((5..6, true)));
|
||||
|
||||
// Replacing "stu"
|
||||
assert_eq!(diff_model.diff_by_index(3), Some((8..9, true)));
|
||||
|
||||
// Replacing "yz"
|
||||
assert_eq!(diff_model.diff_by_index(4), Some((10..11, true)));
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
||||
use crate::view_components::action_button::{
|
||||
ActionButtonTheme, DisabledSecondaryTheme, SecondaryTheme,
|
||||
};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::color::contrast::MinimumAllowedContrast;
|
||||
use warp_core::ui::color::ContrastingColor;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_core::ui::Icon;
|
||||
use warpui::elements::MouseState;
|
||||
|
||||
/// A button rendered within the gutter of the editor.
|
||||
pub(super) trait GutterButton {
|
||||
/// The icon color for the gutter.
|
||||
fn icon_color(&self, mouse_state: &MouseState, appearance: &Appearance) -> Fill {
|
||||
let button_background = self.background_color(mouse_state, appearance);
|
||||
|
||||
let is_hovered = mouse_state.is_hovered();
|
||||
let color = if self.is_enabled() {
|
||||
SecondaryTheme.text_color(is_hovered, Some(button_background), appearance)
|
||||
} else {
|
||||
DisabledSecondaryTheme.text_color(is_hovered, Some(button_background), appearance)
|
||||
};
|
||||
|
||||
let contrast_shifted_color = color.on_background(
|
||||
button_background.into_solid(),
|
||||
MinimumAllowedContrast::NonText,
|
||||
);
|
||||
contrast_shifted_color.into()
|
||||
}
|
||||
|
||||
/// The background color of the button.
|
||||
fn background_color(&self, mouse_state: &MouseState, appearance: &Appearance) -> Fill {
|
||||
if self.is_enabled() {
|
||||
if mouse_state.is_hovered() {
|
||||
Fill::Solid(internal_colors::neutral_3(appearance.theme()))
|
||||
} else {
|
||||
Fill::Solid(internal_colors::neutral_1(appearance.theme()))
|
||||
}
|
||||
} else {
|
||||
Fill::Solid(internal_colors::neutral_1(appearance.theme()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the button is currently enabled. If false, the button is rendered in a disabled
|
||||
/// state.
|
||||
fn is_enabled(&self) -> bool;
|
||||
|
||||
/// The tooltip text displayed when the button is hovered.
|
||||
fn tooltip_text(&self) -> Option<&'static str>;
|
||||
|
||||
/// The icon of the button.
|
||||
fn icon(&self) -> Icon;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct AddAsContextButton {
|
||||
is_enabled: bool,
|
||||
}
|
||||
|
||||
impl AddAsContextButton {
|
||||
pub fn new(is_enabled: bool) -> Self {
|
||||
Self { is_enabled }
|
||||
}
|
||||
}
|
||||
|
||||
impl GutterButton for AddAsContextButton {
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.is_enabled
|
||||
}
|
||||
|
||||
fn tooltip_text(&self) -> Option<&'static str> {
|
||||
if self.is_enabled {
|
||||
Some("Add diff hunk as context")
|
||||
} else {
|
||||
Some("Save changes to attach as context.")
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(&self) -> Icon {
|
||||
Icon::Paperclip
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct RevertHunkButton {
|
||||
is_enabled: bool,
|
||||
}
|
||||
|
||||
impl RevertHunkButton {
|
||||
pub fn new(is_enabled: bool) -> Self {
|
||||
Self { is_enabled }
|
||||
}
|
||||
}
|
||||
|
||||
impl GutterButton for RevertHunkButton {
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.is_enabled
|
||||
}
|
||||
|
||||
fn tooltip_text(&self) -> Option<&'static str> {
|
||||
if self.is_enabled {
|
||||
Some("Revert diff hunk")
|
||||
} else {
|
||||
Some("Save changes to revert")
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(&self) -> Icon {
|
||||
Icon::ReverseLeft
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
#[allow(dead_code)]
|
||||
pub enum CommentButton {
|
||||
#[default]
|
||||
CreateNewComment,
|
||||
Disabled,
|
||||
AddedComment,
|
||||
EditorOpenedToCreateNewComment,
|
||||
EditorOpenedToUpdateComment,
|
||||
}
|
||||
|
||||
impl GutterButton for CommentButton {
|
||||
fn background_color(&self, mouse_state: &MouseState, appearance: &Appearance) -> Fill {
|
||||
match self {
|
||||
CommentButton::CreateNewComment => {
|
||||
if mouse_state.is_hovered() {
|
||||
Fill::Solid(internal_colors::neutral_3(appearance.theme()))
|
||||
} else {
|
||||
Fill::Solid(internal_colors::neutral_1(appearance.theme()))
|
||||
}
|
||||
}
|
||||
CommentButton::EditorOpenedToCreateNewComment => {
|
||||
Fill::Solid(internal_colors::neutral_3(appearance.theme()))
|
||||
}
|
||||
CommentButton::Disabled => Fill::Solid(internal_colors::neutral_1(appearance.theme())),
|
||||
CommentButton::AddedComment | CommentButton::EditorOpenedToUpdateComment => {
|
||||
internal_colors::accent(appearance.theme())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
CommentButton::AddedComment
|
||||
| CommentButton::CreateNewComment
|
||||
| CommentButton::EditorOpenedToCreateNewComment
|
||||
)
|
||||
}
|
||||
|
||||
fn tooltip_text(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
CommentButton::CreateNewComment => Some("Add comment on line"),
|
||||
CommentButton::Disabled => Some("Save changes to add comment"),
|
||||
CommentButton::AddedComment => Some("Show saved comment"),
|
||||
CommentButton::EditorOpenedToCreateNewComment
|
||||
| CommentButton::EditorOpenedToUpdateComment => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(&self) -> Icon {
|
||||
match self {
|
||||
CommentButton::CreateNewComment
|
||||
| CommentButton::Disabled
|
||||
| CommentButton::EditorOpenedToCreateNewComment => Icon::MessagePlusSquare,
|
||||
CommentButton::AddedComment | CommentButton::EditorOpenedToUpdateComment => {
|
||||
Icon::MessageText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use serde_yaml::Mapping;
|
||||
use uuid::Uuid;
|
||||
use warp_editor::content::markdown::MarkdownStyle;
|
||||
use warp_editor::editor::EmbeddedItemModel;
|
||||
use warp_editor::render::element::{RenderContext, RenderableBlock};
|
||||
use warp_editor::render::layout::TextLayout;
|
||||
|
||||
use warp_editor::render::model::{
|
||||
viewport::ViewportItem, BlockSpacing, EmbeddedItem, EmbeddedItemHTMLRepresentation,
|
||||
EmbeddedItemRichFormat, LaidOutEmbeddedItem, RenderState,
|
||||
};
|
||||
use warpui::event::DispatchedEvent;
|
||||
use warpui::units::Pixels;
|
||||
use warpui::{AppContext, EntityId, EventContext, LayoutContext, ViewHandle, WindowId};
|
||||
|
||||
use crate::code::editor::comment_editor::CommentEditor;
|
||||
use crate::code_review::comments::CommentId;
|
||||
|
||||
const COMMENT_ID_MAPPING_KEY: &str = "comment_id";
|
||||
const ENTITY_ID_MAPPING_KEY: &str = "entity_id";
|
||||
const WINDOW_ID_MAPPING_KEY: &str = "window_id";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EmbeddedCommentSpace {
|
||||
// We unfortunately need to store a string version of the ID
|
||||
// in order to return it in EmbeddedItem::hashed_id()
|
||||
id_string: String,
|
||||
editor_entity_id: EntityId,
|
||||
window_id: WindowId,
|
||||
}
|
||||
|
||||
impl EmbeddedCommentSpace {
|
||||
fn new(id: CommentId, editor_entity_id: EntityId, window_id: WindowId) -> Self {
|
||||
Self {
|
||||
id_string: id.to_string(),
|
||||
editor_entity_id,
|
||||
window_id,
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the underlying comment editor view
|
||||
fn get_comment_editor(&self, app: &AppContext) -> Option<ViewHandle<CommentEditor>> {
|
||||
app.view_with_id::<CommentEditor>(self.window_id, self.editor_entity_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl EmbeddedItem for EmbeddedCommentSpace {
|
||||
fn layout(&self, _text_layout: &TextLayout, app: &AppContext) -> Box<dyn LaidOutEmbeddedItem> {
|
||||
let comment_editor = self.get_comment_editor(app);
|
||||
if comment_editor.is_none() {
|
||||
log::error!(
|
||||
"EmbeddedComment can't layout missing comment editor for comment ID {:?}",
|
||||
self.id_string
|
||||
);
|
||||
};
|
||||
|
||||
let size = comment_editor
|
||||
.and_then(|editor| editor.read(app, |editor, _ctx| editor.get_laid_out_size()))
|
||||
.unwrap_or_else(|| {
|
||||
log::error!(
|
||||
"Didn't find laid out size for editor ID {:?}",
|
||||
self.id_string
|
||||
);
|
||||
Vector2F::new(100.0, 24.0)
|
||||
});
|
||||
|
||||
Box::new(LaidOutEmbeddedCommentSpace { size })
|
||||
}
|
||||
|
||||
fn hashed_id(&self) -> &str {
|
||||
self.id_string.as_str()
|
||||
}
|
||||
|
||||
fn to_mapping(&self, _style: MarkdownStyle) -> Mapping {
|
||||
let mut map = Mapping::new();
|
||||
let comment_id = self.id_string.clone();
|
||||
let editor_entity_id = self.editor_entity_id.to_string();
|
||||
let window_id = self.window_id.to_string();
|
||||
map.insert(COMMENT_ID_MAPPING_KEY.into(), comment_id.into());
|
||||
map.insert(ENTITY_ID_MAPPING_KEY.into(), editor_entity_id.into());
|
||||
map.insert(WINDOW_ID_MAPPING_KEY.into(), window_id.into());
|
||||
map
|
||||
}
|
||||
|
||||
fn to_rich_format(&self, app: &AppContext) -> EmbeddedItemRichFormat<'_> {
|
||||
let text = if let Some(editor) = self.get_comment_editor(app) {
|
||||
editor.read(app, |editor, app| editor.comment_text(app))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
EmbeddedItemRichFormat {
|
||||
plain_text: text.to_string(),
|
||||
html: EmbeddedItemHTMLRepresentation {
|
||||
element_name: "div",
|
||||
content: text.to_string(),
|
||||
attributes: HashMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaidOutEmbeddedCommentSpace {
|
||||
pub size: Vector2F,
|
||||
}
|
||||
|
||||
impl LaidOutEmbeddedItem for LaidOutEmbeddedCommentSpace {
|
||||
fn height(&self) -> Pixels {
|
||||
Pixels::new(self.size.y())
|
||||
}
|
||||
|
||||
fn size(&self) -> Vector2F {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn first_line_bound(&self) -> Vector2F {
|
||||
vec2f(self.size.x(), 24.0)
|
||||
}
|
||||
|
||||
fn element(
|
||||
&self,
|
||||
_state: &RenderState,
|
||||
viewport_item: ViewportItem,
|
||||
_model: Option<&dyn EmbeddedItemModel>,
|
||||
_ctx: &AppContext,
|
||||
) -> Box<dyn RenderableBlock> {
|
||||
// Just create a spacer - no child view rendering here
|
||||
Box::new(RenderableEmbeddedCommentSpace::new(viewport_item))
|
||||
}
|
||||
|
||||
fn spacing(&self) -> BlockSpacing {
|
||||
BlockSpacing::default()
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RenderableEmbeddedCommentSpace {
|
||||
viewport_item: ViewportItem,
|
||||
}
|
||||
|
||||
impl RenderableEmbeddedCommentSpace {
|
||||
pub(crate) fn new(viewport_item: ViewportItem) -> Self {
|
||||
Self { viewport_item }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderableBlock for RenderableEmbeddedCommentSpace {
|
||||
fn viewport_item(&self) -> &ViewportItem {
|
||||
&self.viewport_item
|
||||
}
|
||||
|
||||
fn layout(&mut self, _model: &RenderState, _ctx: &mut LayoutContext, _app: &AppContext) {
|
||||
// No-op: this is just a spacer, the actual editor is laid out by EditorWrapper
|
||||
}
|
||||
|
||||
fn paint(&mut self, _model: &RenderState, _ctx: &mut RenderContext, _app: &AppContext) {
|
||||
// No-op: this is just empty space, the actual editor is painted by EditorWrapper
|
||||
}
|
||||
|
||||
fn dispatch_event(
|
||||
&mut self,
|
||||
_model: &RenderState,
|
||||
_event: &DispatchedEvent,
|
||||
_ctx: &mut EventContext,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
// No interactivity: events are handled by the editor rendered by EditorWrapper
|
||||
false
|
||||
}
|
||||
|
||||
fn is_embedded_comment(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The embedded item transformation for comments.
|
||||
#[cfg_attr(not(test), allow(unused))] // TODO(CODE-1464): use this
|
||||
pub(super) fn comment_embedded_item_conversion(
|
||||
mut mapping: serde_yaml::Mapping,
|
||||
) -> Option<Arc<dyn EmbeddedItem>> {
|
||||
use serde_yaml::Value;
|
||||
let Some(Value::String(comment_uuid)) =
|
||||
mapping.remove(&Value::String(COMMENT_ID_MAPPING_KEY.to_string()))
|
||||
else {
|
||||
log::error!("Unable to deserialize embedded comment ID");
|
||||
return None;
|
||||
};
|
||||
let Some(Value::String(entity_id)) =
|
||||
mapping.remove(&Value::String(ENTITY_ID_MAPPING_KEY.to_string()))
|
||||
else {
|
||||
log::error!("Unable to deserialize embedded comment entity ID");
|
||||
return None;
|
||||
};
|
||||
let Some(Value::String(window_id)) =
|
||||
mapping.remove(&Value::String(WINDOW_ID_MAPPING_KEY.to_string()))
|
||||
else {
|
||||
log::error!("Unable to deserialize embedded comment window ID");
|
||||
return None;
|
||||
};
|
||||
|
||||
let comment_id = CommentId::from_uuid(
|
||||
Uuid::from_str(&comment_uuid)
|
||||
.inspect_err(|e| {
|
||||
log::error!("Unable to parse comment ID {comment_uuid}: {e:?}");
|
||||
})
|
||||
.ok()?,
|
||||
);
|
||||
let entity_id = EntityId::from_usize(
|
||||
entity_id
|
||||
.parse::<usize>()
|
||||
.inspect_err(|e| {
|
||||
log::error!("Unable to parse entity ID {entity_id}: {e:?}");
|
||||
})
|
||||
.ok()?,
|
||||
);
|
||||
let window_id = WindowId::from_usize(
|
||||
window_id
|
||||
.parse::<usize>()
|
||||
.inspect_err(|e| {
|
||||
log::error!("Unable to parse entity ID {window_id}: {e:?}");
|
||||
})
|
||||
.ok()?,
|
||||
);
|
||||
Some(Arc::new(EmbeddedCommentSpace::new(
|
||||
comment_id, entity_id, window_id,
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "embedded_comment_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,157 @@
|
||||
use super::{
|
||||
comment_embedded_item_conversion, EmbeddedCommentSpace, EmbeddedItem as _,
|
||||
COMMENT_ID_MAPPING_KEY, ENTITY_ID_MAPPING_KEY, WINDOW_ID_MAPPING_KEY,
|
||||
};
|
||||
use crate::code_review::comments::CommentId;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use warp_editor::content::markdown::MarkdownStyle;
|
||||
use warpui::{EntityId, WindowId};
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_valid_input() {
|
||||
let comment_id = CommentId::new();
|
||||
let entity_id = EntityId::from_usize(123);
|
||||
let window_id = WindowId::from_usize(456);
|
||||
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(
|
||||
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
|
||||
Value::String(comment_id.to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
|
||||
Value::String(entity_id.to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
|
||||
Value::String(window_id.to_string()),
|
||||
);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_roundtrip() {
|
||||
let comment_id = CommentId::new();
|
||||
let entity_id = EntityId::from_usize(789);
|
||||
let window_id = WindowId::from_usize(101);
|
||||
|
||||
let space = EmbeddedCommentSpace::new(comment_id, entity_id, window_id);
|
||||
let mapping = space.to_mapping(MarkdownStyle::Internal);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_missing_comment_id() {
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(
|
||||
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("123".to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("456".to_string()),
|
||||
);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_missing_entity_id() {
|
||||
let comment_id = CommentId::new();
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(
|
||||
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
|
||||
Value::String(comment_id.to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("456".to_string()),
|
||||
);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_missing_window_id() {
|
||||
let comment_id = CommentId::new();
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(
|
||||
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
|
||||
Value::String(comment_id.to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("123".to_string()),
|
||||
);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_invalid_uuid() {
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(
|
||||
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("not-a-valid-uuid".to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("123".to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("456".to_string()),
|
||||
);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_invalid_entity_id() {
|
||||
let comment_id = CommentId::new();
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(
|
||||
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
|
||||
Value::String(comment_id.to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("not-a-number".to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("456".to_string()),
|
||||
);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_comment_embedded_item_conversion_invalid_window_id() {
|
||||
let comment_id = CommentId::new();
|
||||
let mut mapping = Mapping::new();
|
||||
mapping.insert(
|
||||
Value::String(COMMENT_ID_MAPPING_KEY.to_string()),
|
||||
Value::String(comment_id.to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(ENTITY_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("123".to_string()),
|
||||
);
|
||||
mapping.insert(
|
||||
Value::String(WINDOW_ID_MAPPING_KEY.to_string()),
|
||||
Value::String("not-a-number".to_string()),
|
||||
);
|
||||
|
||||
let result = comment_embedded_item_conversion(mapping);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod view;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
pub mod view;
|
||||
@@ -0,0 +1,200 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::editor::find::view::{FIND_BAR_PADDING, FIND_EDITOR_BORDER_RADIUS};
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys,
|
||||
SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
|
||||
ParentElement, Radius, Text,
|
||||
},
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
const GOTO_LINE_WIDTH: f32 = 300.;
|
||||
const GOTO_LINE_LABEL_FONT_SIZE: f32 = 12.;
|
||||
const GOTO_LINE_EDITOR_FONT_SIZE: f32 = 12.;
|
||||
const GOTO_LINE_ERROR_FONT_SIZE: f32 = 11.;
|
||||
const GOTO_LINE_EDITOR_PADDING: f32 = 6.;
|
||||
const GOTO_LINE_EDITOR_BORDER_WIDTH: f32 = 1.;
|
||||
const GOTO_LINE_ROW_SPACING: f32 = 6.;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
Close,
|
||||
Confirm { input: String },
|
||||
}
|
||||
|
||||
pub struct GoToLineView {
|
||||
line_editor: ViewHandle<EditorView>,
|
||||
is_open: bool,
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl GoToLineView {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let line_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let mut editor = EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
text: TextOptions::ui_text(Some(GOTO_LINE_EDITOR_FONT_SIZE), appearance),
|
||||
select_all_on_focus: true,
|
||||
clear_selections_on_blur: false,
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
editor.set_placeholder_text("Line number:Column", ctx);
|
||||
editor
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&line_editor, |me, _, event, ctx| {
|
||||
me.handle_editor_event(event, ctx);
|
||||
});
|
||||
|
||||
let appearance_handle = Appearance::handle(ctx);
|
||||
ctx.observe(&appearance_handle, |_, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
Self {
|
||||
line_editor,
|
||||
is_open: false,
|
||||
error_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.is_open
|
||||
}
|
||||
|
||||
pub fn open(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_open = true;
|
||||
self.error_message = None;
|
||||
self.line_editor.update(ctx, |editor, ctx| {
|
||||
editor.set_interaction_state(InteractionState::Editable, ctx);
|
||||
editor.clear_buffer(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_open = false;
|
||||
self.error_message = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn set_error(&mut self, message: String, ctx: &mut ViewContext<Self>) {
|
||||
self.error_message = Some(message);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
EditorEvent::Enter => {
|
||||
let input = self.line_editor.as_ref(ctx).buffer_text(ctx);
|
||||
ctx.emit(Event::Confirm { input });
|
||||
}
|
||||
EditorEvent::Escape => {
|
||||
ctx.emit(Event::Close);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for GoToLineView {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl TypedActionView for GoToLineView {
|
||||
type Action = ();
|
||||
|
||||
fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext<Self>) {}
|
||||
}
|
||||
|
||||
impl View for GoToLineView {
|
||||
fn ui_name() -> &'static str {
|
||||
"GoToLineView"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
ctx.focus(&self.line_editor);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let label = Text::new_inline(
|
||||
"Go to line",
|
||||
appearance.ui_font_family(),
|
||||
GOTO_LINE_LABEL_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let input_field = Container::new(ChildView::new(&self.line_editor).finish())
|
||||
.with_padding_left(8.)
|
||||
.with_padding_right(4.)
|
||||
.with_padding_top(GOTO_LINE_EDITOR_PADDING)
|
||||
.with_padding_bottom(GOTO_LINE_EDITOR_PADDING)
|
||||
.with_background(theme.surface_1())
|
||||
.with_border(
|
||||
Border::all(GOTO_LINE_EDITOR_BORDER_WIDTH).with_border_fill(theme.surface_3()),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
FIND_EDITOR_BORDER_RADIUS,
|
||||
)))
|
||||
.finish();
|
||||
|
||||
let mut content = Flex::column().with_child(
|
||||
Container::new(label)
|
||||
.with_margin_bottom(GOTO_LINE_ROW_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
content.add_child(input_field);
|
||||
|
||||
if let Some(error) = &self.error_message {
|
||||
let error_text = Text::new_inline(
|
||||
error.clone(),
|
||||
appearance.ui_font_family(),
|
||||
GOTO_LINE_ERROR_FONT_SIZE,
|
||||
)
|
||||
.with_color(theme.ui_error_color())
|
||||
.finish();
|
||||
content.add_child(
|
||||
Container::new(error_text)
|
||||
.with_margin_top(GOTO_LINE_ROW_SPACING)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let panel = Container::new(
|
||||
ConstrainedBox::new(
|
||||
Container::new(content.finish())
|
||||
.with_background(theme.surface_2())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(GOTO_LINE_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(FIND_BAR_PADDING)
|
||||
.with_background(theme.surface_2())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(
|
||||
FIND_EDITOR_BORDER_RADIUS,
|
||||
)))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish();
|
||||
|
||||
Align::new(panel).top_center().finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::ops::Range;
|
||||
use warp_editor::render::model::{LineCount, RenderLineLocation};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditorLineLocation {
|
||||
Collapsed {
|
||||
line_range: Range<LineCount>,
|
||||
},
|
||||
Current {
|
||||
line_number: LineCount,
|
||||
line_range: Range<LineCount>,
|
||||
},
|
||||
Removed {
|
||||
line_number: LineCount,
|
||||
line_range: Range<LineCount>,
|
||||
// There can be many deleted lines in a removal hunk, so we track the index of this line within the hunk.
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl EditorLineLocation {
|
||||
pub fn line_range(&self) -> &Range<LineCount> {
|
||||
match self {
|
||||
EditorLineLocation::Current { line_range, .. } => line_range,
|
||||
EditorLineLocation::Removed { line_range, .. } => line_range,
|
||||
EditorLineLocation::Collapsed { line_range } => line_range,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn line_number(&self) -> Option<LineCount> {
|
||||
match self {
|
||||
EditorLineLocation::Current { line_number, .. } => Some(*line_number),
|
||||
EditorLineLocation::Removed { line_number, .. } => Some(*line_number),
|
||||
EditorLineLocation::Collapsed { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this line location represents the same line as another.
|
||||
/// This is not the same as equality, as the line range of the diff hunk may differ.
|
||||
pub fn is_same_line(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(
|
||||
EditorLineLocation::Current { line_number: a, .. },
|
||||
EditorLineLocation::Current { line_number: b, .. },
|
||||
) if a == b => true,
|
||||
(
|
||||
EditorLineLocation::Removed {
|
||||
line_number: al,
|
||||
index: ai,
|
||||
..
|
||||
},
|
||||
EditorLineLocation::Removed {
|
||||
line_number: bl,
|
||||
index: bi,
|
||||
..
|
||||
},
|
||||
) if al == bl && ai == bi => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_render_line_location(self) -> RenderLineLocation {
|
||||
match self {
|
||||
EditorLineLocation::Current { line_number, .. } => {
|
||||
RenderLineLocation::Current(line_number)
|
||||
}
|
||||
EditorLineLocation::Removed {
|
||||
line_number, index, ..
|
||||
} => RenderLineLocation::Temporary {
|
||||
at_line: line_number,
|
||||
index_from_at_line: index,
|
||||
},
|
||||
EditorLineLocation::Collapsed { line_range } => {
|
||||
debug_assert!(false, "We don't support converting from collapsed line location to render line location yet");
|
||||
RenderLineLocation::Current(line_range.start)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// An iterator over lines that can efficiently return lines in a specified range.
|
||||
/// This should be shared when multiple in-order ranges need to be accessed.
|
||||
/// Note that you cannot request a range before the previous range that was requested.
|
||||
use std::ops::Range;
|
||||
pub struct LineIterator<'a, I: Iterator<Item = &'a str>> {
|
||||
lines: I,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl<'a, I: Iterator<Item = &'a str>> LineIterator<'a, I> {
|
||||
pub fn new(iter: I) -> Self {
|
||||
Self {
|
||||
lines: iter,
|
||||
index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lines_in_range(
|
||||
&mut self,
|
||||
range: &Range<usize>,
|
||||
) -> anyhow::Result<impl Iterator<Item = &'a str> + use<'a, '_, I>> {
|
||||
if self.index > range.start {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Requested range start {} is before current index {}",
|
||||
range.start,
|
||||
self.index
|
||||
));
|
||||
}
|
||||
|
||||
if self.index < range.start {
|
||||
self.lines.nth(range.start - self.index - 1);
|
||||
self.index = range.start;
|
||||
}
|
||||
|
||||
self.index = range.end;
|
||||
Ok(self.lines.by_ref().take(range.end - range.start))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
|
||||
pub(crate) mod comment_editor;
|
||||
mod comments;
|
||||
pub(super) mod diff;
|
||||
mod element;
|
||||
pub mod embedded_comment;
|
||||
pub mod find;
|
||||
pub mod goto_line;
|
||||
pub mod line;
|
||||
mod line_iterator;
|
||||
pub mod model;
|
||||
mod nav_bar;
|
||||
pub mod scroll;
|
||||
pub mod view;
|
||||
|
||||
pub use comment_editor::{CommentEditor, CommentEditorEvent};
|
||||
pub use comments::EditorCommentsModel;
|
||||
pub use comments::EditorReviewComment;
|
||||
pub(crate) use diff::{add_color, remove_color};
|
||||
pub use element::GutterHoverTarget;
|
||||
pub use nav_bar::NavBarBehavior;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code, unused_imports))]
|
||||
// Adding this file level gate as some of the code around editability is not used in WASM yet.
|
||||
|
||||
use warp_core::ui::{appearance::Appearance, theme::Fill};
|
||||
use warp_editor::model::CoreEditorModel;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ConstrainedBox, Container, CrossAxisAlignment, Flex, MouseStateHandle,
|
||||
ParentElement, Shrinkable,
|
||||
},
|
||||
presenter::ChildView,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
units::IntoPixels,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
editor::InteractionState,
|
||||
ui_components::icons::Icon,
|
||||
view_components::action_button::{ActionButton, ButtonSize, NakedTheme},
|
||||
view_components::find::FIND_BAR_PADDING,
|
||||
};
|
||||
|
||||
use super::model::{CodeEditorModel, CodeEditorModelEvent};
|
||||
|
||||
const NAV_BAR_HEIGHT: f32 = 40.;
|
||||
const NAV_BAR_ICON_SIZE: f32 = 16.;
|
||||
const NAV_BAR_ICON_PADDING: f32 = 4.;
|
||||
const NAV_BAR_SEPARATOR_PADDING: f32 = 12.;
|
||||
|
||||
// The ratio of rows to offset of when jumping to a diff nav (base is the total number of lines in viewport)
|
||||
const DIFF_NAV_OFFSET_PIXEL_RATIO: usize = 10;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NavBarEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NavBarAction {
|
||||
NavigateUp,
|
||||
NavigateDown,
|
||||
Revert,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
close_mouse_state: MouseStateHandle,
|
||||
revert_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub enum NavBarBehavior {
|
||||
Closable,
|
||||
NotClosable,
|
||||
}
|
||||
|
||||
pub struct NavBar {
|
||||
model: ModelHandle<CodeEditorModel>,
|
||||
behavior: NavBarBehavior,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
up_label_button: ViewHandle<ActionButton>,
|
||||
down_label_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl NavBar {
|
||||
pub fn new(model: ModelHandle<CodeEditorModel>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&model, |_, _, event, ctx| {
|
||||
if matches!(event, CodeEditorModelEvent::InteractionStateChanged) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
let up_label_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Previous", NakedTheme)
|
||||
.with_size(ButtonSize::InlineActionHeader)
|
||||
.with_icon(Icon::ArrowUp)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(NavBarAction::NavigateUp))
|
||||
});
|
||||
|
||||
let down_label_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Next", NakedTheme)
|
||||
.with_size(ButtonSize::InlineActionHeader)
|
||||
.with_icon(Icon::ArrowDown)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(NavBarAction::NavigateDown))
|
||||
});
|
||||
|
||||
Self {
|
||||
model,
|
||||
behavior: NavBarBehavior::Closable,
|
||||
mouse_state_handles: Default::default(),
|
||||
up_label_button,
|
||||
down_label_button,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_behavior(&mut self, behavior: NavBarBehavior) {
|
||||
self.behavior = behavior;
|
||||
}
|
||||
|
||||
fn diff_hunk_count(&self, app: &AppContext) -> usize {
|
||||
self.model.as_ref(app).diff().as_ref(app).diff_hunk_count()
|
||||
}
|
||||
|
||||
pub fn selected_index(&self, app: &AppContext) -> Option<usize> {
|
||||
self.model.as_ref(app).focused_diff_index()
|
||||
}
|
||||
|
||||
/// Autoscroll until the start of the selected hunk is in the center of the viewport.
|
||||
pub fn autoscroll(&self, ctx: &mut ViewContext<Self>) {
|
||||
let model = self.model.as_ref(ctx);
|
||||
|
||||
let Some(index) = self.selected_index(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(range) = model
|
||||
.diff()
|
||||
.as_ref(ctx)
|
||||
.line_range_by_diff_hunk_index(index)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let character_offset = model.start_of_line_offset(range.start, ctx);
|
||||
|
||||
// Number of lines to offset when autoscrolling to a diff. Keep a minimum of 1 line as context.
|
||||
let delta = (model.lines_in_viewport(ctx) / DIFF_NAV_OFFSET_PIXEL_RATIO).max(1);
|
||||
let pixel_offset = -(delta as f32 * model.line_height(ctx));
|
||||
|
||||
model
|
||||
.render_state()
|
||||
.clone()
|
||||
.update(ctx, |render_state, _ctx| {
|
||||
render_state.request_autoscroll_to_exact_vertical(
|
||||
character_offset,
|
||||
pixel_offset.into_pixels(),
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
fn render_match_index(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
background: Fill,
|
||||
total: usize,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let diff_text = appearance
|
||||
.ui_builder()
|
||||
.span("Hunk:")
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().sub_text_color(background).into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_selectable(false)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let index = (self.selected_index(app).unwrap_or(0) + 1).min(total);
|
||||
let text = format!("{index}/{total}");
|
||||
|
||||
let index = Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.span(text)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(appearance.theme().foreground().into()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_selectable(false)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(4.)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_border(Border::new(1.).with_border_fill(appearance.theme().surface_2()))
|
||||
.finish();
|
||||
|
||||
Align::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(diff_text)
|
||||
.with_child(index)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(16.)
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_revert_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Outlined,
|
||||
self.mouse_state_handles.revert_mouse_state.clone(),
|
||||
)
|
||||
.with_text_label("Reject".to_string())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(NavBarAction::Revert))
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(NAV_BAR_SEPARATOR_PADDING)
|
||||
.with_padding_right(NAV_BAR_SEPARATOR_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.close_button(
|
||||
NAV_BAR_ICON_SIZE,
|
||||
self.mouse_state_handles.close_mouse_state.clone(),
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(NavBarAction::Close))
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(NAV_BAR_ICON_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_nav_label(&self, up: bool) -> Box<dyn Element> {
|
||||
let button_handle = if up {
|
||||
&self.up_label_button
|
||||
} else {
|
||||
&self.down_label_button
|
||||
};
|
||||
|
||||
Container::new(Align::new(ChildView::new(button_handle).finish()).finish())
|
||||
.with_padding_right(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn navigate_up(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.nav_diff_up(ctx);
|
||||
});
|
||||
self.autoscroll(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn navigate_down(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.nav_diff_down(ctx);
|
||||
});
|
||||
self.autoscroll(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NavBar {
|
||||
type Event = NavBarEvent;
|
||||
}
|
||||
|
||||
impl View for NavBar {
|
||||
fn ui_name() -> &'static str {
|
||||
"NavBar"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let total = self.diff_hunk_count(app);
|
||||
|
||||
let editable = matches!(
|
||||
self.model.as_ref(app).interaction_state(),
|
||||
InteractionState::Editable
|
||||
);
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
self.render_match_index(
|
||||
appearance,
|
||||
appearance.theme().background(),
|
||||
total,
|
||||
app,
|
||||
),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_nav_label(true))
|
||||
.with_child(self.render_nav_label(false));
|
||||
|
||||
// Do not render the revert button if there is nothing to revert or the editor is
|
||||
// not in an editable interaction state.
|
||||
if editable && total > 0 {
|
||||
row.add_child(self.render_revert_button(appearance));
|
||||
}
|
||||
|
||||
if matches!(self.behavior, NavBarBehavior::Closable) {
|
||||
row.add_child(self.render_close_button(appearance));
|
||||
}
|
||||
|
||||
Container::new(
|
||||
ConstrainedBox::new(row.finish())
|
||||
.with_height(NAV_BAR_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(FIND_BAR_PADDING)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for NavBar {
|
||||
type Action = NavBarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &NavBarAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
NavBarAction::Close => ctx.emit(NavBarEvent::Close),
|
||||
NavBarAction::NavigateUp => self.navigate_up(ctx),
|
||||
NavBarAction::NavigateDown => self.navigate_down(ctx),
|
||||
NavBarAction::Revert => {
|
||||
self.autoscroll(ctx);
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.revert_diff_index(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use warp_editor::content::version::BufferVersion;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScrollWheelBehavior {
|
||||
#[allow(dead_code)]
|
||||
OnlyHandleOnFocus,
|
||||
#[allow(dead_code)]
|
||||
AlwaysHandle,
|
||||
#[allow(dead_code)]
|
||||
NeverHandle,
|
||||
}
|
||||
|
||||
impl ScrollWheelBehavior {
|
||||
#[allow(dead_code)]
|
||||
pub fn should_handle(&self, focused: bool) -> bool {
|
||||
match self {
|
||||
Self::OnlyHandleOnFocus => focused,
|
||||
Self::AlwaysHandle => true,
|
||||
Self::NeverHandle => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
#[derive(Clone)]
|
||||
pub enum ScrollPosition {
|
||||
LineAndColumn(LineAndColumnArg),
|
||||
FocusedDiffHunk,
|
||||
}
|
||||
|
||||
/// We don't want to scroll to the provided line number until the content has
|
||||
/// been loaded from the file and layout has occurred to update the viewport size.
|
||||
/// This struct is used to track the state of the scroll trigger.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub struct ScrollTrigger {
|
||||
pub minimum_applicable_version: BufferVersion,
|
||||
pub position: ScrollPosition,
|
||||
}
|
||||
|
||||
impl ScrollTrigger {
|
||||
/// Create a new scroll trigger that will jump to the provided line number
|
||||
/// after the provided version has been loaded and a layout update has occurred.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn new(position: ScrollPosition, version: BufferVersion) -> Self {
|
||||
Self {
|
||||
position,
|
||||
minimum_applicable_version: version,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
use std::sync::Arc;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_editor::render::element::VerticalExpansionBehavior;
|
||||
use warpui::{
|
||||
elements::{new_scrollable::ScrollableAppearance, ScrollbarWidth},
|
||||
platform::WindowStyle,
|
||||
App, TypedActionView, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
editor::InteractionState,
|
||||
notebooks::editor::keys::NotebookKeybindings,
|
||||
server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
vim_registers::VimRegisters,
|
||||
workspace::{sync_inputs::SyncedInputState, ActiveSession},
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
AuthStateProvider,
|
||||
};
|
||||
|
||||
use super::{CodeEditorRenderOptions, CodeEditorView, CodeEditorViewAction};
|
||||
use warp_util::user_input::UserInput;
|
||||
|
||||
fn initialize_editor(app: &mut App) -> (WindowId, ViewHandle<CodeEditorView>) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
// Add all required singleton models for EditorView dependencies
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| SyncedInputState::mock());
|
||||
app.add_singleton_model(|_| VimRegisters::new());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
|
||||
// Add mocks required by rich text editor (used in CommentEditor)
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| ActiveSession::default());
|
||||
app.add_singleton_model(NotebookKeybindings::new);
|
||||
|
||||
// Add UserWorkspaces mock (required by EditorView)
|
||||
let team_client_mock = Arc::new(MockTeamClient::new());
|
||||
let workspace_client_mock = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
team_client_mock.clone(),
|
||||
workspace_client_mock.clone(),
|
||||
vec![],
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let (window, editor_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::GrowToMaxHeight),
|
||||
ctx,
|
||||
)
|
||||
.with_horizontal_scrollbar_appearance(ScrollableAppearance::new(ScrollbarWidth::Auto, true))
|
||||
});
|
||||
|
||||
(window, editor_view)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interaction_state_prevents_editing() {
|
||||
App::test((), |mut app| async move {
|
||||
let (_window, editor_view) = initialize_editor(&mut app);
|
||||
|
||||
let text = editor_view.update(&mut app, |view, ctx| {
|
||||
view.handle_action(&CodeEditorViewAction::UserTyped(UserInput::new("abc")), ctx);
|
||||
view.text(ctx)
|
||||
});
|
||||
|
||||
assert_eq!(text.as_str(), "abc");
|
||||
|
||||
// Set to be only selectable
|
||||
editor_view.update(&mut app, |view, ctx| {
|
||||
view.set_interaction_state(InteractionState::Selectable, ctx);
|
||||
});
|
||||
|
||||
let text = editor_view.update(&mut app, |view, ctx| {
|
||||
view.handle_action(&CodeEditorViewAction::UserTyped(UserInput::new("def")), ctx);
|
||||
view.text(ctx)
|
||||
});
|
||||
|
||||
assert_eq!(text.as_str(), "abc");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
use super::{CodeEditorEvent, CodeEditorView};
|
||||
use crate::code::editor::{
|
||||
find::view::Event as FindViewEvent,
|
||||
model::{CaseTransform, CodeEditorModel, LineBound},
|
||||
};
|
||||
use crate::{
|
||||
view_components::find::FindDirection,
|
||||
vim_registers::{RegisterContent, VimRegisters},
|
||||
};
|
||||
use vim::vim::{
|
||||
BracketChar, CharacterMotion, Direction, FindCharMotion, FirstNonWhitespaceMotion,
|
||||
InsertPosition, LineMotion, ModeTransition, MotionType, TextObjectType, VimHandler, VimMode,
|
||||
VimMotion, VimOperand, VimOperator, VimTextObject, WordMotion,
|
||||
};
|
||||
use warp_editor::{
|
||||
content::buffer::{
|
||||
AutoScrollBehavior, BufferEditAction, EditOrigin, SelectionOffsets,
|
||||
ToBufferCharOffset as _, VimInsertPoint,
|
||||
},
|
||||
model::{CoreEditorModel, PlainTextEditorModel},
|
||||
selection::{TextDirection, TextUnit},
|
||||
};
|
||||
use warpui::{text::point::Point, SingletonEntity, ViewContext};
|
||||
|
||||
impl VimHandler for CodeEditorView {
|
||||
fn insert_char(&mut self, c: char, ctx: &mut ViewContext<Self>) {
|
||||
self.user_insert(&c.to_string(), ctx);
|
||||
}
|
||||
|
||||
fn keyword_prg(&mut self, _ctx: &mut ViewContext<Self>) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn navigate_char(
|
||||
&mut self,
|
||||
count: u32,
|
||||
character_motion: &CharacterMotion,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.model.update(ctx, |model, ctx| match character_motion {
|
||||
CharacterMotion::Right => {
|
||||
model.vim_move_horizontal_by_offset(count, &Direction::Forward, false, true, ctx);
|
||||
}
|
||||
CharacterMotion::Up => {
|
||||
model.vim_move_vertical_by_offset(count, TextDirection::Backwards, false, ctx);
|
||||
}
|
||||
CharacterMotion::Down => {
|
||||
model.vim_move_vertical_by_offset(count, TextDirection::Forwards, false, ctx);
|
||||
}
|
||||
CharacterMotion::Left => {
|
||||
model.vim_move_horizontal_by_offset(count, &Direction::Backward, false, true, ctx);
|
||||
}
|
||||
CharacterMotion::WrappingLeft => {
|
||||
model.vim_move_horizontal_by_offset(count, &Direction::Backward, false, false, ctx);
|
||||
}
|
||||
CharacterMotion::WrappingRight => {
|
||||
model.vim_move_horizontal_by_offset(count, &Direction::Forward, false, false, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn navigate_word(&mut self, count: u32, word_motion: &WordMotion, ctx: &mut ViewContext<Self>) {
|
||||
let WordMotion {
|
||||
direction,
|
||||
bound,
|
||||
word_type,
|
||||
} = word_motion;
|
||||
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_navigate_word(*direction, *bound, *word_type, count, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn navigate_line(&mut self, line_count: u32, motion: &LineMotion, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
match motion {
|
||||
LineMotion::Start => model.vim_move_to_line_bound(LineBound::Start, false, ctx),
|
||||
LineMotion::FirstNonWhitespace => model.vim_move_to_first_nonwhitespace(false, ctx),
|
||||
LineMotion::End => {
|
||||
// Only moving to the end of the line ($) uses number-repeat (the line-count var)
|
||||
model.vim_move_vertical_by_offset(
|
||||
line_count.saturating_sub(1),
|
||||
TextDirection::Forwards,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
model.vim_move_to_line_bound(LineBound::End, false, ctx);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn first_nonwhitespace_motion(
|
||||
&mut self,
|
||||
count: u32,
|
||||
motion: &FirstNonWhitespaceMotion,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
match motion {
|
||||
FirstNonWhitespaceMotion::Up => {
|
||||
model.vim_move_vertical_by_offset(count, TextDirection::Backwards, false, ctx);
|
||||
}
|
||||
FirstNonWhitespaceMotion::Down => {
|
||||
model.vim_move_vertical_by_offset(count, TextDirection::Forwards, false, ctx)
|
||||
}
|
||||
FirstNonWhitespaceMotion::DownMinusOne => model.vim_move_vertical_by_offset(
|
||||
count - 1,
|
||||
TextDirection::Forwards,
|
||||
false,
|
||||
ctx,
|
||||
),
|
||||
}
|
||||
|
||||
model.vim_move_to_first_nonwhitespace(false, ctx);
|
||||
})
|
||||
}
|
||||
|
||||
fn find_char(
|
||||
&mut self,
|
||||
occurrence_count: u32,
|
||||
find_char_motion: &FindCharMotion,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_find_char(
|
||||
false, /* keep_selection */
|
||||
occurrence_count,
|
||||
find_char_motion,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn navigate_paragraph(
|
||||
&mut self,
|
||||
count: u32,
|
||||
direction: &Direction,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_move_by_paragraph(count, direction, false, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn operation(
|
||||
&mut self,
|
||||
operator: &VimOperator,
|
||||
operand_count: u32,
|
||||
operand: &VimOperand,
|
||||
register_name: char,
|
||||
replacement_text: &str,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Selection logic is almost the same for all operators, so capture that in a closure first.
|
||||
let selection_change =
|
||||
|model: &mut CodeEditorModel, ctx: &mut warpui::ModelContext<CodeEditorModel>| {
|
||||
match operand {
|
||||
VimOperand::Motion {
|
||||
motion,
|
||||
motion_type,
|
||||
} => {
|
||||
match motion {
|
||||
VimMotion::Character(char_motion) => {
|
||||
model.vim_select_for_char_motion(
|
||||
char_motion,
|
||||
motion_type,
|
||||
operator,
|
||||
operand_count,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
VimMotion::Word(word_motion) => {
|
||||
model.vim_select_for_word_motion(
|
||||
word_motion,
|
||||
operand_count,
|
||||
motion_type,
|
||||
operator,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
VimMotion::Line(line_motion) => {
|
||||
model.vim_select_for_line_motion(
|
||||
line_motion,
|
||||
operand_count,
|
||||
motion_type,
|
||||
operator,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
VimMotion::FirstNonWhitespace(nonws_motion) => {
|
||||
model.vim_select_for_first_nonwhitespace_motion(
|
||||
nonws_motion,
|
||||
motion_type,
|
||||
operator,
|
||||
operand_count,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
VimMotion::Paragraph(direction) => {
|
||||
model.vim_move_by_paragraph(operand_count, direction, true, ctx);
|
||||
if *motion_type == MotionType::Linewise {
|
||||
let include_newline = *operator != VimOperator::Change;
|
||||
model.vim_extend_selection_linewise(include_newline, ctx);
|
||||
}
|
||||
}
|
||||
VimMotion::JumpToLastLine => {
|
||||
model.vim_select_to_buffer_end(ctx);
|
||||
if *motion_type == MotionType::Linewise {
|
||||
model.vim_extend_selection_linewise(
|
||||
*operator != VimOperator::Change,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
VimMotion::JumpToFirstLine => {
|
||||
model.vim_select_to_buffer_start(ctx);
|
||||
if *motion_type == MotionType::Linewise {
|
||||
model.vim_extend_selection_linewise(
|
||||
*operator != VimOperator::Change,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
VimMotion::FindChar(m) => {
|
||||
// Extend selection to the found character according to the motion
|
||||
model.vim_find_char(
|
||||
true, /* keep_selection */
|
||||
operand_count,
|
||||
m,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
VimMotion::JumpToLine(line_number) => {
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let selection_model = model.buffer_selection_model().as_ref(ctx);
|
||||
let current_selections = selection_model.selection_offsets();
|
||||
|
||||
let new_selections = current_selections.mapped(|selection| {
|
||||
let cursor_pos = selection.head;
|
||||
let target_pos =
|
||||
Point::new(*line_number, 0).to_buffer_char_offset(buffer);
|
||||
|
||||
SelectionOffsets {
|
||||
head: target_pos,
|
||||
tail: cursor_pos,
|
||||
}
|
||||
});
|
||||
|
||||
model.vim_set_selections(
|
||||
new_selections,
|
||||
AutoScrollBehavior::Selection,
|
||||
ctx,
|
||||
);
|
||||
|
||||
if *motion_type == MotionType::Linewise {
|
||||
let include_newline = *operator != VimOperator::Change;
|
||||
model.vim_extend_selection_linewise(include_newline, ctx);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// TODO: Implement other motions (find char, brackets, etc.)
|
||||
}
|
||||
}
|
||||
}
|
||||
VimOperand::Line => {
|
||||
// Extend selection down by count-1 lines
|
||||
if operand_count > 1 {
|
||||
model.vim_move_vertical_by_offset(
|
||||
operand_count - 1,
|
||||
TextDirection::Forwards,
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
let include_newline = operator != &VimOperator::Change
|
||||
&& operator != &VimOperator::ToggleComment;
|
||||
model.vim_extend_selection_linewise(include_newline, ctx);
|
||||
}
|
||||
VimOperand::TextObject(text_object) => {
|
||||
model.vim_select_text_object(text_object, Some(operator), ctx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let motion_type = match operand {
|
||||
VimOperand::Motion { motion_type, .. } => *motion_type,
|
||||
VimOperand::TextObject(text_object) => match text_object {
|
||||
VimTextObject {
|
||||
object_type: TextObjectType::Paragraph,
|
||||
..
|
||||
} => MotionType::Linewise,
|
||||
_ => MotionType::Charwise,
|
||||
},
|
||||
VimOperand::Line => MotionType::Linewise,
|
||||
};
|
||||
|
||||
match operator {
|
||||
VimOperator::Delete | VimOperator::Change => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
selection_change(model, ctx);
|
||||
|
||||
// Copy selection to vim register before modifying
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let selection_model = model.buffer_selection_model().clone();
|
||||
let selected_text = buffer
|
||||
.selected_text_as_plain_text(selection_model, ctx)
|
||||
.into_string();
|
||||
if !selected_text.is_empty() {
|
||||
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
|
||||
registers.write_to_register(
|
||||
register_name,
|
||||
selected_text,
|
||||
motion_type,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
if *operator == VimOperator::Change && motion_type == MotionType::Linewise {
|
||||
// Use smart indent to position the cursor when changing the entire
|
||||
// line.
|
||||
model.vim_change_line_with_smart_indent(ctx);
|
||||
} else {
|
||||
model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx);
|
||||
// Insert replacement text if provided
|
||||
if *operator == VimOperator::Change && !replacement_text.is_empty() {
|
||||
model.insert(replacement_text, EditOrigin::UserInitiated, ctx);
|
||||
}
|
||||
if motion_type == MotionType::Linewise {
|
||||
model.vim_move_to_line_bound(LineBound::Start, false, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
VimOperator::Yank => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
// Store existing selections to restore after yank
|
||||
let existing_selections = model.selections(ctx).clone();
|
||||
selection_change(model, ctx);
|
||||
|
||||
// Copy selection to vim register
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let selection_model = model.buffer_selection_model().clone();
|
||||
let selected_text = buffer
|
||||
.selected_text_as_plain_text(selection_model, ctx)
|
||||
.into_string();
|
||||
if !selected_text.is_empty() {
|
||||
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
|
||||
registers.write_to_register(
|
||||
register_name,
|
||||
selected_text,
|
||||
motion_type,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
match operand {
|
||||
VimOperand::TextObject(_) => {
|
||||
// For text objects, move to the start (min) of the selected range
|
||||
let starts = model
|
||||
.buffer_selection_model()
|
||||
.as_ref(ctx)
|
||||
.selection_offsets()
|
||||
.mapped(|selection| {
|
||||
let start = selection.head.min(selection.tail);
|
||||
SelectionOffsets {
|
||||
head: start,
|
||||
tail: start,
|
||||
}
|
||||
});
|
||||
model.vim_set_selections(starts, AutoScrollBehavior::None, ctx);
|
||||
}
|
||||
_ => {
|
||||
model.vim_set_selections(
|
||||
existing_selections,
|
||||
AutoScrollBehavior::None,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
VimOperator::ToggleCase => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.apply_case_transformation_with_selection_change(
|
||||
selection_change,
|
||||
CaseTransform::Toggle,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
VimOperator::Uppercase => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.apply_case_transformation_with_selection_change(
|
||||
selection_change,
|
||||
CaseTransform::Uppercase,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
VimOperator::Lowercase => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.apply_case_transformation_with_selection_change(
|
||||
selection_change,
|
||||
CaseTransform::Lowercase,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
VimOperator::ToggleComment => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
let existing_selections = model.selections(ctx).clone();
|
||||
selection_change(model, ctx);
|
||||
model.toggle_comments(ctx);
|
||||
|
||||
if motion_type == MotionType::Linewise {
|
||||
model.vim_move_to_first_nonwhitespace(false, ctx);
|
||||
} else {
|
||||
model.vim_set_selections(
|
||||
existing_selections,
|
||||
AutoScrollBehavior::None,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_char(&mut self, c: char, char_count: u32, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.replace_char(c, char_count, ctx);
|
||||
});
|
||||
|
||||
// Explicit call to ctx.notify() in the case that we don't make any updates to the model
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn search(&mut self, direction: &Direction, ctx: &mut ViewContext<Self>) {
|
||||
self.last_search_direction = *direction;
|
||||
self.show_find_bar(ctx);
|
||||
}
|
||||
|
||||
fn cycle_search(&mut self, direction: &Direction, ctx: &mut ViewContext<Self>) {
|
||||
let Some(find_bar) = &self.find_bar else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !self.searcher.as_ref(ctx).has_query() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !find_bar.as_ref(ctx).is_open() {
|
||||
find_bar.update(ctx, |find_bar, _| find_bar.set_open(true));
|
||||
}
|
||||
|
||||
// Vim-like behavior:
|
||||
// 'n' (Forward) repeats in the same direction
|
||||
// 'N' (Backward) reverses the last direction
|
||||
let effective_dir = match (direction, self.last_search_direction) {
|
||||
(Direction::Forward, dir) => dir,
|
||||
(Direction::Backward, Direction::Backward) => Direction::Forward,
|
||||
(Direction::Backward, Direction::Forward) => Direction::Backward,
|
||||
};
|
||||
|
||||
// Map vim::Direction to a FindDirection
|
||||
let find_dir = match effective_dir {
|
||||
Direction::Forward => FindDirection::Down,
|
||||
Direction::Backward => FindDirection::Up,
|
||||
};
|
||||
|
||||
find_bar.update(ctx, |_find_bar, ctx| {
|
||||
ctx.emit(FindViewEvent::NextMatch {
|
||||
direction: find_dir,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn search_word_at_cursor(&mut self, direction: &Direction, ctx: &mut ViewContext<Self>) {
|
||||
self.last_search_direction = *direction;
|
||||
let Some(find_bar) = &self.find_bar else {
|
||||
return;
|
||||
};
|
||||
|
||||
let word_under_cursor = self.model.as_ref(ctx).word_under_cursor_for_search(ctx);
|
||||
|
||||
if let Some(word) = word_under_cursor {
|
||||
if !word.trim().is_empty() {
|
||||
find_bar.update(ctx, |find_bar, ctx| {
|
||||
find_bar.set_find_query(ctx, &word);
|
||||
find_bar.set_open(true);
|
||||
// Disable the find input; the search is already defined.
|
||||
find_bar.set_find_input_editable(ctx, false);
|
||||
});
|
||||
|
||||
self.searcher
|
||||
.update(ctx, |searcher, _| searcher.set_auto_select(true));
|
||||
self.run_find(&word, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ex_command(&mut self, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
fn visual_operator(
|
||||
&mut self,
|
||||
operator: &VimOperator,
|
||||
motion_type: MotionType,
|
||||
register_name: char,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
// Compute the visual selection
|
||||
let include_newline = *operator != VimOperator::Change;
|
||||
model.vim_visual_selection_range(motion_type, include_newline, ctx);
|
||||
|
||||
if matches!(
|
||||
operator,
|
||||
VimOperator::Delete | VimOperator::Change | VimOperator::Yank
|
||||
) {
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let selection_model = model.buffer_selection_model().clone();
|
||||
let selected_text = buffer
|
||||
.selected_text_as_plain_text(selection_model, ctx)
|
||||
.into_string();
|
||||
if !selected_text.is_empty() {
|
||||
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
|
||||
registers.write_to_register(register_name, selected_text, motion_type, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match operator {
|
||||
VimOperator::Delete | VimOperator::Change => {
|
||||
let selection_model = model.buffer_selection_model().clone();
|
||||
model.update_content(
|
||||
|mut content, ctx| {
|
||||
content.apply_edit(
|
||||
BufferEditAction::Backspace,
|
||||
EditOrigin::UserInitiated,
|
||||
selection_model,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
if *operator == VimOperator::Change && motion_type == MotionType::Linewise {
|
||||
model.vim_change_line_with_smart_indent(ctx);
|
||||
}
|
||||
}
|
||||
VimOperator::ToggleCase | VimOperator::Lowercase | VimOperator::Uppercase => {
|
||||
let transform = match operator {
|
||||
VimOperator::ToggleCase => CaseTransform::Toggle,
|
||||
VimOperator::Uppercase => CaseTransform::Uppercase,
|
||||
VimOperator::Lowercase => CaseTransform::Lowercase,
|
||||
_ => CaseTransform::Toggle,
|
||||
};
|
||||
model.transform_current_selections_case(transform, ctx);
|
||||
}
|
||||
VimOperator::Yank => {
|
||||
model.vim_clear_selections(ctx);
|
||||
}
|
||||
VimOperator::ToggleComment => {
|
||||
model.toggle_comments(ctx);
|
||||
|
||||
if motion_type == MotionType::Linewise {
|
||||
model.vim_move_to_first_nonwhitespace(false, ctx);
|
||||
} else {
|
||||
model.vim_clear_selections(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Force a re-render so that residual Visual mode highlight is cleared.
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn visual_paste(
|
||||
&mut self,
|
||||
motion_type: MotionType,
|
||||
read_register_name: char,
|
||||
write_register_name: char,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Read content from the specified vim register
|
||||
let Some(RegisterContent {
|
||||
text,
|
||||
motion_type: yanked_motion_type,
|
||||
}) = VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
|
||||
registers.read_from_register(read_register_name, ctx)
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
// Compute the visual selection
|
||||
let include_newline =
|
||||
motion_type == MotionType::Linewise && yanked_motion_type == MotionType::Linewise;
|
||||
model.vim_visual_selection_range(motion_type, include_newline, ctx);
|
||||
|
||||
// Copy current selection to the write register before replacing it
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let selection_model = model.buffer_selection_model().clone();
|
||||
let selected_text = buffer
|
||||
.selected_text_as_plain_text(selection_model.clone(), ctx)
|
||||
.into_string();
|
||||
if !selected_text.is_empty() {
|
||||
VimRegisters::handle(ctx).update(ctx, |registers, ctx| {
|
||||
registers.write_to_register(
|
||||
write_register_name,
|
||||
selected_text,
|
||||
motion_type,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Replace selection with yanked text
|
||||
model.update_content(
|
||||
|mut content, ctx| {
|
||||
content.apply_edit(
|
||||
BufferEditAction::Insert {
|
||||
text: &text,
|
||||
style: model.active_text_style(),
|
||||
override_text_style: None,
|
||||
},
|
||||
EditOrigin::UserInitiated,
|
||||
selection_model,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
if motion_type == MotionType::Linewise {
|
||||
model.vim_move_to_line_bound(LineBound::Start, false, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn visual_text_object(&mut self, text_object: &VimTextObject, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_select_text_object(text_object, None, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn jump_to_first_line(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.jump_to_line_column(0, None, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn jump_to_last_line(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let max_point = buffer.max_point();
|
||||
model.jump_to_line_column(max_point.row as usize, None, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn jump_to_line(&mut self, line_number: u32, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.jump_to_line_column(line_number as usize, None, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn jump_to_matching_bracket(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_jump_to_matching_bracket(false, ctx);
|
||||
})
|
||||
}
|
||||
|
||||
fn jump_to_unmatched_bracket(&mut self, bracket: &BracketChar, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_jump_to_unmatched_bracket(bracket, false, ctx);
|
||||
})
|
||||
}
|
||||
|
||||
fn paste(
|
||||
&mut self,
|
||||
count: u32,
|
||||
direction: &Direction,
|
||||
register_name: char,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(RegisterContent { text, motion_type }) = VimRegisters::handle(ctx)
|
||||
.update(ctx, |registers, ctx| {
|
||||
registers.read_from_register(register_name, ctx)
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// For linewise cursor positioning, compute how many leading whitespace characters are at
|
||||
// the start of the first inserted line.
|
||||
let leading_ws = if motion_type == MotionType::Linewise {
|
||||
text.chars()
|
||||
.take_while(|c| c.is_whitespace() && *c != '\n')
|
||||
.count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let text = match motion_type {
|
||||
MotionType::Charwise => text,
|
||||
MotionType::Linewise => match direction {
|
||||
Direction::Backward => {
|
||||
// 'P' - paste above current line
|
||||
// Insert the text followed by a newline to push current line down
|
||||
trim_one_end_match(&text, '\n').to_owned() + "\n"
|
||||
}
|
||||
Direction::Forward => {
|
||||
// 'p' - paste below current line
|
||||
"\n".to_owned() + trim_one_end_match(&text, '\n')
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let insert_text = text.repeat(count as usize);
|
||||
|
||||
let (insert_point, cursor_offset_len) = match motion_type {
|
||||
MotionType::Charwise => match direction {
|
||||
Direction::Backward => (VimInsertPoint::BeforeCursor, insert_text.len() - 1),
|
||||
Direction::Forward => (VimInsertPoint::AtCursor, insert_text.len() - 1),
|
||||
},
|
||||
MotionType::Linewise => match direction {
|
||||
Direction::Backward => (VimInsertPoint::LineStart, leading_ws),
|
||||
// For linewise "p", offset the cursor by 1 to get onto the new line, then by the line's leading whitespace.
|
||||
Direction::Forward => (VimInsertPoint::LineEnd, 1 + leading_ws),
|
||||
},
|
||||
};
|
||||
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
let selection_model = model.buffer_selection_model().clone();
|
||||
model.update_content(
|
||||
|mut content, ctx| {
|
||||
content.apply_edit(
|
||||
BufferEditAction::VimEvent {
|
||||
text: insert_text,
|
||||
insert_point,
|
||||
cursor_offset_len,
|
||||
},
|
||||
EditOrigin::UserInitiated,
|
||||
selection_model,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn insert_text(
|
||||
&mut self,
|
||||
text: &str,
|
||||
position: &InsertPosition,
|
||||
count: u32,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_insert_text(text, position, count, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle_case(&mut self, char_count: u32, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_toggle_case_chars(char_count, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn join_line(&mut self, mut count: u32, ctx: &mut ViewContext<Self>) {
|
||||
// 1J joins two lines, which is the same as 2J.
|
||||
if count == 1 {
|
||||
count = 2;
|
||||
}
|
||||
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
let buffer = model.content().as_ref(ctx);
|
||||
let current_selections = model.selections(ctx);
|
||||
let mut replacement_ranges = Vec::new();
|
||||
|
||||
// For each selection, find `count` newlines to replace with spaces
|
||||
for selection in current_selections.iter() {
|
||||
let start_offset = selection.head;
|
||||
let mut current_offset = start_offset;
|
||||
let mut newlines_found = 0;
|
||||
|
||||
while newlines_found < count.saturating_sub(1) {
|
||||
let Some(ch) = buffer.char_at(current_offset) else {
|
||||
break;
|
||||
};
|
||||
|
||||
if ch == '\n' {
|
||||
newlines_found += 1;
|
||||
let mut range_end = current_offset + 1;
|
||||
|
||||
// Trim whitespace from the start of the next line
|
||||
while range_end < buffer.max_charoffset() {
|
||||
match buffer.char_at(range_end) {
|
||||
Some(ch) if ch.is_whitespace() && ch != '\n' => range_end += 1,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
replacement_ranges.push((current_offset, range_end));
|
||||
current_offset = range_end;
|
||||
} else {
|
||||
current_offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we have edits, update the model
|
||||
if let Ok(edits) = vec1::Vec1::try_from_vec(
|
||||
replacement_ranges
|
||||
.into_iter()
|
||||
.map(|(start, end)| (" ".to_string(), start..end))
|
||||
.collect(),
|
||||
) {
|
||||
let selection_model = model.buffer_selection_model().clone();
|
||||
model.update_content(
|
||||
|mut content, ctx| {
|
||||
content.apply_edit(
|
||||
BufferEditAction::InsertAtCharOffsetRanges { edits: &edits },
|
||||
EditOrigin::UserInitiated,
|
||||
selection_model,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn undo(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.undo(ctx);
|
||||
|
||||
// Clear selections after undo, for things like delete/change operations which
|
||||
// modify the editor state by changing selections and then making an insert/delete.
|
||||
//
|
||||
// TODO(liliwilson): this only works for the vim undo: cmd+Z and cmd+shift+z will undo
|
||||
// the operation but not the selection. Need a deeper change to the buffer model
|
||||
// undostack to support this.
|
||||
model.vim_clear_selections(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn change_mode(&mut self, old: &VimMode, new: &ModeTransition, ctx: &mut ViewContext<Self>) {
|
||||
match new.mode {
|
||||
VimMode::Normal => {
|
||||
if *old == VimMode::Insert {
|
||||
// When exiting insert mode, move cursor back to cover
|
||||
// the character that was last inserted. In vim, the cursor should
|
||||
// be ON the last inserted character, not after it.
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_move_horizontal_by_offset(
|
||||
1,
|
||||
&Direction::Backward,
|
||||
false,
|
||||
true,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
// Implement line capping for normal mode
|
||||
self.vim_maybe_enforce_cursor_line_cap(ctx);
|
||||
}
|
||||
VimMode::Insert => {
|
||||
// Apply insert position for different insert commands (i, a, o, etc.)
|
||||
self.vim_apply_insert_position(&new.position, ctx);
|
||||
}
|
||||
VimMode::Visual(_) => {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.vim_set_visual_tail_to_selection_heads(ctx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn backspace(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.backspace(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn delete_forward(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.model.update(ctx, |model, ctx| {
|
||||
model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn escape(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
match self.vim_mode(ctx) {
|
||||
Some(VimMode::Normal) => {
|
||||
ctx.emit(CodeEditorEvent::VimEscapeInNormalMode);
|
||||
}
|
||||
_ => {
|
||||
self.vim_escape(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn goto_definition(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(CodeEditorEvent::VimGotoDefinition);
|
||||
}
|
||||
|
||||
fn find_references(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(CodeEditorEvent::VimFindReferences);
|
||||
}
|
||||
|
||||
fn show_hover(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(CodeEditorEvent::VimShowHover);
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`str::trim_end_matches`] except that it only trims up to a single instance.
|
||||
fn trim_one_end_match(s: &str, ch: char) -> &str {
|
||||
if s.ends_with(ch) {
|
||||
&s[..s.len() - 1]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "vim_handler_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
use std::{
|
||||
collections::{hash_map::Entry, HashMap},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::ai::skills::SkillOpenOrigin;
|
||||
use ai::skills::SkillReference;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle, WindowId};
|
||||
|
||||
use crate::{
|
||||
ai::agent::AIAgentActionId,
|
||||
code_review::code_review_view::CodeReviewView,
|
||||
pane_group::{PaneGroup, PaneId},
|
||||
workspace::PaneViewLocator,
|
||||
};
|
||||
|
||||
use super::view::CodeView;
|
||||
|
||||
pub struct CodeEditorSummary<'a> {
|
||||
pub unsaved_changes: Vec<&'a CodeEditorStatus>,
|
||||
}
|
||||
|
||||
impl<'a> CodeEditorSummary<'a> {
|
||||
/// Create a summary from the currently open Code Editors.
|
||||
pub fn new(editors: &'a [CodeEditorStatus]) -> Self {
|
||||
let unsaved_changes = editors
|
||||
.iter()
|
||||
.filter(|editor| editor.unsaved_changes)
|
||||
.collect();
|
||||
|
||||
Self { unsaved_changes }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct CodeEditorStatus {
|
||||
unsaved_changes: bool,
|
||||
}
|
||||
|
||||
impl CodeEditorStatus {
|
||||
pub fn new(unsaved_changes: bool) -> Self {
|
||||
Self { unsaved_changes }
|
||||
}
|
||||
|
||||
/// Fetches all code editors open in the App.
|
||||
pub fn all_editors(app: &AppContext) -> impl Iterator<Item = Self> + '_ {
|
||||
app.window_ids()
|
||||
.flat_map(move |window_id| Self::editors_in_window(window_id, app))
|
||||
}
|
||||
|
||||
/// Fetches all code editors in a given window.
|
||||
pub fn editors_in_window(
|
||||
window_id: WindowId,
|
||||
app: &AppContext,
|
||||
) -> impl Iterator<Item = Self> + '_ {
|
||||
app.views_of_type::<CodeView>(window_id)
|
||||
.into_iter()
|
||||
.flat_map(move |editors| {
|
||||
editors
|
||||
.into_iter()
|
||||
.map(move |editor| Self::editor_status(&editor, app))
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches all code editors in a given tab.
|
||||
pub fn editors_in_tab<'a>(
|
||||
tab: &ViewHandle<PaneGroup>,
|
||||
app: &'a AppContext,
|
||||
) -> impl Iterator<Item = Self> + 'a {
|
||||
tab.as_ref(app)
|
||||
.code_panes(app)
|
||||
.map(move |(_, editor)| Self::editor_status(&editor, app))
|
||||
}
|
||||
|
||||
pub fn editor_status(editor: &ViewHandle<CodeView>, app: &AppContext) -> Self {
|
||||
editor.read(app, |editor_view, ctx| Self {
|
||||
unsaved_changes: editor_view.contains_unsaved_changes(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn status_for_code_review(review: &ViewHandle<CodeReviewView>, app: &AppContext) -> Self {
|
||||
review.read(app, |review_view, ctx| Self {
|
||||
unsaved_changes: review_view.has_unsaved_changes(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches all code review views in a given window (including panel views).
|
||||
pub fn code_review_views_in_window(
|
||||
window_id: WindowId,
|
||||
app: &AppContext,
|
||||
) -> impl Iterator<Item = Self> + '_ {
|
||||
app.views_of_type::<CodeReviewView>(window_id)
|
||||
.into_iter()
|
||||
.flat_map(move |editors| {
|
||||
editors
|
||||
.into_iter()
|
||||
.map(move |editor| Self::status_for_code_review(&editor, app))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)]
|
||||
pub enum CodeSource {
|
||||
/// A new code pane not attached to an existing file.
|
||||
New {
|
||||
/// When the new file is saved, open the file picker to this directory.
|
||||
default_directory: Option<PathBuf>,
|
||||
},
|
||||
/// Opened from file links.
|
||||
Link {
|
||||
path: PathBuf,
|
||||
range_start: Option<LineAndColumnArg>,
|
||||
range_end: Option<LineAndColumnArg>,
|
||||
},
|
||||
/// Opened from an active AI agent conversation.
|
||||
AIAction { id: AIAgentActionId },
|
||||
/// Opened from project rules (WARP.md) file.
|
||||
ProjectRules { path: PathBuf },
|
||||
/// Opened from file tree.
|
||||
FileTree { path: PathBuf },
|
||||
/// Opened from macOS Finder via "Open With".
|
||||
Finder { path: PathBuf },
|
||||
/// Opened from a skill.
|
||||
Skill {
|
||||
reference: SkillReference,
|
||||
path: PathBuf,
|
||||
origin: SkillOpenOrigin,
|
||||
},
|
||||
}
|
||||
|
||||
impl CodeSource {
|
||||
pub fn default_directory(&self) -> Option<&PathBuf> {
|
||||
match self {
|
||||
Self::New {
|
||||
default_directory, ..
|
||||
} => default_directory.as_ref(),
|
||||
Self::Link { .. }
|
||||
| Self::AIAction { .. }
|
||||
| Self::ProjectRules { .. }
|
||||
| Self::FileTree { .. }
|
||||
| Self::Finder { .. }
|
||||
| Self::Skill { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
match self {
|
||||
Self::New { .. } | Self::AIAction { .. } => None,
|
||||
Self::Link { path, .. }
|
||||
| Self::ProjectRules { path }
|
||||
| Self::FileTree { path }
|
||||
| Self::Finder { path }
|
||||
| Self::Skill { path, .. } => Some(path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this is a bundled skill that should be read-only.
|
||||
pub fn is_bundled_skill(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Skill {
|
||||
reference: SkillReference::BundledSkillId(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn omit_line_col(&self) -> CodeSource {
|
||||
if let CodeSource::Link { path, .. } = self {
|
||||
CodeSource::Link {
|
||||
path: path.clone(),
|
||||
range_start: None,
|
||||
range_end: None,
|
||||
}
|
||||
} else {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the variant name as a string for telemetry purposes.
|
||||
pub fn telemetry_source_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::New { .. } => "new",
|
||||
Self::Link { .. } => "link",
|
||||
Self::AIAction { .. } => "ai_action",
|
||||
Self::ProjectRules { .. } => "project_rules",
|
||||
Self::FileTree { .. } => "file_tree",
|
||||
Self::Finder { .. } => "finder",
|
||||
Self::Skill { .. } => "skill",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this source should be restored across app restarts.
|
||||
///
|
||||
/// `AIAction` is ephemeral (tied to a live conversation) and should not
|
||||
/// be restored.
|
||||
pub fn is_restorable(&self) -> bool {
|
||||
!matches!(self, Self::AIAction { .. })
|
||||
}
|
||||
}
|
||||
|
||||
struct CodePaneData {
|
||||
#[allow(unused)]
|
||||
window_id: WindowId,
|
||||
#[allow(unused)]
|
||||
locator: PaneViewLocator,
|
||||
}
|
||||
|
||||
// Allow dead_code here for wasm compilation
|
||||
#[allow(dead_code)]
|
||||
pub enum CodeManagerEvent {
|
||||
EditCompleted { action_id: AIAgentActionId },
|
||||
}
|
||||
|
||||
/// Singleton model for managing the state of open code panes. It is responsible for
|
||||
/// 1) Allow caller to find an open code pane if exists.
|
||||
/// 2) Allow other sources to listen for events emitted when code pane is closed.
|
||||
#[derive(Default)]
|
||||
pub struct CodeManager {
|
||||
source_to_pane_data: HashMap<CodeSource, CodePaneData>,
|
||||
}
|
||||
|
||||
impl CodeManager {
|
||||
/// Register a new pane in the code manager.
|
||||
pub fn register_pane(
|
||||
&mut self,
|
||||
pane_group_id: EntityId,
|
||||
window_id: WindowId,
|
||||
pane_id: PaneId,
|
||||
source: CodeSource,
|
||||
) {
|
||||
let entry = self.source_to_pane_data.entry(source.omit_line_col());
|
||||
if let Entry::Vacant(entry) = entry {
|
||||
entry.insert(CodePaneData {
|
||||
window_id,
|
||||
locator: PaneViewLocator {
|
||||
pane_group_id,
|
||||
pane_id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
log::warn!("Ignoring duplicate code pane registration");
|
||||
}
|
||||
}
|
||||
|
||||
/// De-register an open code pane when it's removed from a pane group.
|
||||
pub fn deregister_pane(&mut self, source: &CodeSource) {
|
||||
self.source_to_pane_data.remove(&source.omit_line_col());
|
||||
}
|
||||
/// Returns the locator for a code pane that already has `path` open in the given pane group.
|
||||
pub fn get_locator_for_path_in_tab(
|
||||
&self,
|
||||
pane_group_id: EntityId,
|
||||
path: &Path,
|
||||
) -> Option<PaneViewLocator> {
|
||||
self.source_to_pane_data
|
||||
.iter()
|
||||
.find(|(source, data)| {
|
||||
data.locator.pane_group_id == pane_group_id
|
||||
&& source.path().is_some_and(|p| p.as_path() == path)
|
||||
})
|
||||
.map(|(_, data)| data.locator)
|
||||
}
|
||||
|
||||
// Allow dead_code here for wasm compilation
|
||||
#[allow(dead_code)]
|
||||
pub fn complete_pending_diffs(&mut self, source: CodeSource, ctx: &mut ModelContext<Self>) {
|
||||
if !self.source_to_pane_data.contains_key(&source) {
|
||||
log::warn!("Trying to complete an edit on a source that doesn't exist");
|
||||
}
|
||||
|
||||
let CodeSource::AIAction { id } = source else {
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.emit(CodeManagerEvent::EditCompleted { action_id: id })
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CodeManager {
|
||||
type Event = CodeManagerEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for CodeManager {}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! File picker component for rendering expandable folder structures.
|
||||
|
||||
pub mod snapshot;
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code, unused_imports))]
|
||||
mod view;
|
||||
|
||||
pub use view::*;
|
||||
@@ -0,0 +1,368 @@
|
||||
#![allow(dead_code)]
|
||||
//! SumTree-based file tree snapshot for efficient lookups and virtualized rendering.
|
||||
//!
|
||||
//! This module provides a SumTree-based data model for the file tree view.
|
||||
|
||||
#[path = "snapshot/iterator.rs"]
|
||||
mod iterator;
|
||||
|
||||
use std::{cmp::Ordering, ops::AddAssign, path::Path, sync::Arc};
|
||||
|
||||
use sum_tree::{Edit, KeyedItem, SeekBias, SumTree};
|
||||
|
||||
/// Represents a single entry in the file tree.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FileEntry {
|
||||
/// The absolute path to this entry.
|
||||
pub path: Arc<Path>,
|
||||
/// Whether this is a file or directory.
|
||||
pub kind: FileEntryKind,
|
||||
/// Whether this entry is ignored by gitignore.
|
||||
pub ignored: bool,
|
||||
/// For directories: whether the contents have been loaded.
|
||||
/// For files: always true.
|
||||
pub loaded: bool,
|
||||
}
|
||||
|
||||
impl FileEntry {
|
||||
/// Creates a new file entry.
|
||||
pub fn file(path: impl Into<Arc<Path>>, ignored: bool) -> Self {
|
||||
let path = path.into();
|
||||
let extension = path.extension().and_then(|e| e.to_str()).map(Arc::from);
|
||||
Self {
|
||||
path,
|
||||
kind: FileEntryKind::File { extension },
|
||||
ignored,
|
||||
loaded: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new directory entry.
|
||||
pub fn directory(path: impl Into<Arc<Path>>, ignored: bool, loaded: bool) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
kind: FileEntryKind::Directory,
|
||||
ignored,
|
||||
loaded,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this is a directory.
|
||||
pub fn is_dir(&self) -> bool {
|
||||
matches!(self.kind, FileEntryKind::Directory)
|
||||
}
|
||||
|
||||
/// Returns true if this is a file.
|
||||
pub fn is_file(&self) -> bool {
|
||||
matches!(self.kind, FileEntryKind::File { .. })
|
||||
}
|
||||
|
||||
/// Returns the file extension if this is a file.
|
||||
#[cfg(test)]
|
||||
pub fn extension(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
FileEntryKind::File { extension } => extension.as_deref(),
|
||||
FileEntryKind::Directory => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of file tree entry.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileEntryKind {
|
||||
File { extension: Option<Arc<str>> },
|
||||
Directory,
|
||||
}
|
||||
|
||||
/// Summary of file entries for aggregate queries.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FileEntrySummary {
|
||||
/// The maximum (lexicographically last) path in this subtree.
|
||||
max_path: Arc<Path>,
|
||||
/// Total count of entries in this subtree.
|
||||
count: usize,
|
||||
/// Count of non-ignored entries in this subtree.
|
||||
visible_count: usize,
|
||||
/// Count of files (not directories) in this subtree.
|
||||
file_count: usize,
|
||||
/// Count of non-ignored files in this subtree.
|
||||
visible_file_count: usize,
|
||||
}
|
||||
|
||||
impl Default for FileEntrySummary {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_path: Arc::from(Path::new("")),
|
||||
count: 0,
|
||||
visible_count: 0,
|
||||
file_count: 0,
|
||||
visible_file_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AddAssign<&FileEntrySummary> for FileEntrySummary {
|
||||
fn add_assign(&mut self, rhs: &FileEntrySummary) {
|
||||
// Entries are sorted by path, so the rightmost (rhs) summary has the max path.
|
||||
self.max_path = rhs.max_path.clone();
|
||||
self.count += rhs.count;
|
||||
self.visible_count += rhs.visible_count;
|
||||
self.file_count += rhs.file_count;
|
||||
self.visible_file_count += rhs.visible_file_count;
|
||||
}
|
||||
}
|
||||
|
||||
impl sum_tree::Item for FileEntry {
|
||||
type Summary = FileEntrySummary;
|
||||
|
||||
fn summary(&self) -> Self::Summary {
|
||||
let is_visible = !self.ignored;
|
||||
let is_file = self.is_file();
|
||||
|
||||
FileEntrySummary {
|
||||
max_path: self.path.clone(),
|
||||
count: 1,
|
||||
visible_count: if is_visible { 1 } else { 0 },
|
||||
file_count: if is_file { 1 } else { 0 },
|
||||
visible_file_count: if is_visible && is_file { 1 } else { 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Newtype for path-based lookups in the SumTree.
|
||||
///
|
||||
/// We can't use `Arc<Path>` directly because:
|
||||
/// 1. Orphan rule: can't impl `sum_tree::Dimension` for external type
|
||||
/// 2. `Arc<Path>` has no `Default` impl, which SumTree cursors require
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PathKey(pub Arc<Path>);
|
||||
|
||||
impl Default for PathKey {
|
||||
fn default() -> Self {
|
||||
Self(Arc::from(Path::new("")))
|
||||
}
|
||||
}
|
||||
|
||||
impl PathKey {
|
||||
pub fn new(path: impl Into<Arc<Path>>) -> Self {
|
||||
Self(path.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for PathKey {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.0.cmp(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for PathKey {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> sum_tree::Dimension<'a, FileEntrySummary> for PathKey {
|
||||
fn add_summary(&mut self, summary: &'a FileEntrySummary) {
|
||||
self.0 = summary.max_path.clone();
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyedItem for FileEntry {
|
||||
type Key = PathKey;
|
||||
|
||||
fn key(&self) -> Self::Key {
|
||||
PathKey(self.path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of the file tree stored in a SumTree.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FileTreeSnapshot {
|
||||
/// The root path of this file tree.
|
||||
root_path: Arc<Path>,
|
||||
/// Entries sorted by path.
|
||||
pub(super) entries_by_path: SumTree<FileEntry>,
|
||||
}
|
||||
|
||||
impl FileTreeSnapshot {
|
||||
/// Creates an empty snapshot with the given root path.
|
||||
pub fn new(root_path: impl Into<Arc<Path>>) -> Self {
|
||||
Self {
|
||||
root_path: root_path.into(),
|
||||
entries_by_path: SumTree::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a snapshot with a root directory entry.
|
||||
pub fn with_root(root_path: impl Into<Arc<Path>>, ignored: bool, loaded: bool) -> Self {
|
||||
let root_path = root_path.into();
|
||||
let mut snapshot = Self::new(root_path.clone());
|
||||
snapshot.insert_entry(FileEntry::directory(root_path, ignored, loaded));
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Returns the root path of this file tree.
|
||||
pub fn root_path(&self) -> &Arc<Path> {
|
||||
&self.root_path
|
||||
}
|
||||
|
||||
/// Returns the total number of entries.
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries_by_path.summary().count
|
||||
}
|
||||
|
||||
/// Returns true if there are no entries.
|
||||
#[cfg(test)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Looks up an entry by path. O(log n).
|
||||
pub fn entry_for_path(&self, path: &Path) -> Option<&FileEntry> {
|
||||
let key = PathKey::new(Arc::from(path));
|
||||
let mut cursor = self.entries_by_path.cursor::<PathKey, ()>();
|
||||
cursor.seek(&key, SeekBias::Left);
|
||||
cursor.item().filter(|entry| entry.path.as_ref() == path)
|
||||
}
|
||||
|
||||
/// Inserts or updates an entry. O(log n).
|
||||
pub fn insert_entry(&mut self, entry: FileEntry) {
|
||||
self.entries_by_path.edit(&mut [Edit::Insert(entry)]);
|
||||
}
|
||||
|
||||
/// Removes an entry by path. O(log n).
|
||||
pub fn remove_entry(&mut self, path: &Path) {
|
||||
if let Some(entry) = self.entry_for_path(path).cloned() {
|
||||
self.entries_by_path.edit(&mut [Edit::Remove(entry)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over direct children of the given directory path.
|
||||
pub fn child_entries<'a>(
|
||||
&'a self,
|
||||
parent_path: &'a Path,
|
||||
) -> impl Iterator<Item = &'a FileEntry> {
|
||||
iterator::ChildEntriesIter::new(self, parent_path)
|
||||
}
|
||||
|
||||
/// Checks if the parent directory of the given path is loaded.
|
||||
/// Returns true if the parent exists and is loaded, or if the path is the root.
|
||||
pub fn is_parent_loaded(&self, path: &Path) -> bool {
|
||||
let Some(parent) = path.parent() else {
|
||||
// No parent means this is a root-level path
|
||||
return true;
|
||||
};
|
||||
|
||||
// If parent is the root path, check if it's loaded
|
||||
if parent == self.root_path.as_ref() {
|
||||
return self
|
||||
.entry_for_path(parent)
|
||||
.is_some_and(|e| e.is_dir() && e.loaded);
|
||||
}
|
||||
|
||||
// Check if parent directory exists and is loaded
|
||||
self.entry_for_path(parent)
|
||||
.is_some_and(|e| e.is_dir() && e.loaded)
|
||||
}
|
||||
|
||||
/// Handles a file/directory being added.
|
||||
/// Returns true if the entry was added, false if the parent is not loaded.
|
||||
pub fn handle_added(&mut self, path: &Path, is_dir: bool, ignored: bool) -> bool {
|
||||
if !self.is_parent_loaded(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let entry = if is_dir {
|
||||
FileEntry::directory(Arc::from(path), ignored, false)
|
||||
} else {
|
||||
FileEntry::file(Arc::from(path), ignored)
|
||||
};
|
||||
self.insert_entry(entry);
|
||||
true
|
||||
}
|
||||
|
||||
/// Handles a file/directory being removed.
|
||||
/// Returns true if the entry was removed, false if it didn't exist or parent is not loaded.
|
||||
pub fn handle_removed(&mut self, path: &Path) -> bool {
|
||||
if !self.is_parent_loaded(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.entry_for_path(path).is_some() {
|
||||
self.remove_entry(path);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames an entry from old_path to new_path, preserving its properties.
|
||||
pub fn rename_entry(&mut self, old_path: &Path, new_path: &Path) {
|
||||
let Some(old_entry) = self.entry_for_path(old_path).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Remove the old entry
|
||||
self.remove_entry(old_path);
|
||||
|
||||
// Create a new entry at the new path with the same properties
|
||||
let new_entry = FileEntry {
|
||||
path: Arc::from(new_path),
|
||||
kind: old_entry.kind,
|
||||
ignored: old_entry.ignored,
|
||||
loaded: old_entry.loaded,
|
||||
};
|
||||
self.insert_entry(new_entry);
|
||||
}
|
||||
|
||||
/// Expands a directory by marking it as loaded.
|
||||
pub fn expand_directory(&mut self, path: &Path) -> Option<()> {
|
||||
let entry = self.entry_for_path(path)?.clone();
|
||||
if !entry.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let updated = FileEntry {
|
||||
loaded: true,
|
||||
..entry
|
||||
};
|
||||
self.insert_entry(updated);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Populates a directory with its children from the filesystem.
|
||||
/// This scans the directory and adds all immediate children.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn load_directory_children(
|
||||
&mut self,
|
||||
dir_path: &Path,
|
||||
check_ignored: impl Fn(&Path) -> bool,
|
||||
) -> std::io::Result<()> {
|
||||
use std::fs;
|
||||
|
||||
// Mark directory as loaded
|
||||
self.expand_directory(dir_path);
|
||||
|
||||
// Read directory contents
|
||||
for entry in fs::read_dir(dir_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let is_dir = entry.file_type()?.is_dir();
|
||||
let ignored = check_ignored(&path);
|
||||
|
||||
let file_entry = if is_dir {
|
||||
FileEntry::directory(Arc::from(path.as_path()), ignored, false)
|
||||
} else {
|
||||
FileEntry::file(Arc::from(path.as_path()), ignored)
|
||||
};
|
||||
self.insert_entry(file_entry);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "snapshot_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Iterator implementations for FileTreeSnapshot.
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use sum_tree::{Cursor, SeekBias};
|
||||
|
||||
use super::{FileEntry, FileTreeSnapshot, PathKey};
|
||||
|
||||
/// Iterator over direct children of a directory.
|
||||
///
|
||||
/// # How it works
|
||||
///
|
||||
/// Entries in the SumTree are sorted lexicographically by path. This means all entries
|
||||
/// within a directory's subtree are **contiguous** in the sorted order:
|
||||
///
|
||||
/// ```text
|
||||
/// Sorted entries for child_entries("/project/src/"):
|
||||
/// ┌─────────────┬─────────────────────┬──────────────────────┬─────────────────────────────┬─────────────┐
|
||||
/// │ /project/ │ /project/src/ │ /project/src/lib.rs │ /project/src/utils/ │ /project/z │
|
||||
/// │ │ (skip: parent) │ ✓ yield (1 comp) │ ✓ yield (1 comp) │ (stop) │
|
||||
/// │ │ ↓ │ │ │ │
|
||||
/// │ │ cursor starts here │ │ /project/src/utils/helper.rs│ │
|
||||
/// │ │ │ │ (skip: 2 components) │ │
|
||||
/// └─────────────┴─────────────────────┴──────────────────────┴─────────────────────────────┴─────────────┘
|
||||
/// ```
|
||||
///
|
||||
/// The iterator:
|
||||
/// 1. Seeks to the parent path in O(log n)
|
||||
/// 2. Skips the parent directory entry itself
|
||||
/// 3. Iterates forward, yielding entries with exactly 1 path component after the parent prefix
|
||||
/// 4. Skips deeper descendants (2+ components) — they'll be visited when their parent is expanded
|
||||
/// 5. Stops when reaching an entry outside the parent's subtree
|
||||
pub struct ChildEntriesIter<'a> {
|
||||
cursor: Cursor<'a, FileEntry, PathKey, ()>,
|
||||
parent_path: &'a Path,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<'a> ChildEntriesIter<'a> {
|
||||
pub(super) fn new(snapshot: &'a FileTreeSnapshot, parent_path: &'a Path) -> Self {
|
||||
let mut cursor = snapshot.entries_by_path.cursor::<PathKey, ()>();
|
||||
let key = PathKey::new(Arc::from(parent_path));
|
||||
cursor.seek(&key, SeekBias::Left);
|
||||
|
||||
// Skip past the parent directory itself
|
||||
if cursor
|
||||
.item()
|
||||
.is_some_and(|e| e.path.as_ref() == parent_path)
|
||||
{
|
||||
cursor.next();
|
||||
}
|
||||
|
||||
Self {
|
||||
cursor,
|
||||
parent_path,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for ChildEntriesIter<'a> {
|
||||
type Item = &'a FileEntry;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.done {
|
||||
return None;
|
||||
}
|
||||
|
||||
loop {
|
||||
let entry = self.cursor.item()?;
|
||||
|
||||
// Stop if we've moved past the parent's subtree (lexicographically)
|
||||
if !entry.path.starts_with(self.parent_path) {
|
||||
self.done = true;
|
||||
return None;
|
||||
}
|
||||
|
||||
// Count path components after the parent prefix to determine depth
|
||||
let relative = entry.path.strip_prefix(self.parent_path).ok()?;
|
||||
let components: Vec<_> = relative.components().collect();
|
||||
|
||||
self.cursor.next();
|
||||
|
||||
// Yield only direct children (exactly 1 component after parent)
|
||||
// Skip grandchildren and deeper (2+ components)
|
||||
if components.len() == 1 {
|
||||
return Some(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
//! Tests for the file tree snapshot module.
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
use sum_tree::Item;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Helper to create a test snapshot from a list of path strings.
|
||||
/// Paths ending in '/' are treated as directories, others as files.
|
||||
/// Paths starting with '!' are marked as ignored.
|
||||
fn test_snapshot(paths: &[&str]) -> FileTreeSnapshot {
|
||||
let root = paths
|
||||
.first()
|
||||
.map(|p| p.trim_start_matches('!'))
|
||||
.unwrap_or("/");
|
||||
let root_path: Arc<Path> = Arc::from(Path::new(root.trim_end_matches('/')));
|
||||
let mut snapshot = FileTreeSnapshot::new(root_path);
|
||||
|
||||
for path_str in paths {
|
||||
let ignored = path_str.starts_with('!');
|
||||
let path_str = path_str.trim_start_matches('!');
|
||||
let is_dir = path_str.ends_with('/');
|
||||
let path_str = path_str.trim_end_matches('/');
|
||||
let path: Arc<Path> = Arc::from(Path::new(path_str));
|
||||
|
||||
let entry = if is_dir {
|
||||
FileEntry::directory(path, ignored, true)
|
||||
} else {
|
||||
FileEntry::file(path, ignored)
|
||||
};
|
||||
snapshot.insert_entry(entry);
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FileEntry Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_file_entry_creation() {
|
||||
let file = FileEntry::file(Path::new("/src/main.rs"), false);
|
||||
assert!(file.is_file());
|
||||
assert!(!file.is_dir());
|
||||
assert_eq!(file.extension(), Some("rs"));
|
||||
assert!(!file.ignored);
|
||||
assert!(file.loaded);
|
||||
|
||||
let dir = FileEntry::directory(Path::new("/src"), false, true);
|
||||
assert!(dir.is_dir());
|
||||
assert!(!dir.is_file());
|
||||
assert_eq!(dir.extension(), None);
|
||||
assert!(!dir.ignored);
|
||||
assert!(dir.loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_entry_ignored() {
|
||||
let ignored_file = FileEntry::file(Path::new("/target/debug/main"), true);
|
||||
assert!(ignored_file.ignored);
|
||||
|
||||
let ignored_dir = FileEntry::directory(Path::new("/target"), true, false);
|
||||
assert!(ignored_dir.ignored);
|
||||
assert!(!ignored_dir.loaded);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FileEntrySummary Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_summary_for_visible_file() {
|
||||
let entry = FileEntry::file(Path::new("/src/main.rs"), false);
|
||||
let summary = entry.summary();
|
||||
|
||||
assert_eq!(summary.count, 1);
|
||||
assert_eq!(summary.visible_count, 1);
|
||||
assert_eq!(summary.file_count, 1);
|
||||
assert_eq!(summary.visible_file_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_for_ignored_file() {
|
||||
let entry = FileEntry::file(Path::new("/target/debug/main"), true);
|
||||
let summary = entry.summary();
|
||||
|
||||
assert_eq!(summary.count, 1);
|
||||
assert_eq!(summary.visible_count, 0);
|
||||
assert_eq!(summary.file_count, 1);
|
||||
assert_eq!(summary.visible_file_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_for_visible_directory() {
|
||||
let entry = FileEntry::directory(Path::new("/src"), false, true);
|
||||
let summary = entry.summary();
|
||||
|
||||
assert_eq!(summary.count, 1);
|
||||
assert_eq!(summary.visible_count, 1);
|
||||
assert_eq!(summary.file_count, 0);
|
||||
assert_eq!(summary.visible_file_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_for_ignored_directory() {
|
||||
let entry = FileEntry::directory(Path::new("/target"), true, false);
|
||||
let summary = entry.summary();
|
||||
|
||||
assert_eq!(summary.count, 1);
|
||||
assert_eq!(summary.visible_count, 0);
|
||||
assert_eq!(summary.file_count, 0);
|
||||
assert_eq!(summary.visible_file_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summary_add_assign() {
|
||||
let mut summary1 = FileEntrySummary {
|
||||
max_path: Arc::from(Path::new("/a")),
|
||||
count: 2,
|
||||
visible_count: 1,
|
||||
file_count: 1,
|
||||
visible_file_count: 1,
|
||||
};
|
||||
|
||||
let summary2 = FileEntrySummary {
|
||||
max_path: Arc::from(Path::new("/b")),
|
||||
count: 3,
|
||||
visible_count: 2,
|
||||
file_count: 2,
|
||||
visible_file_count: 1,
|
||||
};
|
||||
|
||||
summary1 += &summary2;
|
||||
|
||||
assert_eq!(summary1.max_path.as_ref(), Path::new("/b"));
|
||||
assert_eq!(summary1.count, 5);
|
||||
assert_eq!(summary1.visible_count, 3);
|
||||
assert_eq!(summary1.file_count, 3);
|
||||
assert_eq!(summary1.visible_file_count, 2);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PathKey Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_path_key_ordering() {
|
||||
let key_a = PathKey::new(Path::new("/a"));
|
||||
let key_b = PathKey::new(Path::new("/b"));
|
||||
let key_aa = PathKey::new(Path::new("/a/a"));
|
||||
|
||||
assert!(key_a < key_aa);
|
||||
assert!(key_aa < key_b);
|
||||
assert!(key_a < key_b);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FileTreeSnapshot Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_with_root() {
|
||||
let snapshot = FileTreeSnapshot::with_root(Path::new("/project"), false, true);
|
||||
assert_eq!(snapshot.len(), 1);
|
||||
|
||||
let root = snapshot.entry_for_path(Path::new("/project")).unwrap();
|
||||
assert!(root.is_dir());
|
||||
assert!(root.loaded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_and_lookup() {
|
||||
let snapshot = test_snapshot(&[
|
||||
"/project/",
|
||||
"/project/src/",
|
||||
"/project/src/main.rs",
|
||||
"/project/src/lib.rs",
|
||||
]);
|
||||
|
||||
assert_eq!(snapshot.len(), 4);
|
||||
|
||||
let main_rs = snapshot.entry_for_path(Path::new("/project/src/main.rs"));
|
||||
assert!(main_rs.is_some());
|
||||
assert!(main_rs.unwrap().is_file());
|
||||
|
||||
let src = snapshot.entry_for_path(Path::new("/project/src"));
|
||||
assert!(src.is_some());
|
||||
assert!(src.unwrap().is_dir());
|
||||
|
||||
let missing = snapshot.entry_for_path(Path::new("/project/nonexistent"));
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_entry() {
|
||||
let mut snapshot = test_snapshot(&["/project/", "/project/src/", "/project/src/main.rs"]);
|
||||
|
||||
assert_eq!(snapshot.len(), 3);
|
||||
|
||||
snapshot.remove_entry(Path::new("/project/src/main.rs"));
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
assert!(snapshot
|
||||
.entry_for_path(Path::new("/project/src/main.rs"))
|
||||
.is_none());
|
||||
assert!(snapshot.entry_for_path(Path::new("/project/src")).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_child_entries() {
|
||||
let snapshot = test_snapshot(&[
|
||||
"/project/",
|
||||
"/project/src/",
|
||||
"/project/src/main.rs",
|
||||
"/project/src/lib.rs",
|
||||
"/project/tests/",
|
||||
"/project/tests/test.rs",
|
||||
"/project/Cargo.toml",
|
||||
]);
|
||||
|
||||
let root_children: Vec<_> = snapshot.child_entries(Path::new("/project")).collect();
|
||||
assert_eq!(root_children.len(), 3);
|
||||
|
||||
let child_paths: Vec<_> = root_children.iter().map(|e| e.path.as_ref()).collect();
|
||||
assert!(child_paths.contains(&Path::new("/project/src")));
|
||||
assert!(child_paths.contains(&Path::new("/project/tests")));
|
||||
assert!(child_paths.contains(&Path::new("/project/Cargo.toml")));
|
||||
|
||||
// Should not include nested entries
|
||||
assert!(!child_paths.contains(&Path::new("/project/src/main.rs")));
|
||||
|
||||
let src_children: Vec<_> = snapshot.child_entries(Path::new("/project/src")).collect();
|
||||
assert_eq!(src_children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_child_entries_empty_directory() {
|
||||
let snapshot = test_snapshot(&["/project/", "/project/empty/"]);
|
||||
|
||||
let children: Vec<_> = snapshot
|
||||
.child_entries(Path::new("/project/empty"))
|
||||
.collect();
|
||||
assert!(children.is_empty());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Lazy Loading Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_is_parent_loaded_root() {
|
||||
let snapshot = FileTreeSnapshot::with_root(Path::new("/project"), false, true);
|
||||
assert!(snapshot.is_parent_loaded(Path::new("/project/src")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_parent_loaded_unloaded_directory() {
|
||||
let mut snapshot = FileTreeSnapshot::new(Path::new("/project"));
|
||||
snapshot.insert_entry(FileEntry::directory(Path::new("/project"), false, true));
|
||||
snapshot.insert_entry(FileEntry::directory(
|
||||
Path::new("/project/collapsed"),
|
||||
false,
|
||||
false,
|
||||
));
|
||||
|
||||
// Parent /project is loaded
|
||||
assert!(snapshot.is_parent_loaded(Path::new("/project/collapsed")));
|
||||
// Parent /project/collapsed is NOT loaded
|
||||
assert!(!snapshot.is_parent_loaded(Path::new("/project/collapsed/child.txt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_parent_loaded_nested() {
|
||||
let snapshot = test_snapshot(&["/project/", "/project/src/", "/project/src/nested/"]);
|
||||
|
||||
assert!(snapshot.is_parent_loaded(Path::new("/project/src")));
|
||||
assert!(snapshot.is_parent_loaded(Path::new("/project/src/nested")));
|
||||
assert!(snapshot.is_parent_loaded(Path::new("/project/src/nested/file.rs")));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_single_entry() {
|
||||
let mut snapshot = FileTreeSnapshot::new(Path::new("/"));
|
||||
snapshot.insert_entry(FileEntry::file(Path::new("/only_file.txt"), false));
|
||||
|
||||
assert_eq!(snapshot.len(), 1);
|
||||
assert!(snapshot
|
||||
.entry_for_path(Path::new("/only_file.txt"))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_existing_entry() {
|
||||
let mut snapshot = test_snapshot(&["/project/", "/project/file.txt"]);
|
||||
|
||||
// Update the file to be ignored
|
||||
let updated = FileEntry::file(Path::new("/project/file.txt"), true);
|
||||
snapshot.insert_entry(updated);
|
||||
|
||||
assert_eq!(snapshot.len(), 2); // Should not duplicate
|
||||
let entry = snapshot
|
||||
.entry_for_path(Path::new("/project/file.txt"))
|
||||
.unwrap();
|
||||
assert!(entry.ignored);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Filesystem Event Handling Tests
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_handle_added_in_loaded_directory() {
|
||||
let mut snapshot = test_snapshot(&["/project/", "/project/src/"]);
|
||||
|
||||
// Add a file to a loaded directory
|
||||
let result = snapshot.handle_added(Path::new("/project/src/new_file.rs"), false, false);
|
||||
assert!(result);
|
||||
assert!(snapshot
|
||||
.entry_for_path(Path::new("/project/src/new_file.rs"))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_added_in_unloaded_directory() {
|
||||
let mut snapshot = FileTreeSnapshot::new(Path::new("/project"));
|
||||
snapshot.insert_entry(FileEntry::directory(Path::new("/project"), false, true));
|
||||
snapshot.insert_entry(FileEntry::directory(
|
||||
Path::new("/project/collapsed"),
|
||||
false,
|
||||
false,
|
||||
));
|
||||
|
||||
// Try to add a file to an unloaded directory - should fail
|
||||
let result = snapshot.handle_added(Path::new("/project/collapsed/file.rs"), false, false);
|
||||
assert!(!result);
|
||||
assert!(snapshot
|
||||
.entry_for_path(Path::new("/project/collapsed/file.rs"))
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_removed() {
|
||||
let mut snapshot = test_snapshot(&["/project/", "/project/file.txt"]);
|
||||
|
||||
let result = snapshot.handle_removed(Path::new("/project/file.txt"));
|
||||
assert!(result);
|
||||
assert!(snapshot
|
||||
.entry_for_path(Path::new("/project/file.txt"))
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_removed_nonexistent() {
|
||||
let mut snapshot = test_snapshot(&["/project/"]);
|
||||
|
||||
let result = snapshot.handle_removed(Path::new("/project/nonexistent.txt"));
|
||||
assert!(!result);
|
||||
}
|
||||
|
||||
/// Test helper: Handles a file/directory being renamed/moved.
|
||||
/// Returns true if the rename was processed.
|
||||
fn handle_renamed(
|
||||
snapshot: &mut FileTreeSnapshot,
|
||||
old_path: &Path,
|
||||
new_path: &Path,
|
||||
is_dir: bool,
|
||||
ignored: bool,
|
||||
) -> bool {
|
||||
let old_loaded = snapshot.is_parent_loaded(old_path);
|
||||
let new_loaded = snapshot.is_parent_loaded(new_path);
|
||||
|
||||
// Remove from old location if parent was loaded
|
||||
if old_loaded {
|
||||
snapshot.remove_entry(old_path);
|
||||
}
|
||||
|
||||
// Add to new location if parent is loaded
|
||||
if new_loaded {
|
||||
let entry = if is_dir {
|
||||
FileEntry::directory(Arc::from(new_path), ignored, false)
|
||||
} else {
|
||||
FileEntry::file(Arc::from(new_path), ignored)
|
||||
};
|
||||
snapshot.insert_entry(entry);
|
||||
}
|
||||
|
||||
old_loaded || new_loaded
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_renamed() {
|
||||
let mut snapshot = test_snapshot(&["/project/", "/project/old_name.txt"]);
|
||||
|
||||
let result = handle_renamed(
|
||||
&mut snapshot,
|
||||
Path::new("/project/old_name.txt"),
|
||||
Path::new("/project/new_name.txt"),
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert!(result);
|
||||
assert!(snapshot
|
||||
.entry_for_path(Path::new("/project/old_name.txt"))
|
||||
.is_none());
|
||||
assert!(snapshot
|
||||
.entry_for_path(Path::new("/project/new_name.txt"))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_directory() {
|
||||
let mut snapshot = FileTreeSnapshot::new(Path::new("/project"));
|
||||
snapshot.insert_entry(FileEntry::directory(Path::new("/project"), false, true));
|
||||
snapshot.insert_entry(FileEntry::directory(
|
||||
Path::new("/project/collapsed"),
|
||||
false,
|
||||
false,
|
||||
));
|
||||
|
||||
let entry = snapshot
|
||||
.entry_for_path(Path::new("/project/collapsed"))
|
||||
.unwrap();
|
||||
assert!(!entry.loaded);
|
||||
|
||||
snapshot.expand_directory(Path::new("/project/collapsed"));
|
||||
|
||||
let entry = snapshot
|
||||
.entry_for_path(Path::new("/project/collapsed"))
|
||||
.unwrap();
|
||||
assert!(entry.loaded);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
//! Module for utlities related to editing items in the file tree.
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "editing_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
use repo_metadata::file_tree_store::FileTreeEntryState;
|
||||
use repo_metadata::{FileMetadata, FileTreeEntry};
|
||||
use std::cmp::Ordering;
|
||||
use std::sync::Arc;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::{elements::MouseStateHandle, ViewContext};
|
||||
|
||||
use super::{FileTreeIdentifier, FileTreeItem, FileTreeView};
|
||||
use crate::{
|
||||
code::file_tree::{
|
||||
view::{PendingEdit, PendingEditKind},
|
||||
FileTreeEvent,
|
||||
},
|
||||
send_telemetry_from_ctx,
|
||||
server::telemetry::TelemetryEvent,
|
||||
};
|
||||
|
||||
/// Custom ordering function for items in the file tree.
|
||||
///
|
||||
/// Directories are ordered first, sorted alphabetically.
|
||||
/// Files are ordered second, sorted alphabetically.
|
||||
/// Within each group, dotfiles (entries starting with a dot) are ordered first.
|
||||
pub(super) fn sort_entries_for_file_tree(
|
||||
entry_1: &StandardizedPath,
|
||||
entry_2: &StandardizedPath,
|
||||
entry_map: &FileTreeEntry,
|
||||
) -> Ordering {
|
||||
use std::cmp::Ordering;
|
||||
|
||||
// Entries missing from the map sort before present entries, and compare
|
||||
// equal to each other. Using the same `Ordering` on both sides would
|
||||
// violate antisymmetry and cause `sorted_by` to panic with
|
||||
// "user-provided comparison function does not correctly implement a total order".
|
||||
let (entry_1, entry_2) = match (entry_map.get(entry_1), entry_map.get(entry_2)) {
|
||||
(None, None) => return Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Less,
|
||||
(Some(_), None) => return Ordering::Greater,
|
||||
(Some(e1), Some(e2)) => (e1, e2),
|
||||
};
|
||||
|
||||
let is_dir_1 = matches!(entry_1, FileTreeEntryState::Directory(_));
|
||||
let is_dir_2 = matches!(entry_2, FileTreeEntryState::Directory(_));
|
||||
|
||||
// Order directories before any files.
|
||||
match (is_dir_1, is_dir_2) {
|
||||
(true, false) => return Ordering::Less,
|
||||
(false, true) => return Ordering::Greater,
|
||||
// Both are same type, continue with alphabetical sort.
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Same antisymmetry requirement for missing file names.
|
||||
let (name_1, name_2) = match (entry_1.path().file_name(), entry_2.path().file_name()) {
|
||||
(None, None) => return Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Less,
|
||||
(Some(_), None) => return Ordering::Greater,
|
||||
(Some(n1), Some(n2)) => (n1, n2),
|
||||
};
|
||||
|
||||
let starts_with_dot_1 = name_1.starts_with('.');
|
||||
let starts_with_dot_2 = name_2.starts_with('.');
|
||||
|
||||
// Items starting with "." come first.
|
||||
match (starts_with_dot_1, starts_with_dot_2) {
|
||||
(true, false) => Ordering::Less,
|
||||
(false, true) => Ordering::Greater,
|
||||
_ => name_1.cmp(name_2),
|
||||
}
|
||||
}
|
||||
|
||||
impl FileTreeView {
|
||||
/// Creates a new file below the directory at the given identifier.
|
||||
pub(super) fn create_new_file(&mut self, id: &FileTreeIdentifier, ctx: &mut ViewContext<Self>) {
|
||||
let Some(root_dir) = self.root_directories.get_mut(&id.root) else {
|
||||
return;
|
||||
};
|
||||
let (path, depth) = match root_dir.items.get(id.index) {
|
||||
Some(FileTreeItem::File { .. }) => {
|
||||
log::warn!("Cannot create a new file below a file");
|
||||
return;
|
||||
}
|
||||
Some(FileTreeItem::DirectoryHeader {
|
||||
directory, depth, ..
|
||||
}) => (directory.path.clone(), *depth),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Ensure the parent directory is expanded before creating a file beneath it.
|
||||
if !self.is_folder_expanded(&id.root, &path) {
|
||||
self.toggle_folder_expansion(&id.root, &path, ctx);
|
||||
}
|
||||
|
||||
// Create a dummy FileTreeItem for the file we are about to create--we'll replace
|
||||
// this with something real once the user types in the actual file.
|
||||
let new_item_index = id.index + 1;
|
||||
let Some(root_dir) = self.root_directories.get_mut(&id.root) else {
|
||||
return;
|
||||
};
|
||||
root_dir.items.insert(
|
||||
new_item_index,
|
||||
FileTreeItem::File {
|
||||
metadata: FileMetadata::from_standardized(path.join("new_file"), false).into(),
|
||||
depth: depth + 1,
|
||||
mouse_state_handle: MouseStateHandle::default(),
|
||||
draggable_state: warpui::elements::DraggableState::default(),
|
||||
},
|
||||
);
|
||||
|
||||
// Ensure the new item we just created is selected.
|
||||
let new_id = FileTreeIdentifier {
|
||||
root: id.root.clone(),
|
||||
index: new_item_index,
|
||||
};
|
||||
self.select_id(&new_id, ctx);
|
||||
|
||||
// Ensure the editor is focused.
|
||||
ctx.focus(&self.editor_view);
|
||||
self.pending_edit = Some(PendingEdit {
|
||||
id: new_id,
|
||||
kind: PendingEditKind::CreateNewFile,
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts a rename edit on the item at the given identifier.
|
||||
pub(super) fn start_rename(&mut self, id: &FileTreeIdentifier, ctx: &mut ViewContext<Self>) {
|
||||
let Some(root_dir) = self.root_directories.get(&id.root) else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = root_dir.items.get(id.index) else {
|
||||
return;
|
||||
};
|
||||
// Prefill the editor with the current file or directory name.
|
||||
let current_name = item
|
||||
.path()
|
||||
.file_name()
|
||||
.map(|s| s.to_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.pending_edit = Some(PendingEdit {
|
||||
id: id.clone(),
|
||||
kind: PendingEditKind::RenameExisting,
|
||||
});
|
||||
|
||||
self.editor_view.update(ctx, |view, ctx| {
|
||||
view.set_buffer_text(¤t_name, ctx);
|
||||
});
|
||||
ctx.focus(&self.editor_view);
|
||||
}
|
||||
|
||||
/// Commits a pending edit to the file tree.
|
||||
pub(super) fn commit_pending_edit(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(pending_edit) = self.pending_edit.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let file_tree_id = pending_edit.id.clone();
|
||||
|
||||
let buffer_content = self.editor_view.as_ref(ctx).buffer_text(ctx);
|
||||
self.editor_view.update(ctx, |view, ctx| {
|
||||
view.clear_buffer(ctx);
|
||||
});
|
||||
|
||||
match pending_edit.kind {
|
||||
PendingEditKind::CreateNewFile => {
|
||||
let new_entry = {
|
||||
let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = root_dir.items.get_mut(file_tree_id.index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let FileTreeItem::File { metadata, .. } = item {
|
||||
let mut new_std = (*metadata.path).clone();
|
||||
new_std.set_file_name(&buffer_content);
|
||||
let local_path = new_std.to_local_path_lossy();
|
||||
metadata.path = Arc::new(new_std);
|
||||
|
||||
if let Err(e) = std::fs::File::create_new(&local_path) {
|
||||
log::warn!("Failed to create file: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(TelemetryEvent::FileTreeItemCreated, ctx);
|
||||
|
||||
FileTreeEntryState::File(metadata.clone())
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) {
|
||||
// Ensure the file tree has the new item we've just created.
|
||||
Self::insert_entry(&mut root_dir.entry, new_entry);
|
||||
}
|
||||
|
||||
self.open_in_new_pane(&file_tree_id, ctx);
|
||||
self.rebuild_flattened_items();
|
||||
}
|
||||
PendingEditKind::RenameExisting => {
|
||||
let Some(root_dir) = self.root_directories.get(&file_tree_id.root) else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = root_dir.items.get(file_tree_id.index) else {
|
||||
return;
|
||||
};
|
||||
if buffer_content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let old_std_path = item.path().clone();
|
||||
let mut new_std_path = old_std_path.clone();
|
||||
new_std_path.set_file_name(&buffer_content);
|
||||
|
||||
let old_path = old_std_path.to_local_path_lossy();
|
||||
let new_path = new_std_path.to_local_path_lossy();
|
||||
if let Err(e) = std::fs::rename(&old_path, &new_path) {
|
||||
log::warn!(
|
||||
"Failed to rename {} -> {}: {e}",
|
||||
old_path.display(),
|
||||
new_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the in-memory model immediately so the UI reflects the change without delay.
|
||||
if let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) {
|
||||
root_dir.entry.rename_path(&old_std_path, &new_std_path);
|
||||
}
|
||||
|
||||
// Emit event to notify workspace that a file was renamed
|
||||
ctx.emit(FileTreeEvent::FileRenamed {
|
||||
old_path: old_path.clone(),
|
||||
new_path: new_path.clone(),
|
||||
});
|
||||
|
||||
// Rebuild and select the renamed item using its FileTreeIdentifier
|
||||
self.rebuild_flatten_items_and_select_path(Some(&file_tree_id), None);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a pending edit and discards any changes.
|
||||
pub(super) fn cancel_pending_edit(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(pending_edit) = self.pending_edit.take() {
|
||||
let id = &pending_edit.id;
|
||||
if self.selected_item.as_ref() == Some(id) {
|
||||
self.selected_item = None;
|
||||
}
|
||||
self.editor_view.update(ctx, |view, ctx| {
|
||||
view.clear_buffer(ctx);
|
||||
});
|
||||
// Only remove placeholder in the create-new-file flow.
|
||||
if pending_edit.kind == PendingEditKind::CreateNewFile {
|
||||
if let Some(root_dir) = self.root_directories.get_mut(&id.root) {
|
||||
root_dir.items.remove(id.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Inserts a new entry into the tree.
|
||||
fn insert_entry(root_entry: &mut FileTreeEntry, child_entry: FileTreeEntryState) {
|
||||
let Some(parent) = child_entry.path().parent() else {
|
||||
return;
|
||||
};
|
||||
|
||||
root_entry.insert_child_state(&parent, child_entry);
|
||||
}
|
||||
|
||||
pub(super) fn handle_pending_edit(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.pending_edit.is_none() {
|
||||
return;
|
||||
};
|
||||
|
||||
let editor_contents = self.editor_view.as_ref(ctx).buffer_text(ctx);
|
||||
// If the editor is empty and the editor was dismissed, cancel the editor.
|
||||
// Otherwise commit the editor. This matches VSCode's behavior.
|
||||
if editor_contents.is_empty() {
|
||||
self.cancel_pending_edit(ctx);
|
||||
} else {
|
||||
self.commit_pending_edit(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use repo_metadata::file_tree_store::{FileTreeDirectoryEntryState, FileTreeEntryState};
|
||||
use repo_metadata::{FileMetadata, FileTreeEntry};
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
|
||||
use super::sort_entries_for_file_tree;
|
||||
|
||||
fn std_path(s: &str) -> StandardizedPath {
|
||||
StandardizedPath::try_new(s).expect("test path should be valid")
|
||||
}
|
||||
|
||||
fn dir_state(path: &str) -> FileTreeEntryState {
|
||||
FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
|
||||
path: Arc::new(std_path(path)),
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn file_state(path: &str) -> FileTreeEntryState {
|
||||
FileTreeEntryState::File(FileMetadata::from_standardized(std_path(path), false).into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_entries_for_file_tree_is_antisymmetric_for_missing_entries() {
|
||||
let root = std_path("/repo");
|
||||
let mut entry = FileTreeEntry::new_for_directory(Arc::new(root.clone()));
|
||||
entry.insert_child_state(&root, dir_state("/repo/src"));
|
||||
entry.insert_child_state(&root, file_state("/repo/README.md"));
|
||||
|
||||
let paths = [
|
||||
std_path("/repo/src"), // present (directory)
|
||||
std_path("/repo/README.md"), // present (file)
|
||||
std_path("/repo/ghost_a"), // missing
|
||||
std_path("/repo/ghost_b"), // missing
|
||||
];
|
||||
|
||||
for a in &paths {
|
||||
for b in &paths {
|
||||
let ab = sort_entries_for_file_tree(a, b, &entry);
|
||||
let ba = sort_entries_for_file_tree(b, a, &entry);
|
||||
assert_eq!(
|
||||
ab.reverse(),
|
||||
ba,
|
||||
"comparator not antisymmetric for ({}, {}): cmp(a,b) = {:?}, cmp(b,a) = {:?}",
|
||||
a.as_str(),
|
||||
b.as_str(),
|
||||
ab,
|
||||
ba,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_entries_for_file_tree_sorts_without_panicking_on_missing_children() {
|
||||
let root = std_path("/repo");
|
||||
let mut entry = FileTreeEntry::new_for_directory(Arc::new(root.clone()));
|
||||
entry.insert_child_state(&root, dir_state("/repo/src"));
|
||||
|
||||
// Multiple missing entries are required to reliably trigger the sort's
|
||||
// total-order violation check.
|
||||
let mut paths = [
|
||||
std_path("/repo/src"),
|
||||
std_path("/repo/ghost_a"),
|
||||
std_path("/repo/ghost_b"),
|
||||
std_path("/repo/ghost_c"),
|
||||
std_path("/repo/ghost_d"),
|
||||
std_path("/repo/ghost_e"),
|
||||
];
|
||||
|
||||
paths.sort_by(|a, b| sort_entries_for_file_tree(a, b, &entry));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use warpui::elements::{DraggableState, MouseStateHandle};
|
||||
|
||||
use super::FileTreeItem;
|
||||
use crate::code::icon_from_file_path;
|
||||
use crate::ui_components::item_highlight::ImageOrIcon;
|
||||
use crate::{appearance::Appearance, ui_components::icons::Icon};
|
||||
|
||||
impl FileTreeItem {
|
||||
pub(super) fn to_render_state(
|
||||
&self,
|
||||
is_expanded: Option<bool>,
|
||||
appearance: &Appearance,
|
||||
) -> RenderState {
|
||||
match self {
|
||||
FileTreeItem::File {
|
||||
metadata,
|
||||
mouse_state_handle,
|
||||
depth,
|
||||
draggable_state,
|
||||
} => {
|
||||
let display_name = metadata
|
||||
.path
|
||||
.file_name()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| String::from("File"));
|
||||
|
||||
let icon_from_file_path =
|
||||
icon_from_file_path(metadata.path.as_str(), appearance).map(ImageOrIcon::Image);
|
||||
|
||||
RenderState {
|
||||
display_name,
|
||||
icon: icon_from_file_path.unwrap_or(ImageOrIcon::Icon(Icon::File)),
|
||||
is_expanded,
|
||||
depth: *depth,
|
||||
mouse_state: mouse_state_handle.clone(),
|
||||
draggable_state: draggable_state.clone(),
|
||||
is_ignored: metadata.ignored,
|
||||
}
|
||||
}
|
||||
FileTreeItem::DirectoryHeader {
|
||||
directory,
|
||||
mouse_state_handle,
|
||||
depth,
|
||||
draggable_state,
|
||||
} => {
|
||||
let display_name = directory
|
||||
.path
|
||||
.file_name()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| String::from("Folder"));
|
||||
RenderState {
|
||||
display_name,
|
||||
icon: ImageOrIcon::Icon(Icon::Folder),
|
||||
is_expanded,
|
||||
depth: *depth,
|
||||
mouse_state: mouse_state_handle.clone(),
|
||||
draggable_state: draggable_state.clone(),
|
||||
is_ignored: directory.ignored,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RenderState {
|
||||
pub display_name: String,
|
||||
pub icon: ImageOrIcon,
|
||||
pub is_expanded: Option<bool>,
|
||||
pub depth: usize,
|
||||
pub mouse_state: MouseStateHandle,
|
||||
pub draggable_state: DraggableState,
|
||||
pub is_ignored: bool,
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use repo_metadata::entry::{DirectoryEntry, Entry, FileMetadata};
|
||||
use repo_metadata::file_tree_store::FileTreeState;
|
||||
use repo_metadata::local_model::IndexedRepoState;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use virtual_fs::{Stub, VirtualFS};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{platform::WindowStyle, App, ModelHandle};
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient};
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::vim_registers::VimRegisters;
|
||||
use crate::workspace::sync_inputs::SyncedInputState;
|
||||
use crate::workspace::ToastStack;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
use super::FileTreeView;
|
||||
|
||||
fn std_path(path: &std::path::Path) -> warp_util::standardized_path::StandardizedPath {
|
||||
warp_util::standardized_path::StandardizedPath::try_from_local(path).unwrap()
|
||||
}
|
||||
|
||||
fn initialize_app(
|
||||
app: &mut App,
|
||||
) -> (
|
||||
ModelHandle<DetectedRepositories>,
|
||||
ModelHandle<RepoMetadataModel>,
|
||||
) {
|
||||
initialize_settings_for_tests(app);
|
||||
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| ToastStack);
|
||||
app.add_singleton_model(|_| SyncedInputState::mock());
|
||||
app.add_singleton_model(|_| VimRegisters::new());
|
||||
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
|
||||
let team_client = Arc::new(MockTeamClient::new());
|
||||
let workspace_client = Arc::new(MockWorkspaceClient::new());
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(team_client.clone(), workspace_client.clone(), vec![], ctx)
|
||||
});
|
||||
|
||||
let detected_repositories = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
let repository_metadata_model = app.add_singleton_model(RepoMetadataModel::new);
|
||||
|
||||
(detected_repositories, repository_metadata_model)
|
||||
}
|
||||
|
||||
fn build_repo_state(repo_root: &std::path::Path) -> FileTreeState {
|
||||
let source_file = Entry::File(FileMetadata::new(
|
||||
repo_root.join("packages/app/src/main.rs"),
|
||||
false,
|
||||
));
|
||||
let src_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo_root.join("packages/app/src"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![source_file],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let app_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo_root.join("packages/app"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![src_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let packages_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo_root.join("packages"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![app_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
let root = Entry::Directory(DirectoryEntry {
|
||||
path: std_path(repo_root),
|
||||
children: vec![packages_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
FileTreeState::new(root, vec![], None)
|
||||
}
|
||||
|
||||
fn build_repo_state_with_unloaded_directory(repo_root: &std::path::Path) -> FileTreeState {
|
||||
let unloaded_src_dir = Entry::Directory(DirectoryEntry {
|
||||
path: warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&repo_root.join("src"),
|
||||
)
|
||||
.unwrap(),
|
||||
children: vec![],
|
||||
ignored: false,
|
||||
loaded: false,
|
||||
});
|
||||
let root = Entry::Directory(DirectoryEntry {
|
||||
path: std_path(repo_root),
|
||||
children: vec![unloaded_src_dir],
|
||||
ignored: false,
|
||||
loaded: true,
|
||||
});
|
||||
FileTreeState::new(root, vec![], None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_transition_unregisters_lazy_loaded_path() {
|
||||
VirtualFS::test("file_tree_repo_transition", |dirs, mut vfs| {
|
||||
vfs.mkdir("repo/.git/objects")
|
||||
.mkdir("repo/packages/app/src")
|
||||
.with_files(vec![
|
||||
Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"),
|
||||
Stub::FileWithContent("repo/.git/config", "[core]\n\trepositoryformatversion = 0"),
|
||||
Stub::FileWithContent("repo/packages/app/src/main.rs", "fn main() {}\n"),
|
||||
]);
|
||||
|
||||
let repo_root = dirs.tests().join("repo");
|
||||
let displayed_root = repo_root.join("packages/app");
|
||||
let canonical_repo_root =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root)
|
||||
.unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (detected_repositories, repository_metadata_model) = initialize_app(&mut app);
|
||||
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
detected_repositories.update(&mut app, |repositories, _ctx| {
|
||||
repositories.insert_test_repo_root(canonical_repo_root.clone());
|
||||
});
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![displayed_root.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view.registered_lazy_loaded_paths.contains(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&displayed_root
|
||||
)
|
||||
.unwrap()
|
||||
));
|
||||
let displayed_std =
|
||||
warp_util::standardized_path::StandardizedPath::try_from_local(&displayed_root)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
view.root_directories
|
||||
.get(&displayed_std)
|
||||
.map(|root_dir| root_dir.entry.root_directory().to_local_path_lossy()),
|
||||
Some(displayed_root.clone())
|
||||
);
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(model.is_lazy_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&displayed_root
|
||||
)
|
||||
.unwrap(),
|
||||
ctx
|
||||
));
|
||||
});
|
||||
|
||||
repository_metadata_model.update(&mut app, |model, ctx| {
|
||||
model.insert_test_state(canonical_repo_root, build_repo_state(&repo_root), ctx);
|
||||
});
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![displayed_root.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let displayed_std =
|
||||
warp_util::standardized_path::StandardizedPath::try_from_local(&displayed_root)
|
||||
.unwrap();
|
||||
let repo_std =
|
||||
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap();
|
||||
assert!(!view.registered_lazy_loaded_paths.contains(&displayed_std));
|
||||
assert_eq!(view.root_for_path(&displayed_std), Some(repo_std.clone()));
|
||||
assert_eq!(
|
||||
view.root_directories
|
||||
.get(&displayed_std)
|
||||
.map(|root_dir| (**root_dir.entry.root_directory()).clone()),
|
||||
Some(repo_std)
|
||||
);
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_lazy_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&displayed_root
|
||||
)
|
||||
.unwrap(),
|
||||
ctx
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_backed_unloaded_directory_loads_through_model() {
|
||||
VirtualFS::test("file_tree_repo_backed_load", |dirs, mut vfs| {
|
||||
vfs.mkdir("repo/.git/objects")
|
||||
.mkdir("repo/src/nested")
|
||||
.with_files(vec![
|
||||
Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"),
|
||||
Stub::FileWithContent(
|
||||
"repo/.git/config",
|
||||
"[core]
|
||||
\trepositoryformatversion = 0",
|
||||
),
|
||||
Stub::FileWithContent(
|
||||
"repo/src/nested/main.rs",
|
||||
"fn main() {}
|
||||
",
|
||||
),
|
||||
]);
|
||||
|
||||
let repo_root = dirs.tests().join("repo");
|
||||
let src_dir = repo_root.join("src");
|
||||
let nested_dir = repo_root.join("src/nested");
|
||||
let source_file = repo_root.join("src/nested/main.rs");
|
||||
let canonical_repo_root =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root)
|
||||
.unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (detected_repositories, repository_metadata_model) = initialize_app(&mut app);
|
||||
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
detected_repositories.update(&mut app, |repositories, _ctx| {
|
||||
repositories.insert_test_repo_root(canonical_repo_root.clone());
|
||||
});
|
||||
repository_metadata_model.update(&mut app, |model, ctx| {
|
||||
model.insert_test_state(
|
||||
canonical_repo_root,
|
||||
build_repo_state_with_unloaded_directory(&repo_root),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![repo_root.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(!view
|
||||
.root_directories
|
||||
.get(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap()
|
||||
)
|
||||
.is_some_and(|root_dir| root_dir.entry.contains(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&source_file
|
||||
)
|
||||
.unwrap()
|
||||
)));
|
||||
});
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.ensure_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap(),
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&src_dir)
|
||||
.unwrap(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view
|
||||
.root_directories
|
||||
.get(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap()
|
||||
)
|
||||
.is_some_and(|root_dir| root_dir.entry.contains(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&nested_dir
|
||||
)
|
||||
.unwrap()
|
||||
)));
|
||||
});
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.ensure_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap(),
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&nested_dir)
|
||||
.unwrap(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view
|
||||
.root_directories
|
||||
.get(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap()
|
||||
)
|
||||
.is_some_and(|root_dir| root_dir.entry.contains(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&source_file
|
||||
)
|
||||
.unwrap()
|
||||
)));
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_lazy_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap(),
|
||||
ctx
|
||||
));
|
||||
let id = repo_metadata::RepositoryIdentifier::local(
|
||||
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(model.get_repository(&id, ctx).is_some_and(|state| {
|
||||
state.entry.contains(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&source_file,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_repository_root_does_not_register_lazy_loaded_path() {
|
||||
VirtualFS::test("file_tree_pending_repo_root", |dirs, mut vfs| {
|
||||
vfs.mkdir("repo/.git/objects").with_files(vec![
|
||||
Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"),
|
||||
Stub::FileWithContent("repo/.git/config", "[core]\n\trepositoryformatversion = 0"),
|
||||
]);
|
||||
|
||||
let repo_root = dirs.tests().join("repo");
|
||||
let canonical_repo_root =
|
||||
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root)
|
||||
.unwrap();
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (detected_repositories, repository_metadata_model) = initialize_app(&mut app);
|
||||
let directory_watcher = app.add_singleton_model(DirectoryWatcher::new);
|
||||
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
let repository_handle = directory_watcher.update(&mut app, |watcher, ctx| {
|
||||
watcher
|
||||
.add_directory(canonical_repo_root.clone(), ctx)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
detected_repositories.update(&mut app, |repositories, _ctx| {
|
||||
repositories.insert_test_repo_root(canonical_repo_root.clone());
|
||||
});
|
||||
repository_metadata_model.update(&mut app, |model, ctx| {
|
||||
model.index_directory(repository_handle, ctx).unwrap();
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
let id = repo_metadata::RepositoryIdentifier::local(
|
||||
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(matches!(
|
||||
model.repository_state(&id, ctx),
|
||||
Some(IndexedRepoState::Pending)
|
||||
));
|
||||
});
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![repo_root.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(!view.registered_lazy_loaded_paths.contains(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap()
|
||||
));
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_lazy_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap(),
|
||||
ctx
|
||||
));
|
||||
let id = repo_metadata::RepositoryIdentifier::local(
|
||||
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(matches!(
|
||||
model.repository_state(&id, ctx),
|
||||
Some(IndexedRepoState::Pending)
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_lazy_loaded_path_registration_is_retried() {
|
||||
VirtualFS::test("file_tree_lazy_loaded_path_retry", |dirs, mut vfs| {
|
||||
let displayed_root = dirs.tests().join("late_dir");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (_detected_repositories, repository_metadata_model) = initialize_app(&mut app);
|
||||
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![displayed_root.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(!view.registered_lazy_loaded_paths.contains(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&displayed_root
|
||||
)
|
||||
.unwrap()
|
||||
));
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_lazy_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&displayed_root
|
||||
)
|
||||
.unwrap(),
|
||||
ctx
|
||||
));
|
||||
});
|
||||
|
||||
vfs.mkdir("late_dir")
|
||||
.with_files(vec![Stub::FileWithContent("late_dir/file.txt", "content")]);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![displayed_root.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view.registered_lazy_loaded_paths.contains(&std_path(&displayed_root)));
|
||||
assert!(matches!(
|
||||
view.root_directories.get(&std_path(&displayed_root)).map(|root_dir| &root_dir.entry),
|
||||
Some(entry)
|
||||
if entry.contains(&std_path(&displayed_root.join("file.txt")))
|
||||
));
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(model.is_lazy_loaded_path(
|
||||
&warp_util::standardized_path::StandardizedPath::try_from_local(
|
||||
&displayed_root
|
||||
)
|
||||
.unwrap(),
|
||||
ctx
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Ancestor grouping (APP-4106) ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sibling_roots_are_preserved() {
|
||||
VirtualFS::test("file_tree_sibling_roots", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/a").mkdir("tree/b").with_files(vec![
|
||||
Stub::FileWithContent("tree/a/x.txt", "x"),
|
||||
Stub::FileWithContent("tree/b/y.txt", "y"),
|
||||
]);
|
||||
let a = dirs.tests().join("tree/a");
|
||||
let b = dirs.tests().join("tree/b");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![a.clone(), b.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert_eq!(view.displayed_directories, vec![std_path(&a), std_path(&b)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_expand_overrides_selection_when_most_recent_root_changes() {
|
||||
VirtualFS::test(
|
||||
"file_tree_auto_expand_overrides_on_new_root",
|
||||
|dirs, mut vfs| {
|
||||
vfs.mkdir("code/foo").mkdir("other").with_files(vec![
|
||||
Stub::FileWithContent("code/foo/file.txt", "x"),
|
||||
Stub::FileWithContent("other/file.txt", "y"),
|
||||
]);
|
||||
let code = dirs.tests().join("code");
|
||||
let other = dirs.tests().join("other");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) =
|
||||
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
// Start with `code` as the only root and select its header.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![code.clone()], ctx);
|
||||
view.auto_expand_to_most_recent_directory(ctx);
|
||||
});
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let selected = view.selected_item.as_ref().unwrap();
|
||||
assert_eq!(selected.root, std_path(&code));
|
||||
});
|
||||
|
||||
// Now cd to a brand-new root. `other` becomes most-recent.
|
||||
// Selection must move to `other`, not stay on `code`.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![other.clone(), code.clone()], ctx);
|
||||
view.auto_expand_to_most_recent_directory(ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let selected = view.selected_item.as_ref().expect("selection set");
|
||||
assert_eq!(selected.root, std_path(&other));
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_expand_preserves_existing_selection() {
|
||||
VirtualFS::test(
|
||||
"file_tree_auto_expand_preserves_selection",
|
||||
|dirs, mut vfs| {
|
||||
vfs.mkdir("tree/sub")
|
||||
.with_files(vec![Stub::FileWithContent("tree/sub/file.txt", "content")]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let sub = tree.join("sub");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) =
|
||||
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![tree.clone()], ctx);
|
||||
});
|
||||
|
||||
// Simulate a prior explicit selection (e.g. user focused a
|
||||
// file in the code editor and `scroll_to_file` selected it).
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.toggle_folder_expansion(&std_path(&tree), &std_path(&sub), ctx);
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
let (index, _) = root_dir
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, item)| item.path() == &std_path(&sub))
|
||||
.expect("sub directory is flattened");
|
||||
let id = super::FileTreeIdentifier {
|
||||
root: std_path(&tree),
|
||||
index,
|
||||
};
|
||||
view.select_id(&id, ctx);
|
||||
});
|
||||
|
||||
// Auto-expand must not override that selection with the root header.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.auto_expand_to_most_recent_directory(ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let selected = view.selected_item.clone().expect("selection set");
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
let selected_path = root_dir.items.get(selected.index).unwrap().path();
|
||||
assert_eq!(selected_path, &std_path(&sub));
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_on_file_under_absorbed_descendant_keeps_file_selected() {
|
||||
// Simulates: user clicks a file in the tree. The code view opens it,
|
||||
// which causes `DirectoriesChanged` to fire with the file's
|
||||
// parent/repo added. The resulting `set_root_directories` must NOT
|
||||
// override the user's file selection with the cwd-follow parent.
|
||||
VirtualFS::test(
|
||||
"file_tree_click_file_preserves_selection",
|
||||
|dirs, mut vfs| {
|
||||
vfs.mkdir("code/warp-server")
|
||||
.with_files(vec![Stub::FileWithContent(
|
||||
"code/warp-server/main.rs",
|
||||
"fn main() {}\n",
|
||||
)]);
|
||||
let code = dirs.tests().join("code");
|
||||
let warp_server = code.join("warp-server");
|
||||
let main_rs = warp_server.join("main.rs");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) =
|
||||
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
// Seed with `code` as the only root and expand warp-server so
|
||||
// main.rs is materialized in the flattened items.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![code.clone()], ctx);
|
||||
view.toggle_folder_expansion(&std_path(&code), &std_path(&warp_server), ctx);
|
||||
});
|
||||
|
||||
// Simulate a click on main.rs (select_id is what the click
|
||||
// action and the active-file scroll both go through).
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
let root_dir = view.root_directories.get(&std_path(&code)).unwrap();
|
||||
let (index, _) = root_dir
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, item)| item.path() == &std_path(&main_rs))
|
||||
.expect("main.rs materialized");
|
||||
let id = super::FileTreeIdentifier {
|
||||
root: std_path(&code),
|
||||
index,
|
||||
};
|
||||
view.select_id(&id, ctx);
|
||||
});
|
||||
|
||||
// Now `DirectoriesChanged` fires as a side effect of the file
|
||||
// opening in a code view — the working-directories-model adds
|
||||
// the file's repo/parent (warp-server) to the active set.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![warp_server.clone(), code.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
// Selection is still on main.rs, not on warp-server.
|
||||
let selected = view.selected_item.clone().expect("selection");
|
||||
let root_dir = view.root_directories.get(&std_path(&code)).unwrap();
|
||||
let path = root_dir.items.get(selected.index).unwrap().path();
|
||||
assert_eq!(path, &std_path(&main_rs));
|
||||
// And we didn't set a pending focus target that could
|
||||
// later steal focus back to the parent directory.
|
||||
assert!(view.pending_focus_target.is_none());
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_focus_target_does_not_re_scroll_after_first_apply() {
|
||||
// After the initial focus-follow scrolls to the cwd, subsequent
|
||||
// rebuilds (e.g. from repo-metadata updates) must keep the
|
||||
// selection but NOT re-scroll, so user scrolling is respected.
|
||||
VirtualFS::test("file_tree_pending_respects_user_scroll", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/warp-server")
|
||||
.with_files(vec![Stub::FileWithContent(
|
||||
"tree/warp-server/main.rs",
|
||||
"fn main() {}\n",
|
||||
)]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let warp_server = tree.join("warp-server");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![warp_server.clone(), tree.clone()], ctx);
|
||||
});
|
||||
|
||||
// Initial apply should have scrolled once.
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let pending = view.pending_focus_target.as_ref().expect("pending");
|
||||
assert!(pending.scrolled);
|
||||
});
|
||||
|
||||
// Simulate a later rebuild (e.g. metadata update). Selection
|
||||
// should still land on warp-server, but `scrolled` must stay
|
||||
// true (no re-scroll).
|
||||
file_tree_view.update(&mut app, |view, _ctx| {
|
||||
view.rebuild_flattened_items();
|
||||
view.apply_pending_focus_target();
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let selected = view.selected_item.clone().expect("selection");
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
let path = root_dir.items.get(selected.index).unwrap().path();
|
||||
assert_eq!(path, &std_path(&warp_server));
|
||||
let pending = view.pending_focus_target.as_ref().expect("pending");
|
||||
assert!(pending.scrolled, "scrolled flag stays set after re-apply");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_follows_absorbed_descendant_once_its_item_is_materialized() {
|
||||
VirtualFS::test("file_tree_focus_follow_deferred", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/warp-server")
|
||||
.with_files(vec![Stub::FileWithContent(
|
||||
"tree/warp-server/main.rs",
|
||||
"fn main() {}\n",
|
||||
)]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let warp_server = tree.join("warp-server");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
// User cd's into warp-server with ~/tree as the ancestor root.
|
||||
// The warp-server entry should be materialized by indexing and
|
||||
// selected as the focus-follow target.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![warp_server.clone(), tree.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
// Single displayed root, descendant absorbed.
|
||||
assert_eq!(view.displayed_directories, vec![std_path(&tree)]);
|
||||
// Selection landed on warp-server's directory header.
|
||||
let selected = view.selected_item.clone().expect("selection set");
|
||||
assert_eq!(selected.root, std_path(&tree));
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
let selected_item = root_dir
|
||||
.items
|
||||
.get(selected.index)
|
||||
.expect("selected index in range");
|
||||
assert_eq!(selected_item.path(), &std_path(&warp_server));
|
||||
// Pending target is preserved across rebuilds so later
|
||||
// repo-metadata updates don't override the cwd-follow
|
||||
// selection. It clears when the user interacts explicitly
|
||||
// (see pending_focus_target_cleared_on_user_select).
|
||||
let pending = view
|
||||
.pending_focus_target
|
||||
.as_ref()
|
||||
.expect("pending target preserved");
|
||||
assert_eq!(pending.root, std_path(&tree));
|
||||
assert_eq!(pending.path, std_path(&warp_server));
|
||||
// The initial apply scrolled; later applies must not
|
||||
// re-scroll so user scrolling is respected.
|
||||
assert!(pending.scrolled, "initial apply scrolls the tree");
|
||||
});
|
||||
|
||||
// User clicks somewhere else (simulated via select_id). Pending
|
||||
// target must clear so future rebuilds don't re-steal focus.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
let id = super::FileTreeIdentifier {
|
||||
root: std_path(&tree),
|
||||
index: 0,
|
||||
};
|
||||
// Sanity: the first item is the root header, not warp-server.
|
||||
assert_ne!(
|
||||
root_dir.items.first().unwrap().path(),
|
||||
&std_path(&warp_server)
|
||||
);
|
||||
view.select_id(&id, ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view.pending_focus_target.is_none());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_is_absorbed_into_ancestor() {
|
||||
VirtualFS::test("file_tree_absorb_descendant", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/a")
|
||||
.with_files(vec![Stub::FileWithContent("tree/a/x.txt", "x")]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let a = tree.join("a");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
// Input in most-recent-first order: descendant first.
|
||||
view.set_root_directories(vec![a.clone(), tree.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
// Only the ancestor survives as a displayed root.
|
||||
assert_eq!(view.displayed_directories, vec![std_path(&tree)]);
|
||||
assert!(view.root_directories.contains_key(&std_path(&tree)));
|
||||
assert!(!view.root_directories.contains_key(&std_path(&a)));
|
||||
// The absorbed descendant is expanded inside the surviving root.
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
assert!(root_dir.expanded_folders.contains(&std_path(&a)));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cd_into_descendant_absorbs_into_existing_ancestor_root() {
|
||||
VirtualFS::test("file_tree_cd_into_descendant", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/a/z")
|
||||
.with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let z = tree.join("a/z");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
// Start with only the ancestor displayed.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![tree.clone()], ctx);
|
||||
});
|
||||
|
||||
// Simulate cd-ing into ~/tree/a/z by emitting the descendant as the
|
||||
// most-recent path.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![z.clone(), tree.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
// Still a single root, no new top-level entry.
|
||||
assert_eq!(view.displayed_directories, vec![std_path(&tree)]);
|
||||
// Ancestor chain is auto-expanded down to the cwd.
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
assert!(root_dir
|
||||
.expanded_folders
|
||||
.contains(&std_path(&tree.join("a"))));
|
||||
assert!(root_dir.expanded_folders.contains(&std_path(&z)));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_collapse_blocks_auto_expand_on_absorption() {
|
||||
VirtualFS::test("file_tree_collapse_blocks_expand", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/a/z")
|
||||
.with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let a = tree.join("a");
|
||||
let z = a.join("z");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
// Start with the ancestor displayed and explicitly collapse `a`.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![tree.clone()], ctx);
|
||||
// First expand so the toggle records a collapse.
|
||||
view.toggle_folder_expansion(&std_path(&tree), &std_path(&a), ctx);
|
||||
view.toggle_folder_expansion(&std_path(&tree), &std_path(&a), ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&a)));
|
||||
});
|
||||
|
||||
// Now cd into ~/tree/a/z. Auto-expansion must not re-open `a`.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![z.clone(), tree.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
|
||||
assert!(!root_dir.expanded_folders.contains(&std_path(&a)));
|
||||
assert!(!root_dir.expanded_folders.contains(&std_path(&z)));
|
||||
assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&a)));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorption_migrates_expanded_and_explicitly_collapsed_state() {
|
||||
VirtualFS::test("file_tree_absorb_migrates_state", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/a/z")
|
||||
.with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let a = tree.join("a");
|
||||
let z = a.join("z");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let _ = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
// Start with `a` as a standalone top-level root and record
|
||||
// an explicit collapse on `a/z` under that standalone root.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![a.clone()], ctx);
|
||||
// Expand then collapse z so the toggle records a collapse on it.
|
||||
view.toggle_folder_expansion(&std_path(&a), &std_path(&z), ctx);
|
||||
view.toggle_folder_expansion(&std_path(&a), &std_path(&z), ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view.is_explicitly_collapsed(&std_path(&a), &std_path(&z)));
|
||||
});
|
||||
|
||||
// Now absorb `a` into `tree` by adding the ancestor.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![a.clone(), tree.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
// Standalone absorbed-root entry is gone.
|
||||
assert!(!view.root_directories.contains_key(&std_path(&a)));
|
||||
// Its explicit-collapse state moved over to the ancestor.
|
||||
assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&z)));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorbed_descendant_is_unregistered_from_lazy_loaded_paths() {
|
||||
VirtualFS::test("file_tree_absorb_unregisters_lazy", |dirs, mut vfs| {
|
||||
vfs.mkdir("tree/a")
|
||||
.with_files(vec![Stub::FileWithContent("tree/a/x.txt", "x")]);
|
||||
let tree = dirs.tests().join("tree");
|
||||
let a = tree.join("a");
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let (_, repository_metadata_model) = initialize_app(&mut app);
|
||||
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
|
||||
|
||||
// Initial state: `a` alone is a standalone lazy-loaded root.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_is_active(true, ctx);
|
||||
view.set_root_directories(vec![a.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(view.registered_lazy_loaded_paths.contains(&std_path(&a)));
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(model.is_lazy_loaded_path(&std_path(&a), ctx));
|
||||
});
|
||||
|
||||
// Add the ancestor. `a` should be absorbed and its lazy-loaded
|
||||
// registration should be cleaned up.
|
||||
file_tree_view.update(&mut app, |view, ctx| {
|
||||
view.set_root_directories(vec![a.clone(), tree.clone()], ctx);
|
||||
});
|
||||
|
||||
file_tree_view.read(&app, |view, _ctx| {
|
||||
assert!(!view.registered_lazy_loaded_paths.contains(&std_path(&a)));
|
||||
assert!(view.registered_lazy_loaded_paths.contains(&std_path(&tree)));
|
||||
});
|
||||
repository_metadata_model.read(&app, |model, ctx| {
|
||||
assert!(!model.is_lazy_loaded_path(&std_path(&a), ctx));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Find References UI component for displaying LSP textDocument/references results.
|
||||
//!
|
||||
//! This module provides a hover card that shows all references to a symbol
|
||||
//! as a flat list with file info, line numbers, and syntax-highlighted code snippets.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use lsp::ReferenceLocation;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use string_offset::CharOffset;
|
||||
use warp_core::ui::{
|
||||
appearance::Appearance, icons::Icon as WarpIcon, theme::color::internal_colors,
|
||||
};
|
||||
use warp_files::FileModel;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ClippedScrollable,
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, Hoverable,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
Radius, ScrollbarWidth, Shrinkable, Stack, Text,
|
||||
},
|
||||
keymap::FixedBinding,
|
||||
platform::Cursor,
|
||||
prelude::Align,
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
|
||||
use super::{
|
||||
editor::view::{CodeEditorRenderOptions, CodeEditorView},
|
||||
global_buffer_model::GlobalBufferModel,
|
||||
};
|
||||
use crate::editor::InteractionState;
|
||||
use warp_editor::{
|
||||
content::buffer::InitialBufferState, render::element::VerticalExpansionBehavior,
|
||||
};
|
||||
|
||||
/// Maximum height for the find references card.
|
||||
pub const FIND_REFERENCES_CARD_MAX_HEIGHT: f32 = 300.;
|
||||
|
||||
const HAS_REFERENCES: &str = "HasReferences";
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
FindReferencesViewAction::Close,
|
||||
id!(FindReferencesView::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
FindReferencesViewAction::SelectReference,
|
||||
id!(FindReferencesView::ui_name()) & id!(HAS_REFERENCES),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"numpadenter",
|
||||
FindReferencesViewAction::SelectReference,
|
||||
id!(FindReferencesView::ui_name()) & id!(HAS_REFERENCES),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"up",
|
||||
FindReferencesViewAction::ArrowUp,
|
||||
id!(FindReferencesView::ui_name()) & id!(HAS_REFERENCES),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"down",
|
||||
FindReferencesViewAction::ArrowDown,
|
||||
id!(FindReferencesView::ui_name()) & id!(HAS_REFERENCES),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Actions that FindReferencesView can handle.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FindReferencesViewAction {
|
||||
/// Navigate to the reference at the given index.
|
||||
GotoReference(usize),
|
||||
/// Close the references card.
|
||||
Close,
|
||||
/// Move selection up (arrow up).
|
||||
ArrowUp,
|
||||
/// Move selection down (arrow down).
|
||||
ArrowDown,
|
||||
/// Select the currently highlighted reference (enter).
|
||||
SelectReference,
|
||||
}
|
||||
|
||||
/// Events emitted by FindReferencesView.
|
||||
pub enum FindReferencesViewEvent {
|
||||
/// User requested navigation to a reference.
|
||||
GotoReference(usize),
|
||||
/// User requested to close the card.
|
||||
CloseRequested,
|
||||
}
|
||||
|
||||
/// View for displaying find references results with async line loading.
|
||||
pub struct FindReferencesView {
|
||||
/// Flat list of all references with their UI state.
|
||||
references: Vec<ReferenceEntryWithUi>,
|
||||
/// The offset where references were requested (used for positioning).
|
||||
request_offset: CharOffset,
|
||||
/// Scroll state for the card content.
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
back_mouse_state: MouseStateHandle,
|
||||
/// The index of the currently selected reference for keyboard navigation.
|
||||
selected_reference_index: usize,
|
||||
}
|
||||
|
||||
/// A reference entry bundled with its UI state.
|
||||
pub struct ReferenceEntryWithUi {
|
||||
/// The reference data.
|
||||
pub entry: FlatReferenceEntry,
|
||||
/// Mouse state for the entry row hover.
|
||||
pub entry_mouse_state: MouseStateHandle,
|
||||
/// Mouse state for the file name tooltip.
|
||||
pub file_name_mouse_state: MouseStateHandle,
|
||||
/// Editor view for syntax highlighting.
|
||||
pub editor_view: ViewHandle<CodeEditorView>,
|
||||
}
|
||||
|
||||
impl ReferenceEntryWithUi {
|
||||
/// Updates the line content and refreshes the editor view.
|
||||
/// Trims whitespace from the start of the line and adjusts the column accordingly.
|
||||
pub fn update_line_content(&mut self, line: &str, ctx: &mut ViewContext<FindReferencesView>) {
|
||||
let trimmed = line.trim_start();
|
||||
let trim_offset = line.len() - trimmed.len();
|
||||
self.entry.line_content = Some(trimmed.to_string());
|
||||
self.entry.column = self.entry.column.saturating_sub(trim_offset);
|
||||
|
||||
// Update the editor view with the loaded content
|
||||
let content = trimmed.to_string();
|
||||
let file_path = self.entry.file_path.clone();
|
||||
self.editor_view.update(ctx, |view, ctx| {
|
||||
view.set_language_with_path(&file_path, ctx);
|
||||
let state = InitialBufferState::plain_text(&content);
|
||||
view.reset(state, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl FindReferencesView {
|
||||
/// Creates a new FindReferencesView with async line loading.
|
||||
/// Lines are loaded asynchronously from GlobalBufferModel (fast) or disk (slow).
|
||||
///
|
||||
/// Converts raw ReferenceLocation to FlatReferenceEntry and loads line content.
|
||||
pub fn new(
|
||||
raw_references: Vec<ReferenceLocation>,
|
||||
workspace_root: Option<PathBuf>,
|
||||
request_offset: CharOffset,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
// Convert ReferenceLocation to ReferenceEntryWithUi (without line content - will load async)
|
||||
let mut references: Vec<ReferenceEntryWithUi> = raw_references
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let file_path = r.file_path.clone();
|
||||
|
||||
// Get just the file name (leaf) for display
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
||||
|
||||
// Guard against empty file names from malformed LSP URIs
|
||||
let file_name = if file_name.is_empty() {
|
||||
"[unknown]".to_string()
|
||||
} else {
|
||||
file_name
|
||||
};
|
||||
|
||||
// Get relative path from workspace root for tooltip
|
||||
let display_path = workspace_root
|
||||
.as_ref()
|
||||
.and_then(|root| file_path.strip_prefix(root).ok())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| file_path.to_string_lossy().to_string());
|
||||
|
||||
let line_number = r.range.start.line + 1; // Convert 0-based to 1-based
|
||||
|
||||
let entry = FlatReferenceEntry {
|
||||
file_path,
|
||||
file_name,
|
||||
display_path,
|
||||
line_number,
|
||||
column: r.range.start.column,
|
||||
line_content: None, // Will be loaded async
|
||||
};
|
||||
|
||||
// Create editor view for this reference
|
||||
let editor_view = Self::create_editor_view_for_reference(&entry, ctx);
|
||||
|
||||
ReferenceEntryWithUi {
|
||||
entry,
|
||||
entry_mouse_state: MouseStateHandle::default(),
|
||||
file_name_mouse_state: MouseStateHandle::default(),
|
||||
editor_view,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Batch references by file for efficient loading
|
||||
let mut file_to_refs: HashMap<PathBuf, Vec<usize>> = HashMap::new();
|
||||
for (idx, reference) in references.iter().enumerate() {
|
||||
if reference.entry.line_content.is_none() {
|
||||
file_to_refs
|
||||
.entry(reference.entry.file_path.clone())
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load lines from GlobalBufferModel first (fast, in-memory)
|
||||
let global_buffer = GlobalBufferModel::handle(ctx);
|
||||
for (file_path, ref_indices) in file_to_refs.iter() {
|
||||
// Collect all line numbers we need from this file (0-based)
|
||||
let line_numbers: Vec<usize> = ref_indices
|
||||
.iter()
|
||||
.map(|&idx| references[idx].entry.line_number - 1)
|
||||
.collect();
|
||||
|
||||
if line_numbers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(lines) = global_buffer.update(ctx, |model, ctx| {
|
||||
model.get_lines_for_file(file_path, line_numbers.clone(), ctx)
|
||||
}) {
|
||||
// Build a map from line number to content for quick lookup
|
||||
let line_map: HashMap<usize, &String> =
|
||||
lines.iter().map(|(ln, content)| (*ln, content)).collect();
|
||||
|
||||
// Successfully loaded from buffer - update references
|
||||
for &ref_idx in ref_indices {
|
||||
let reference = &mut references[ref_idx];
|
||||
let line_num = reference.entry.line_number - 1; // 0-based
|
||||
if let Some(line) = line_map.get(&line_num) {
|
||||
reference.update_line_content(line, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For any references still without line_content, spawn async file reads
|
||||
for (file_path, ref_indices) in file_to_refs {
|
||||
// Check if any refs for this file still need loading
|
||||
let needs_loading: Vec<usize> = ref_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&idx| references[idx].entry.line_content.is_none())
|
||||
.collect();
|
||||
|
||||
if needs_loading.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect line numbers for this file (0-based)
|
||||
let line_numbers: Vec<usize> = needs_loading
|
||||
.iter()
|
||||
.map(|&idx| references[idx].entry.line_number - 1)
|
||||
.collect();
|
||||
|
||||
let file_path_clone = file_path.clone();
|
||||
ctx.spawn(
|
||||
async move { FileModel::read_lines_async(&file_path_clone, line_numbers).await },
|
||||
move |me, result, ctx| {
|
||||
if let Ok(lines) = result {
|
||||
// Build a map from line number to content for quick lookup
|
||||
let line_map: HashMap<usize, &String> =
|
||||
lines.iter().map(|(ln, content)| (*ln, content)).collect();
|
||||
|
||||
for &ref_idx in &needs_loading {
|
||||
if ref_idx >= me.references.len() {
|
||||
continue;
|
||||
}
|
||||
let reference = &mut me.references[ref_idx];
|
||||
let line_num = reference.entry.line_number - 1; // 0-based
|
||||
if let Some(line) = line_map.get(&line_num) {
|
||||
reference.update_line_content(line, ctx);
|
||||
}
|
||||
}
|
||||
ctx.notify(); // Trigger re-render
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Self {
|
||||
references,
|
||||
request_offset,
|
||||
scroll_state: ClippedScrollStateHandle::default(),
|
||||
back_mouse_state: MouseStateHandle::default(),
|
||||
selected_reference_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the request offset for positioning the card.
|
||||
pub fn request_offset(&self) -> CharOffset {
|
||||
self.request_offset
|
||||
}
|
||||
|
||||
/// Gets a reference by index.
|
||||
pub fn get_reference(&self, index: usize) -> Option<&FlatReferenceEntry> {
|
||||
self.references.get(index).map(|r| &r.entry)
|
||||
}
|
||||
|
||||
/// Creates a read-only code editor view for a single reference line.
|
||||
fn create_editor_view_for_reference(
|
||||
reference: &FlatReferenceEntry,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<CodeEditorView> {
|
||||
let view = ctx.add_typed_action_view(|ctx| {
|
||||
let mut editor_view = CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight),
|
||||
ctx,
|
||||
)
|
||||
.with_can_show_diff_ui(false)
|
||||
.with_show_line_numbers(false)
|
||||
.with_horizontal_scrollbar_appearance(
|
||||
warpui::elements::new_scrollable::ScrollableAppearance::new(
|
||||
warpui::elements::ScrollbarWidth::None,
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
editor_view.set_vertical_scrollbar_appearance(
|
||||
warpui::elements::new_scrollable::ScrollableAppearance::new(
|
||||
warpui::elements::ScrollbarWidth::None,
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
editor_view
|
||||
});
|
||||
|
||||
// Initialize the editor with the reference line content (or empty if still loading)
|
||||
let content = reference.line_content.clone().unwrap_or_default();
|
||||
let file_path = reference.file_path.clone();
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.set_show_current_line_highlights(false, ctx);
|
||||
view.set_interaction_state(InteractionState::Disabled, ctx);
|
||||
|
||||
// Set up syntax highlighting based on file extension
|
||||
view.set_language_with_path(&file_path, ctx);
|
||||
|
||||
// Reset with the reference line content
|
||||
let state = InitialBufferState::plain_text(&content);
|
||||
view.reset(state, ctx);
|
||||
});
|
||||
|
||||
view
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FindReferencesView {
|
||||
type Event = FindReferencesViewEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for FindReferencesView {
|
||||
type Action = FindReferencesViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
FindReferencesViewAction::GotoReference(index) => {
|
||||
ctx.emit(FindReferencesViewEvent::GotoReference(*index));
|
||||
}
|
||||
FindReferencesViewAction::Close => {
|
||||
ctx.emit(FindReferencesViewEvent::CloseRequested);
|
||||
}
|
||||
FindReferencesViewAction::ArrowUp => {
|
||||
if !self.references.is_empty() {
|
||||
self.selected_reference_index =
|
||||
(self.selected_reference_index + self.references.len() - 1)
|
||||
% self.references.len();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
FindReferencesViewAction::ArrowDown => {
|
||||
if !self.references.is_empty() {
|
||||
self.selected_reference_index =
|
||||
(self.selected_reference_index + 1) % self.references.len();
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
FindReferencesViewAction::SelectReference => {
|
||||
if self.selected_reference_index < self.references.len() {
|
||||
ctx.emit(FindReferencesViewEvent::GotoReference(
|
||||
self.selected_reference_index,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for FindReferencesView {
|
||||
fn ui_name() -> &'static str {
|
||||
"FindReferencesView"
|
||||
}
|
||||
|
||||
fn keymap_context(&self, _app: &AppContext) -> warpui::keymap::Context {
|
||||
let mut context = Self::default_keymap_context();
|
||||
if !self.references.is_empty() {
|
||||
context.set.insert(HAS_REFERENCES);
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if self.references.is_empty() {
|
||||
return warpui::elements::Empty::new().finish();
|
||||
}
|
||||
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Header with back arrow and "Showing X references"
|
||||
let header = render_header(self.back_mouse_state.clone(), self.references.len(), app);
|
||||
|
||||
// Content: flat list of reference entries
|
||||
let mut content_column =
|
||||
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for (index, reference) in self.references.iter().enumerate() {
|
||||
let is_selected = index == self.selected_reference_index;
|
||||
let entry = render_reference_entry(
|
||||
&reference.entry,
|
||||
reference.entry_mouse_state.clone(),
|
||||
reference.file_name_mouse_state.clone(),
|
||||
&reference.editor_view,
|
||||
index,
|
||||
is_selected,
|
||||
app,
|
||||
);
|
||||
content_column.add_child(entry);
|
||||
}
|
||||
|
||||
// Make content scrollable
|
||||
let scrollable_content = ClippedScrollable::vertical(
|
||||
self.scroll_state.clone(),
|
||||
content_column.finish(),
|
||||
ScrollbarWidth::None,
|
||||
theme.disabled_ui_text_color().into(),
|
||||
theme.active_ui_text_color().into(),
|
||||
Fill::None,
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Combine header and content
|
||||
let card_content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(header)
|
||||
.with_child(Shrinkable::new(1., scrollable_content).finish())
|
||||
.finish();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Container::new(card_content)
|
||||
.with_background_color(internal_colors::neutral_1(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.with_border(Border::all(1.).with_border_fill(internal_colors::neutral_4(theme)))
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(FIND_REFERENCES_CARD_MAX_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single reference entry with file information for flat list display.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlatReferenceEntry {
|
||||
/// The absolute file path.
|
||||
pub file_path: PathBuf,
|
||||
/// The display file name (just the file name, not full path).
|
||||
pub file_name: String,
|
||||
/// The relative path from workspace root (for tooltip display).
|
||||
pub display_path: String,
|
||||
/// 1-based line number where the reference appears.
|
||||
pub line_number: usize,
|
||||
/// 0-based column number where the reference starts (adjusted for trimmed whitespace).
|
||||
pub column: usize,
|
||||
/// The content of the line containing the reference (trimmed).
|
||||
/// None indicates the line is still loading from disk.
|
||||
pub line_content: Option<String>,
|
||||
}
|
||||
|
||||
/// Renders the card header with back arrow and "Showing X references" text.
|
||||
fn render_header(
|
||||
back_mouse_state: MouseStateHandle,
|
||||
total_refs: usize,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// "Showing X references" title
|
||||
let title_text = if total_refs == 1 {
|
||||
"Showing 1 reference".to_string()
|
||||
} else {
|
||||
format!("Showing {total_refs} references")
|
||||
};
|
||||
|
||||
let title = Align::new(
|
||||
Text::new_inline(
|
||||
title_text,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.active_ui_text_color().into())
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish();
|
||||
|
||||
// Close (X) button on the right
|
||||
let icon_color = theme.sub_text_color(theme.background());
|
||||
let close_button = Hoverable::new(back_mouse_state, move |state| {
|
||||
let close_icon = ConstrainedBox::new(
|
||||
warpui::elements::Icon::new(WarpIcon::X.into(), icon_color).finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let container = Container::new(close_icon)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_uniform_padding(2.);
|
||||
|
||||
if state.is_hovered() {
|
||||
container
|
||||
.with_background(internal_colors::fg_overlay_2(theme))
|
||||
.finish()
|
||||
} else {
|
||||
container.finish()
|
||||
}
|
||||
})
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(FindReferencesViewAction::Close);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., title).finish())
|
||||
.with_child(close_button)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_background(theme.background())
|
||||
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a single reference entry row with file info, line number, and code snippet.
|
||||
fn render_reference_entry(
|
||||
entry: &FlatReferenceEntry,
|
||||
entry_mouse_state: MouseStateHandle,
|
||||
file_name_mouse_state: MouseStateHandle,
|
||||
editor_view: &ViewHandle<CodeEditorView>,
|
||||
index: usize,
|
||||
is_selected: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let file_name = entry.file_name.clone();
|
||||
let display_path = entry.display_path.clone();
|
||||
let line_number = entry.line_number;
|
||||
let line_content = entry.line_content.clone();
|
||||
|
||||
ConstrainedBox::new(
|
||||
Hoverable::new(entry_mouse_state, move |state| {
|
||||
// File icon - use language-specific icon
|
||||
let file_icon = ConstrainedBox::new(crate::search::files::icon::icon_from_file_path(
|
||||
&file_name,
|
||||
appearance,
|
||||
ItemHighlightState::Default,
|
||||
))
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
// File name with tooltip showing full path
|
||||
let file_name_with_tooltip =
|
||||
Hoverable::new(file_name_mouse_state.clone(), move |file_name_state| {
|
||||
let file_name_text = Text::new_inline(
|
||||
file_name.clone(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish();
|
||||
|
||||
if file_name_state.is_hovered() {
|
||||
let mut stack = Stack::new().with_child(file_name_text);
|
||||
let tooltip = appearance
|
||||
.ui_builder()
|
||||
.tool_tip(display_path.clone())
|
||||
.build()
|
||||
.finish();
|
||||
stack.add_positioned_overlay_child(
|
||||
tooltip,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
Vector2F::new(0., 4.),
|
||||
ParentOffsetBounds::Unbounded,
|
||||
ParentAnchor::BottomMiddle,
|
||||
ChildAnchor::TopMiddle,
|
||||
),
|
||||
);
|
||||
stack.finish()
|
||||
} else {
|
||||
file_name_text
|
||||
}
|
||||
})
|
||||
.finish();
|
||||
|
||||
// Line number
|
||||
let line_num_text = Text::new_inline(
|
||||
line_number.to_string(),
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish();
|
||||
|
||||
// Code content: use editor view for syntax highlighting, or show loading indicator
|
||||
// Cap the content element's height to prevent the Align from inflating
|
||||
// the Flex::row (Align::layout always takes constraint.max as its size).
|
||||
// Use the monospace line height so the editor matches the text children.
|
||||
let content_max_height =
|
||||
appearance.monospace_font_size() * appearance.line_height_ratio();
|
||||
let content_element: Box<dyn Element> = if line_content.is_some() {
|
||||
ConstrainedBox::new(
|
||||
Align::new(ChildView::new(editor_view).finish())
|
||||
.top_left()
|
||||
.finish(),
|
||||
)
|
||||
.with_max_height(content_max_height)
|
||||
.finish()
|
||||
} else {
|
||||
// Show loading indicator when line_content is None
|
||||
Text::new_inline(
|
||||
"Loading...",
|
||||
appearance.monospace_font_family(),
|
||||
appearance.monospace_font_size(),
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish()
|
||||
};
|
||||
|
||||
// Layout: [file_icon] [file_name] [line_number] [code_snippet]
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Container::new(file_icon).with_margin_right(6.).finish())
|
||||
.with_child(
|
||||
ConstrainedBox::new(file_name_with_tooltip)
|
||||
.with_width(120.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(Align::new(line_num_text).right().finish())
|
||||
.with_width(40.)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., content_element).finish())
|
||||
.finish();
|
||||
|
||||
let container = Container::new(row)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(6.);
|
||||
|
||||
if state.is_hovered() || is_selected {
|
||||
container
|
||||
.with_background(internal_colors::fg_overlay_2(theme))
|
||||
.finish()
|
||||
} else {
|
||||
container.finish()
|
||||
}
|
||||
})
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(FindReferencesViewAction::GotoReference(index));
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish(),
|
||||
)
|
||||
.with_min_height(28.)
|
||||
.finish()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
use std::path::Path;
|
||||
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{
|
||||
assets::asset_cache::AssetSource,
|
||||
elements::{CacheOption, Icon, Image},
|
||||
Element,
|
||||
};
|
||||
|
||||
/// Returns a special icon for the given file path, if any.
|
||||
pub fn icon_from_file_path(path: &str, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
||||
let theme = appearance.theme();
|
||||
let parsed_path = Path::new(path);
|
||||
let extension = parsed_path.extension().and_then(|ext| ext.to_str());
|
||||
|
||||
let image = match extension {
|
||||
Some("rs") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/rust.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("json") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/json.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("ts") | Some("tsx") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/typescript.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("js") | Some("jsx") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/javascript.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("py") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/python.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("cpp") | Some("hpp") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/cpp.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("go") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/go.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("md") => Icon::new(
|
||||
"bundled/svg/file_type/markdown.svg",
|
||||
theme.main_text_color(theme.background()).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
Some("sh") => Icon::new(
|
||||
"bundled/svg/terminal.svg",
|
||||
theme.main_text_color(theme.background()).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
Some("kt") | Some("kts") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/kotlin.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("php") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/php.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("pl") | Some("pm") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/perl.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("c") | Some("h") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/c.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("pyx") | Some("pxd") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/cython.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("swf") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/flash.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("wasm") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/wasm.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("zig") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/zig.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("sql") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/sql.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("ng") | Some("ngml") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/angular.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
Some("tf") | Some("hcl") | Some("tfvars") => Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: "bundled/svg/file_type/terraform.svg",
|
||||
},
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.finish(),
|
||||
_ => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(image)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::blocklist::inline_action::code_diff_view::DiffSessionType;
|
||||
use ai::diff_validation::DiffType;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warp_files::{FileModel, FileModelEvent};
|
||||
use warp_util::file::FileId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warp_util::file::FileSaveError;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::elements::ChildView;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::diff_viewer::DiffViewer;
|
||||
use super::diff_viewer::DisplayMode;
|
||||
use super::editor::scroll::{ScrollPosition, ScrollTrigger};
|
||||
use super::editor::view::{CodeEditorEvent, CodeEditorView};
|
||||
use super::editor::NavBarBehavior;
|
||||
use super::DiffResult;
|
||||
use crate::editor::InteractionState;
|
||||
|
||||
pub enum InlineDiffViewEvent {
|
||||
DiffStatusUpdated,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
FileLoaded,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
FileSaved,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
FailedToSave {
|
||||
error: Rc<FileSaveError>,
|
||||
},
|
||||
DiffAccepted {
|
||||
diff: Rc<DiffResult>,
|
||||
},
|
||||
UserEdited,
|
||||
}
|
||||
|
||||
/// An inline diff viewer with optional file-backed save support.
|
||||
///
|
||||
/// When a backing file is registered (via [`Self::register_file`]), this view supports the full
|
||||
/// accept/save/revert lifecycle through `FileModel`. Without a registered file, it behaves
|
||||
/// as a read-only diff viewer (e.g. for WASM or restored conversations).
|
||||
pub struct InlineDiffView {
|
||||
editor: ViewHandle<CodeEditorView>,
|
||||
diff_type: Option<DiffType>,
|
||||
file_path: Option<StandardizedPath>,
|
||||
/// Whether the user has edited the diff content.
|
||||
was_edited: bool,
|
||||
/// `FileModel` file ID for the backing file. Set via [`Self::register_file`].
|
||||
///
|
||||
/// When `Some`:
|
||||
/// - The editor is editable (interaction state follows the `DisplayMode` rules).
|
||||
/// - Accept, save, and revert operations write through `FileModel`.
|
||||
///
|
||||
/// When `None` (WASM, restored conversations, or before registration):
|
||||
/// - The editor is selection-only (never editable).
|
||||
/// - Accept, save, and revert are no-ops.
|
||||
backing_file_id: Option<FileId>,
|
||||
/// Whether the diff is a new file creation (for revert: delete instead of restore).
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
is_new_file: bool,
|
||||
}
|
||||
|
||||
impl InlineDiffView {
|
||||
pub fn new(
|
||||
editor: ViewHandle<CodeEditorView>,
|
||||
diff_type: Option<DiffType>,
|
||||
display_mode: Option<DisplayMode>,
|
||||
file_path: Option<StandardizedPath>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let is_new_file = matches!(diff_type, Some(DiffType::Create { .. }));
|
||||
|
||||
ctx.subscribe_to_view(&editor, |me, _view, event, ctx| match event {
|
||||
CodeEditorEvent::DiffUpdated => {
|
||||
ctx.emit(InlineDiffViewEvent::DiffStatusUpdated);
|
||||
}
|
||||
CodeEditorEvent::UnifiedDiffComputed(diff) => {
|
||||
ctx.emit(InlineDiffViewEvent::DiffAccepted { diff: diff.clone() });
|
||||
}
|
||||
CodeEditorEvent::ContentChanged { origin } => {
|
||||
if origin.from_user() && !me.was_edited {
|
||||
me.was_edited = true;
|
||||
ctx.emit(InlineDiffViewEvent::UserEdited);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let model = Self {
|
||||
editor,
|
||||
diff_type,
|
||||
file_path,
|
||||
was_edited: false,
|
||||
backing_file_id: None,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
is_new_file,
|
||||
};
|
||||
|
||||
model.apply_diffs_if_any(ctx);
|
||||
if let Some(display_mode) = display_mode {
|
||||
model.set_display_mode(display_mode, ctx);
|
||||
}
|
||||
|
||||
model
|
||||
}
|
||||
|
||||
/// Register a file with `FileModel` for save support.
|
||||
///
|
||||
/// The `session_type` determines whether the file is local or remote.
|
||||
/// For `Local`, the file is registered by path on the local filesystem.
|
||||
/// For `Remote`, the file is registered against the remote backend so
|
||||
/// that `save()` / `delete()` dispatch over the wire via
|
||||
/// `RemoteServerClient`.
|
||||
///
|
||||
/// This must be called after construction for non-WASM environments.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn register_file(&mut self, session_type: &DiffSessionType, ctx: &mut ViewContext<Self>) {
|
||||
let Some(file_path) = &self.file_path else {
|
||||
return;
|
||||
};
|
||||
|
||||
let file_model = FileModel::handle(ctx);
|
||||
let file_id = match session_type {
|
||||
DiffSessionType::Local => {
|
||||
let Some(local_path) = file_path.to_local_path() else {
|
||||
log::error!(
|
||||
"Failed to convert StandardizedPath to local path: {file_path}; \
|
||||
diff will be read-only",
|
||||
);
|
||||
return;
|
||||
};
|
||||
file_model.update(ctx, |file_model, ctx| {
|
||||
file_model.register_file_path(&local_path, false, ctx)
|
||||
})
|
||||
}
|
||||
DiffSessionType::Remote(host_id) => {
|
||||
let host_id = host_id.clone();
|
||||
let remote_path = file_path.clone();
|
||||
file_model.update(ctx, |file_model, _ctx| {
|
||||
file_model.register_remote_file(host_id, remote_path)
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
self.finish_file_registration(file_id, ctx);
|
||||
}
|
||||
|
||||
/// Common registration logic: subscribes to events and sets the
|
||||
/// backing file ID after a file has been registered with `FileModel`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn finish_file_registration(&mut self, file_id: FileId, ctx: &mut ViewContext<Self>) {
|
||||
let file_model = FileModel::handle(ctx);
|
||||
|
||||
let version = self.editor.as_ref(ctx).version(ctx);
|
||||
file_model.update(ctx, |file_model, _ctx| {
|
||||
file_model.set_version(file_id, version);
|
||||
});
|
||||
|
||||
self.backing_file_id = Some(file_id);
|
||||
|
||||
// Subscribe to FileModel events for this file.
|
||||
ctx.subscribe_to_model(&file_model, move |_me, _file_model, event, ctx| {
|
||||
if file_id == event.file_id() {
|
||||
match event {
|
||||
FileModelEvent::FileSaved { .. } => {
|
||||
ctx.emit(InlineDiffViewEvent::FileSaved);
|
||||
}
|
||||
FileModelEvent::FailedToSave { error, .. } => {
|
||||
ctx.emit(InlineDiffViewEvent::FailedToSave {
|
||||
error: error.clone(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.emit(InlineDiffViewEvent::FileLoaded);
|
||||
}
|
||||
|
||||
fn apply_diffs_if_any(&self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(diff) = self.diff_type.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let deltas = match diff {
|
||||
DiffType::Create { delta } => vec![delta],
|
||||
DiffType::Update { mut deltas, .. } => {
|
||||
deltas.sort_by_key(|delta| delta.replacement_line_range.start);
|
||||
deltas
|
||||
}
|
||||
DiffType::Delete { delta } => vec![delta],
|
||||
};
|
||||
|
||||
if deltas.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.apply_diffs(deltas, ctx);
|
||||
editor.toggle_diff_nav(None, ctx);
|
||||
editor.set_pending_scroll(ScrollTrigger::new(
|
||||
ScrollPosition::FocusedDiffHunk,
|
||||
editor.buffer_version(ctx),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn save_content(&self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(file_id) = self.backing_file_id else {
|
||||
return;
|
||||
};
|
||||
let content = self.editor.as_ref(ctx).text(ctx).into_string();
|
||||
let version = self.editor.as_ref(ctx).version(ctx);
|
||||
|
||||
if let Err(err) = FileModel::handle(ctx).update(ctx, |file_model, ctx| {
|
||||
file_model.save(file_id, content, version, ctx)
|
||||
}) {
|
||||
ctx.emit(InlineDiffViewEvent::FailedToSave {
|
||||
error: Rc::new(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InlineDiffView {
|
||||
pub fn file_path(&self) -> Option<&StandardizedPath> {
|
||||
self.file_path.as_ref()
|
||||
}
|
||||
|
||||
pub fn file_name(&self) -> Option<String> {
|
||||
self.file_path()
|
||||
.map(|p| p.file_name().unwrap_or_default().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl DiffViewer for InlineDiffView {
|
||||
fn editor(&self) -> &ViewHandle<CodeEditorView> {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
fn diff(&self) -> Option<&DiffType> {
|
||||
self.diff_type.as_ref()
|
||||
}
|
||||
|
||||
fn was_edited(&self) -> bool {
|
||||
self.was_edited
|
||||
}
|
||||
|
||||
fn set_display_mode(&self, mode: DisplayMode, ctx: &mut ViewContext<Self>) {
|
||||
let is_delete = matches!(self.diff(), Some(DiffType::Delete { .. }));
|
||||
let interaction_state = if self.backing_file_id.is_some() {
|
||||
mode.interaction_state(is_delete)
|
||||
} else {
|
||||
// No file registered (e.g. WASM or restored conversations): always read-only.
|
||||
InteractionState::Selectable
|
||||
};
|
||||
self.editor().update(ctx, |editor, ctx| {
|
||||
editor.set_scroll_wheel_behavior(mode.scroll_wheel_behavior());
|
||||
editor.set_vertical_expansion_behavior(mode.vertical_expansion_behavior(), ctx);
|
||||
editor.set_vertical_scrollbar_appearance(mode.scrollbar_appearance());
|
||||
editor.set_horizontal_scrollbar_appearance(mode.scrollbar_appearance());
|
||||
editor.set_interaction_state(interaction_state, ctx);
|
||||
editor.set_show_nav_bar(mode.show_nav_bar());
|
||||
editor.set_nav_bar_behavior(NavBarBehavior::NotClosable, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn accept_and_save_diff(&self, ctx: &mut ViewContext<Self>) {
|
||||
// No-op when no file is registered (WASM / restored conversations).
|
||||
if self.backing_file_id.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute the unified diff (result arrives via CodeEditorEvent::UnifiedDiffComputed).
|
||||
if let Some(file_path) = &self.file_path {
|
||||
let file_name = file_path.to_string();
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.retrieve_unified_diff(file_name, ctx)
|
||||
});
|
||||
}
|
||||
// Save the current editor content to disk.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
self.save_content(ctx);
|
||||
}
|
||||
|
||||
fn restore_diff_base(&mut self, _ctx: &mut ViewContext<Self>) -> Result<(), String> {
|
||||
// No-op when no file is registered (WASM / restored conversations).
|
||||
if self.backing_file_id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
let file_id = self
|
||||
.backing_file_id
|
||||
.expect("backing_file_id must be Some — checked by early return above");
|
||||
|
||||
if self.is_new_file {
|
||||
// For newly created files, delete instead of restoring.
|
||||
let version = self.editor.as_ref(_ctx).version(_ctx);
|
||||
FileModel::handle(_ctx)
|
||||
.update(_ctx, |file_model, ctx| {
|
||||
file_model.delete(file_id, version, ctx)
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete file: {e:?}"))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// For existing files, restore the base content from the editor's DiffModel.
|
||||
let base_content = self
|
||||
.editor
|
||||
.as_ref(_ctx)
|
||||
.model
|
||||
.as_ref(_ctx)
|
||||
.diff()
|
||||
.as_ref(_ctx)
|
||||
.base()
|
||||
.ok_or_else(|| "Missing base content".to_string())?
|
||||
.to_string();
|
||||
|
||||
let version = self.editor.as_ref(_ctx).version(_ctx);
|
||||
FileModel::handle(_ctx)
|
||||
.update(_ctx, |file_model, ctx| {
|
||||
file_model.save(file_id, base_content, version, ctx)
|
||||
})
|
||||
.map_err(|e| format!("Failed to save file: {e:?}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for InlineDiffView {
|
||||
type Event = InlineDiffViewEvent;
|
||||
}
|
||||
|
||||
impl View for InlineDiffView {
|
||||
fn ui_name() -> &'static str {
|
||||
"InlineDiffView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
ChildView::new(&self.editor).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for InlineDiffView {
|
||||
type Action = ();
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
use lsp::{HoverContents, LspServerLogLevel, MarkupKind};
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use num_traits::SaturatingSub;
|
||||
use string_offset::CharOffset;
|
||||
use warp_core::ui::{
|
||||
appearance::Appearance,
|
||||
theme::{color::internal_colors, WarpTheme},
|
||||
};
|
||||
use warp_editor::{
|
||||
content::buffer::InitialBufferState,
|
||||
render::{element::VerticalExpansionBehavior, model::Decoration},
|
||||
};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink,
|
||||
Hoverable, MouseStateHandle, ParentElement, Radius, Rect, ScrollbarWidth,
|
||||
},
|
||||
AppContext, Element, SingletonEntity, ViewContext,
|
||||
};
|
||||
|
||||
use crate::code::local_code_editor::{
|
||||
HoverContentSegment, LocalCodeEditorView, LspHoverState, HOVER_TOOLTIP_MAX_HEIGHT,
|
||||
HOVER_TOOLTIP_MAX_WIDTH,
|
||||
};
|
||||
use crate::editor::InteractionState;
|
||||
|
||||
use super::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use super::lsp_telemetry::LspTelemetryEvent;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
|
||||
/// A processed diagnostic with its converted offset range.
|
||||
/// Stored on LocalCodeEditorView and used for both decoration and hover display.
|
||||
#[derive(Clone)]
|
||||
pub struct ProcessedDiagnostic {
|
||||
/// The diagnostic message.
|
||||
pub message: String,
|
||||
/// The severity of the diagnostic.
|
||||
pub severity: lsp_types::DiagnosticSeverity,
|
||||
/// The start offset (0-based, for rendering).
|
||||
pub start: CharOffset,
|
||||
/// The end offset (0-based, for rendering).
|
||||
pub end: CharOffset,
|
||||
}
|
||||
|
||||
enum PendingSection {
|
||||
Markdown(Vec<FormattedTextLine>),
|
||||
Code { language: String, code: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingSections {
|
||||
sections: Vec<PendingSection>,
|
||||
pending: Option<PendingSection>,
|
||||
active_line_break: bool,
|
||||
}
|
||||
|
||||
impl PendingSections {
|
||||
fn push_formatted_line(&mut self, line: FormattedTextLine) {
|
||||
match line {
|
||||
FormattedTextLine::LineBreak
|
||||
if self.active_line_break
|
||||
|| matches!(self.pending, Some(PendingSection::Code { .. }) | None) => {}
|
||||
FormattedTextLine::HorizontalRule => {
|
||||
self.active_line_break = false;
|
||||
if let Some(section) = self.pending.take() {
|
||||
self.sections.push(section);
|
||||
}
|
||||
}
|
||||
FormattedTextLine::CodeBlock(code_block) => {
|
||||
self.active_line_break = false;
|
||||
match self.pending.take() {
|
||||
Some(pending @ PendingSection::Markdown(_)) => {
|
||||
self.sections.push(pending);
|
||||
}
|
||||
Some(PendingSection::Code { mut code, language }) => {
|
||||
if language == code_block.lang {
|
||||
code.push('\n');
|
||||
code.push_str(code_block.code.trim());
|
||||
|
||||
self.pending = Some(PendingSection::Code { code, language });
|
||||
return;
|
||||
}
|
||||
self.sections.push(PendingSection::Code { code, language });
|
||||
}
|
||||
None => (),
|
||||
};
|
||||
self.pending = Some(PendingSection::Code {
|
||||
code: code_block.code,
|
||||
language: code_block.lang,
|
||||
})
|
||||
}
|
||||
other => {
|
||||
self.active_line_break = matches!(other, FormattedTextLine::LineBreak);
|
||||
match self.pending.take() {
|
||||
Some(code @ PendingSection::Code { .. }) => {
|
||||
self.sections.push(code);
|
||||
self.pending = Some(PendingSection::Markdown(vec![other]));
|
||||
}
|
||||
Some(PendingSection::Markdown(mut markdown)) => {
|
||||
markdown.push(other);
|
||||
self.pending = Some(PendingSection::Markdown(markdown));
|
||||
}
|
||||
None => self.pending = Some(PendingSection::Markdown(vec![other])),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(self, ctx: &mut ViewContext<LocalCodeEditorView>) -> Vec<HoverContentSegment> {
|
||||
let mut segments = Vec::new();
|
||||
for section in self.sections {
|
||||
match section {
|
||||
PendingSection::Markdown(text_lines) => {
|
||||
segments.push(HoverContentSegment::Text(FormattedText::new(text_lines)))
|
||||
}
|
||||
PendingSection::Code { language, code } => segments.push(
|
||||
LocalCodeEditorView::create_highlighted_code_fragment(language, code, ctx),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pending) = self.pending {
|
||||
match pending {
|
||||
PendingSection::Markdown(text_lines) => {
|
||||
segments.push(HoverContentSegment::Text(FormattedText::new(text_lines)))
|
||||
}
|
||||
PendingSection::Code { language, code } => segments.push(
|
||||
LocalCodeEditorView::create_highlighted_code_fragment(language, code, ctx),
|
||||
),
|
||||
}
|
||||
}
|
||||
segments
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalCodeEditorView {
|
||||
/// Refresh diagnostics from the LSP server.
|
||||
/// This updates the cached processed diagnostics and creates decorations for the editor.
|
||||
pub(super) fn refresh_diagnostics(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// Update cached processed diagnostics.
|
||||
self.processed_diagnostics = self.compute_processed_diagnostics(ctx);
|
||||
|
||||
// Convert processed diagnostics to decorations.
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let error_color = appearance.theme().ui_error_color();
|
||||
let warning_color = appearance.theme().ui_warning_color();
|
||||
|
||||
let decorations: Vec<Decoration> = self
|
||||
.processed_diagnostics
|
||||
.iter()
|
||||
.map(|diag| {
|
||||
let color = match diag.severity {
|
||||
lsp_types::DiagnosticSeverity::ERROR => error_color,
|
||||
lsp_types::DiagnosticSeverity::WARNING => warning_color,
|
||||
_ => error_color, // Fallback, though we filter to only errors/warnings
|
||||
};
|
||||
Decoration::new(diag.start, diag.end).with_dashed_underline(color)
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.diagnostic_decorations = decorations;
|
||||
self.update_editor_decorations(ctx);
|
||||
}
|
||||
|
||||
pub(super) fn clear_diagnostics(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.processed_diagnostics.clear();
|
||||
self.diagnostic_decorations.clear();
|
||||
self.update_editor_decorations(ctx);
|
||||
}
|
||||
|
||||
/// Update the editor's text decorations with diagnostic decorations.
|
||||
fn update_editor_decorations(&self, ctx: &mut ViewContext<Self>) {
|
||||
// Pass diagnostic decorations to the render state.
|
||||
let decorations = self.diagnostic_decorations.clone();
|
||||
self.editor.update(ctx, |editor, ctx| {
|
||||
editor.set_diagnostic_decorations(decorations, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Compute processed diagnostics (errors and warnings) with their converted offset ranges.
|
||||
/// Returns an empty vec if LSP server is not available or there are no diagnostics.
|
||||
fn compute_processed_diagnostics(&self, ctx: &ViewContext<Self>) -> Vec<ProcessedDiagnostic> {
|
||||
let Some(lsp_server) = self.lsp_server.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(file_path) = self.file_path() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(doc_diagnostics) = lsp_server
|
||||
.as_ref(ctx)
|
||||
.diagnostics_for_path(file_path)
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Only show diagnostics that match the current buffer version.
|
||||
let current_buffer_version = self.editor.as_ref(ctx).buffer_version(ctx).as_usize() as i32;
|
||||
let diag_count = doc_diagnostics.diagnostics.len();
|
||||
let diag_age_ms = doc_diagnostics.published_at.elapsed().as_millis();
|
||||
|
||||
match doc_diagnostics.version {
|
||||
Some(version) if version != current_buffer_version => {
|
||||
lsp_server.as_ref(ctx).log_to_server_log(
|
||||
LspServerLogLevel::Info,
|
||||
format!(
|
||||
"render: DROPPED (version mismatch) file={} render_version={current_buffer_version} diag_version={version} diag_count={diag_count} age_ms={diag_age_ms}",
|
||||
file_path.display(),
|
||||
),
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
Some(version) => {
|
||||
lsp_server.as_ref(ctx).log_to_server_log(
|
||||
LspServerLogLevel::Debug,
|
||||
format!(
|
||||
"render: OK file={} render_version={current_buffer_version} diag_version={version} diag_count={diag_count} age_ms={diag_age_ms}",
|
||||
file_path.display(),
|
||||
),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
lsp_server.as_ref(ctx).log_to_server_log(
|
||||
LspServerLogLevel::Debug,
|
||||
format!(
|
||||
"render: UNVERSIONED file={} render_version={current_buffer_version} diag_count={diag_count} age_ms={diag_age_ms}",
|
||||
file_path.display(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
doc_diagnostics
|
||||
.diagnostics
|
||||
.iter()
|
||||
.filter_map(|diagnostic| {
|
||||
// Only include errors and warnings.
|
||||
let severity = diagnostic.severity?;
|
||||
if !matches!(
|
||||
severity,
|
||||
lsp_types::DiagnosticSeverity::ERROR | lsp_types::DiagnosticSeverity::WARNING
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Convert LSP range to CharOffset range.
|
||||
let range: lsp::types::Range = diagnostic.range.into();
|
||||
let mut start_offset = self
|
||||
.editor
|
||||
.as_ref(ctx)
|
||||
.lsp_location_to_offset(&range.start, ctx);
|
||||
let mut end_offset = self
|
||||
.editor
|
||||
.as_ref(ctx)
|
||||
.lsp_location_to_offset(&range.end, ctx);
|
||||
|
||||
// Handle zero-width ranges by expanding to at least 1 character.
|
||||
if start_offset >= end_offset {
|
||||
end_offset = start_offset + CharOffset::from(1);
|
||||
}
|
||||
|
||||
// Check if the diagnostic range only covers a newline character.
|
||||
// This happens for diagnostics like "missing semicolon" that point to
|
||||
// the end of a line. In this case, shift the range back to cover the
|
||||
// last character on the line instead, so it renders visibly.
|
||||
let is_single_char_range =
|
||||
end_offset.saturating_sub(&start_offset) == CharOffset::from(1);
|
||||
if is_single_char_range {
|
||||
let char_at_start = self.editor.as_ref(ctx).char_at(start_offset, ctx);
|
||||
if let Some('\n') = char_at_start {
|
||||
// Shift range back by 1 to cover the character before the newline.
|
||||
if start_offset > CharOffset::from(1) {
|
||||
start_offset = start_offset.saturating_sub(&CharOffset::from(1));
|
||||
end_offset = end_offset.saturating_sub(&CharOffset::from(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to 0-based offsets (render offsets).
|
||||
let start = start_offset.saturating_sub(&CharOffset::from(1));
|
||||
let end = end_offset.saturating_sub(&CharOffset::from(1));
|
||||
|
||||
Some(ProcessedDiagnostic {
|
||||
message: diagnostic.message.clone(),
|
||||
severity,
|
||||
start,
|
||||
end,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get diagnostics at the given offset from the cached processed diagnostics.
|
||||
/// Returns a list of ProcessedDiagnostic for any diagnostics whose range contains the offset.
|
||||
/// The input offset and ProcessedDiagnostic ranges are both 0-based render offsets.
|
||||
pub(super) fn diagnostics_at_offset(&self, offset: CharOffset) -> Vec<ProcessedDiagnostic> {
|
||||
self.processed_diagnostics
|
||||
.iter()
|
||||
.filter(|diag| offset >= diag.start && offset < diag.end)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Request hover information (documentation, type info) for a given offset.
|
||||
pub fn hover_for_offset(&mut self, offset: CharOffset, ctx: &mut ViewContext<Self>) {
|
||||
if matches!(self.lsp_hover_state, LspHoverState::None) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lsp_position = self
|
||||
.editor()
|
||||
.as_ref(ctx)
|
||||
.offset_to_lsp_position(offset, ctx);
|
||||
|
||||
let Some(file_path) = self.file_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if self.lsp_server.is_none() {
|
||||
log::warn!("No LSP server available for hover");
|
||||
return;
|
||||
}
|
||||
|
||||
let future = match self
|
||||
.lsp_server
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_ref(ctx)
|
||||
.hover(file_path.to_path_buf(), lsp_position)
|
||||
{
|
||||
Ok(future) => future,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to call lsp.hover: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let abort_handle = ctx
|
||||
.spawn(future, move |me, result, ctx| {
|
||||
// Get diagnostics at the hovered offset from cached processed diagnostics.
|
||||
// We always check for diagnostics, regardless of the LSP hover result.
|
||||
let diagnostics = me.diagnostics_at_offset(offset);
|
||||
|
||||
// Extract hover range and contents from the LSP result (if available).
|
||||
let (hover_range, hover_contents) = match result {
|
||||
Ok(Some(hover_result)) => (hover_result.range, Some(hover_result.contents)),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
// Create hover segments if we have non-empty contents.
|
||||
let segments = match hover_contents {
|
||||
Some(contents) if !contents.is_empty() => {
|
||||
me.create_hover_content_segments(contents, ctx)
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
// Only show the hover tooltip if there's something to display.
|
||||
if segments.is_empty() && diagnostics.is_empty() {
|
||||
me.lsp_hover_state.clear();
|
||||
} else {
|
||||
let had_content = !segments.is_empty();
|
||||
let had_diagnostics = !diagnostics.is_empty();
|
||||
if let Some(server) = me.lsp_server.as_ref() {
|
||||
send_telemetry_from_ctx!(
|
||||
LspTelemetryEvent::HoverShown {
|
||||
server_type: server.as_ref(ctx).server_name(),
|
||||
had_content,
|
||||
had_diagnostics,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
let editor = me.editor().as_ref(ctx);
|
||||
|
||||
// Determine the offset range for positioning the tooltip.
|
||||
let offset_range = match hover_range {
|
||||
Some(range) => {
|
||||
let start = editor.lsp_location_to_offset(&range.start, ctx);
|
||||
let end = editor.lsp_location_to_offset(&range.end, ctx);
|
||||
// Rendering range is 0-based instead of 1-based.
|
||||
start.saturating_sub(&CharOffset::from(1))
|
||||
..end.saturating_sub(&CharOffset::from(1))
|
||||
}
|
||||
None => match editor.word_range_at_offset(offset, ctx) {
|
||||
Some(range) => {
|
||||
range.start.saturating_sub(&CharOffset::from(1))
|
||||
..range.end.saturating_sub(&CharOffset::from(1))
|
||||
}
|
||||
None => offset..offset + 1,
|
||||
},
|
||||
};
|
||||
|
||||
me.lsp_hover_state = LspHoverState::Loaded {
|
||||
segments,
|
||||
diagnostics,
|
||||
hovered_offset_range: offset_range,
|
||||
scroll_state: ClippedScrollStateHandle::default(),
|
||||
mouse_state: MouseStateHandle::default(),
|
||||
};
|
||||
}
|
||||
ctx.notify();
|
||||
})
|
||||
.abort_handle();
|
||||
|
||||
self.lsp_hover_state = LspHoverState::Loading(Some(abort_handle));
|
||||
}
|
||||
|
||||
pub(super) fn create_highlighted_code_fragment(
|
||||
language: String,
|
||||
code: String,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> HoverContentSegment {
|
||||
let view = ctx.add_typed_action_view(|ctx| {
|
||||
CodeEditorView::new(
|
||||
None,
|
||||
None,
|
||||
CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight),
|
||||
ctx,
|
||||
)
|
||||
.with_can_show_diff_ui(false)
|
||||
.with_show_line_numbers(false)
|
||||
});
|
||||
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.set_show_current_line_highlights(false, ctx);
|
||||
view.set_interaction_state(InteractionState::Selectable, ctx);
|
||||
let state = InitialBufferState::plain_text(code.trim());
|
||||
view.reset(state, ctx);
|
||||
view.set_language_with_name(&language, ctx);
|
||||
});
|
||||
|
||||
HoverContentSegment::CodeBlock { view }
|
||||
}
|
||||
|
||||
/// Creates hover content segments from parsed FormattedText lines.
|
||||
/// Code blocks are converted to CodeEditorViews for syntax highlighting,
|
||||
/// while other content is grouped into FormattedText segments.
|
||||
/// Consecutive code blocks with the same language are merged into a single view.
|
||||
pub(super) fn create_hover_content_segments(
|
||||
&mut self,
|
||||
content: HoverContents,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Vec<HoverContentSegment> {
|
||||
let mut pending = PendingSections::default();
|
||||
|
||||
for section in content.sections {
|
||||
let text = match section.kind {
|
||||
MarkupKind::Markdown => match markdown_parser::parse_markdown(§ion.value) {
|
||||
Ok(text) => text,
|
||||
Err(_) => FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(section.value),
|
||||
])]),
|
||||
},
|
||||
MarkupKind::PlainText => FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(section.value),
|
||||
])]),
|
||||
};
|
||||
|
||||
for line in text.lines {
|
||||
pending.push_formatted_line(line);
|
||||
}
|
||||
}
|
||||
|
||||
pending.flush(ctx)
|
||||
}
|
||||
|
||||
/// Render the LSP hover tooltip if hover state is available.
|
||||
pub(super) fn render_hover_tooltip(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
let (segments, diagnostics, scroll_state, mouse_state) = match &self.lsp_hover_state {
|
||||
LspHoverState::Loaded {
|
||||
segments,
|
||||
diagnostics,
|
||||
scroll_state,
|
||||
mouse_state,
|
||||
..
|
||||
} => (
|
||||
segments,
|
||||
diagnostics,
|
||||
scroll_state.clone(),
|
||||
mouse_state.clone(),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Don't show tooltip if there's no content.
|
||||
if segments.is_empty() && diagnostics.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Build content column with diagnostics first, then hover info.
|
||||
let mut content_column =
|
||||
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
let mut is_first = true;
|
||||
|
||||
// Render diagnostics first (if any).
|
||||
for diagnostic in diagnostics {
|
||||
if !is_first {
|
||||
content_column.add_child(Self::render_separator(theme));
|
||||
} else {
|
||||
is_first = false;
|
||||
}
|
||||
|
||||
content_column.add_child(Self::render_diagnostic(diagnostic, appearance));
|
||||
}
|
||||
|
||||
// Render hover info segments after diagnostics.
|
||||
for segment in segments {
|
||||
if !is_first {
|
||||
content_column.add_child(Self::render_separator(theme));
|
||||
} else {
|
||||
is_first = false;
|
||||
}
|
||||
match segment {
|
||||
HoverContentSegment::Text(formatted_text) => {
|
||||
// Render text content using FormattedTextElement.
|
||||
let text_element = FormattedTextElement::new(
|
||||
formatted_text.clone(),
|
||||
appearance.monospace_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_family(),
|
||||
theme.active_ui_text_color().into(),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.finish();
|
||||
content_column.add_child(text_element);
|
||||
}
|
||||
HoverContentSegment::CodeBlock { view, .. } => {
|
||||
// Render code block using the embedded CodeEditorView.
|
||||
let code_element = Container::new(ChildView::new(view).finish())
|
||||
.with_padding_top(4.)
|
||||
.with_horizontal_padding(8.)
|
||||
.finish();
|
||||
content_column.add_child(code_element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make content scrollable if it exceeds max height.
|
||||
let scrollable_content = ClippedScrollable::vertical(
|
||||
scroll_state,
|
||||
content_column.finish(),
|
||||
ScrollbarWidth::Auto,
|
||||
theme.disabled_ui_text_color().into(),
|
||||
theme.active_ui_text_color().into(),
|
||||
warpui::elements::Fill::None,
|
||||
)
|
||||
.finish();
|
||||
|
||||
let constrained_content = ConstrainedBox::new(scrollable_content)
|
||||
.with_width(HOVER_TOOLTIP_MAX_WIDTH)
|
||||
.with_max_height(HOVER_TOOLTIP_MAX_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let tooltip = Container::new(constrained_content)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(6.)
|
||||
.with_background(theme.background())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_border(Border::all(1.).with_border_fill(internal_colors::neutral_4(theme)))
|
||||
.finish();
|
||||
|
||||
// Wrap in Hoverable so we can track whether the mouse is over the tooltip.
|
||||
// This is used by LocalCodeEditorView to avoid clearing hover state when
|
||||
// the mouse moves over the tooltip itself.
|
||||
let hoverable_tooltip = Hoverable::new(mouse_state, |_| tooltip).finish();
|
||||
|
||||
Some(hoverable_tooltip)
|
||||
}
|
||||
|
||||
/// Render a separator line between hover card sections.
|
||||
fn render_separator(theme: &WarpTheme) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Rect::new()
|
||||
.with_background(internal_colors::neutral_2(theme))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(1.)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(4.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Render a diagnostic message with severity prefix.
|
||||
fn render_diagnostic(
|
||||
diagnostic: &ProcessedDiagnostic,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Create the diagnostic text with bold severity prefix.
|
||||
let severity_text = match diagnostic.severity {
|
||||
lsp_types::DiagnosticSeverity::ERROR => "Error",
|
||||
lsp_types::DiagnosticSeverity::WARNING => "Warning",
|
||||
lsp_types::DiagnosticSeverity::INFORMATION => "Info",
|
||||
lsp_types::DiagnosticSeverity::HINT => "Hint",
|
||||
_ => "Diagnostic",
|
||||
};
|
||||
|
||||
let text = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::bold(format!("{severity_text}: ")),
|
||||
FormattedTextFragment::plain_text(&diagnostic.message),
|
||||
])]);
|
||||
|
||||
// Use error or warning color for the entire diagnostic text.
|
||||
let text_color = match diagnostic.severity {
|
||||
lsp_types::DiagnosticSeverity::ERROR => theme.ui_error_color(),
|
||||
lsp_types::DiagnosticSeverity::WARNING => theme.ui_warning_color(),
|
||||
_ => theme.active_ui_text_color().into_solid(),
|
||||
};
|
||||
|
||||
FormattedTextElement::new(
|
||||
text,
|
||||
appearance.monospace_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_family(),
|
||||
text_color,
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Periodically shuts down idle LSP servers.
|
||||
//!
|
||||
//! Every `SCAN_INTERVAL` this singleton scans for workspaces that have a running LSP server
|
||||
//! but no associated:
|
||||
//! 1. Active local `TerminalView` whose repo root matches the workspace root.
|
||||
//! 2. Open `LocalCodeEditorView` with a file that belongs to that workspace root.
|
||||
//!
|
||||
//! For those workspaces, we call `LspManagerModel::stop` to tear down the LSP server.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::stream::AbortHandle;
|
||||
use lsp::LspManagerModel;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::code::local_code_editor::LocalCodeEditorView;
|
||||
use crate::terminal::TerminalView;
|
||||
|
||||
const SCAN_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
pub struct LanguageServerShutdownManager {
|
||||
in_progress_scan: Option<AbortHandle>,
|
||||
}
|
||||
|
||||
impl LanguageServerShutdownManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
in_progress_scan: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_in_progress_scan(&self) -> bool {
|
||||
self.in_progress_scan.is_some()
|
||||
}
|
||||
|
||||
pub fn schedule_next_scan(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(scan) = self.in_progress_scan.take() {
|
||||
scan.abort();
|
||||
}
|
||||
|
||||
self.in_progress_scan = Some(
|
||||
ctx.spawn(
|
||||
async {
|
||||
Timer::after(SCAN_INTERVAL).await;
|
||||
},
|
||||
|me, _, ctx| {
|
||||
if me.scan_and_shutdown_unused_servers(ctx) {
|
||||
me.schedule_next_scan(ctx);
|
||||
} else {
|
||||
me.in_progress_scan = None;
|
||||
}
|
||||
},
|
||||
)
|
||||
.abort_handle(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Scans for unused LSP servers and shuts them down.
|
||||
///
|
||||
/// Returns `true` if there are still active workspace roots remaining (indicating more scans
|
||||
/// may be needed), or `false` if all workspace roots were shut down or there were no roots.
|
||||
fn scan_and_shutdown_unused_servers(&self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
let lsp_manager_handle = LspManagerModel::handle(ctx);
|
||||
let workspace_roots: Vec<PathBuf> = lsp_manager_handle
|
||||
.as_ref(ctx)
|
||||
.workspace_roots()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if workspace_roots.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut unused_roots = Vec::new();
|
||||
for root in &workspace_roots {
|
||||
if !workspace_root_in_use(root, ctx) {
|
||||
unused_roots.push(root);
|
||||
}
|
||||
}
|
||||
|
||||
// There will be remaining active roots if not all of the workspace roots are unused.
|
||||
let has_active_roots = unused_roots.len() < workspace_roots.len();
|
||||
|
||||
if unused_roots.is_empty() {
|
||||
return has_active_roots;
|
||||
}
|
||||
|
||||
// Stop servers for all workspaces that are no longer in use.
|
||||
lsp_manager_handle.update(ctx, |manager, m_ctx| {
|
||||
for root in unused_roots {
|
||||
log::info!("Stopping unused LSP for workspace {}", root.display());
|
||||
manager.stop_all(root.clone(), m_ctx);
|
||||
}
|
||||
});
|
||||
|
||||
has_active_roots
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_root_in_use(root: &Path, app: &AppContext) -> bool {
|
||||
has_terminal_for_workspace(root, app) || has_open_file_for_workspace(root, app)
|
||||
}
|
||||
|
||||
fn has_terminal_for_workspace(root: &Path, app: &AppContext) -> bool {
|
||||
for window_id in app.window_ids() {
|
||||
if let Some(terminals) = app.views_of_type::<TerminalView>(window_id) {
|
||||
for terminal in terminals {
|
||||
let Some(pwd) = terminal.as_ref(app).pwd_if_local(app) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(cwd) = PathBuf::from(pwd).canonicalize() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if cwd.starts_with(root) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn has_open_file_for_workspace(root: &Path, app: &AppContext) -> bool {
|
||||
for window_id in app.window_ids() {
|
||||
if let Some(editors) = app.views_of_type::<LocalCodeEditorView>(window_id) {
|
||||
for editor in editors {
|
||||
let editor_ref = editor.as_ref(app);
|
||||
|
||||
if !editor_ref.language_server_enabled() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(path) = editor_ref.file_path() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if path.starts_with(root) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
impl Entity for LanguageServerShutdownManager {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for LanguageServerShutdownManager {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use warp_editor::{content::buffer::InitialBufferState, render::model::LineCount};
|
||||
use warp_util::file::{FileLoadError, FileSaveError};
|
||||
use warpui::{
|
||||
elements::MouseStateHandle, AppContext, Element, Entity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use ai::diff_validation::DiffType;
|
||||
|
||||
use super::editor::view::CodeEditorView;
|
||||
use super::ImmediateSaveError;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::{code::editor::EditorReviewComment, code_review::comments::CommentId};
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
|
||||
pub use super::diff_viewer::DisplayMode;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LocalCodeEditorEvent {
|
||||
#[allow(dead_code)]
|
||||
FileLoaded,
|
||||
#[allow(dead_code)]
|
||||
FailedToLoad { error: Rc<FileLoadError> },
|
||||
#[allow(dead_code)]
|
||||
FileSaved,
|
||||
#[allow(dead_code)]
|
||||
FailedToSave { error: Rc<FileSaveError> },
|
||||
#[allow(dead_code)]
|
||||
DiffAccepted,
|
||||
#[allow(dead_code)]
|
||||
DiffRejected,
|
||||
#[allow(dead_code)]
|
||||
VimMinimizeRequested,
|
||||
#[allow(dead_code)]
|
||||
UserEdited,
|
||||
#[allow(dead_code)]
|
||||
DiffStatusUpdated,
|
||||
#[allow(dead_code)]
|
||||
SelectionAddedAsContext {
|
||||
relative_file_path: String,
|
||||
line_range: Range<LineCount>,
|
||||
selected_text: String,
|
||||
},
|
||||
#[allow(dead_code)]
|
||||
DiscardUnsavedChanges { path: PathBuf },
|
||||
#[allow(dead_code)]
|
||||
CommentSaved { comment: EditorReviewComment },
|
||||
#[allow(dead_code)]
|
||||
DeleteComment { id: CommentId },
|
||||
#[allow(dead_code)]
|
||||
RequestOpenComment(CommentId),
|
||||
#[allow(dead_code)]
|
||||
ViewportUpdated,
|
||||
#[allow(dead_code)]
|
||||
DelayedRenderingFlushed,
|
||||
#[allow(dead_code)]
|
||||
LayoutInvalidated,
|
||||
}
|
||||
|
||||
pub struct LocalCodeEditorView {
|
||||
editor: ViewHandle<CodeEditorView>,
|
||||
}
|
||||
|
||||
impl LocalCodeEditorView {
|
||||
pub fn new(
|
||||
editor: ViewHandle<CodeEditorView>,
|
||||
_diff_type: Option<DiffType>,
|
||||
_enable_diff_nav_by_default: bool,
|
||||
_display_mode: Option<DisplayMode>,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
Self { editor }
|
||||
}
|
||||
|
||||
pub fn with_selection_as_context(self, _terminal_target_fn: Box<TerminalTargetFn>) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reset_with_state(&mut self, _state: InitialBufferState, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
pub fn editor(&self) -> &ViewHandle<CodeEditorView> {
|
||||
&self.editor
|
||||
}
|
||||
|
||||
pub fn save_local(&self, _ctx: &mut ViewContext<Self>) -> Result<(), ImmediateSaveError> {
|
||||
Err(ImmediateSaveError::NoFileId)
|
||||
}
|
||||
|
||||
pub fn has_unsaved_changes(&self, _ctx: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn file_path(&self) -> Option<&Path> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for LocalCodeEditorView {
|
||||
type Event = LocalCodeEditorEvent;
|
||||
}
|
||||
|
||||
impl View for LocalCodeEditorView {
|
||||
fn ui_name() -> &'static str {
|
||||
"LocalCodeEditorView"
|
||||
}
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
warpui::elements::Empty::new().finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for LocalCodeEditorView {
|
||||
type Action = ();
|
||||
}
|
||||
|
||||
type TerminalTargetFn = dyn Fn(WindowId, &AppContext) -> Option<ViewHandle<TerminalView>>;
|
||||
|
||||
pub fn render_unsaved_circle_with_tooltip(
|
||||
_mouse_state: MouseStateHandle,
|
||||
_tooltip_text: String,
|
||||
_size: f32,
|
||||
_right_margin: f32,
|
||||
_appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
warpui::elements::Empty::new().finish()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use lsp::supported_servers::LSPServerType;
|
||||
use sha2::{Digest, Sha256};
|
||||
use simple_logger::manager::resolve_log_path;
|
||||
|
||||
/// Returns the relative log path (within the LSP log directory) for an LSP server.
|
||||
/// For example, `rust-analyzer/12345678.log`.
|
||||
pub fn relative_log_path(server_type: LSPServerType, workspace_path: &Path) -> PathBuf {
|
||||
let server_type_name = server_type.binary_name();
|
||||
let workspace_hash = hash_workspace_path(workspace_path);
|
||||
|
||||
PathBuf::from(server_type_name).join(format!("{workspace_hash}.log"))
|
||||
}
|
||||
|
||||
/// Returns the path to the log file for an LSP server.
|
||||
///
|
||||
/// Format: `{secure_state_dir}/lsp/{server_type}/{workspace_hash}.log`
|
||||
///
|
||||
/// The workspace path is hashed to avoid filesystem issues with long or special character paths.
|
||||
pub fn log_file_path(server_type: LSPServerType, workspace_path: &Path) -> PathBuf {
|
||||
resolve_log_path("lsp", relative_log_path(server_type, workspace_path))
|
||||
}
|
||||
|
||||
/// Hashes the workspace path to create a filesystem-safe identifier.
|
||||
/// Uses first 16 characters of SHA256 hex digest (64 bits of entropy).
|
||||
fn hash_workspace_path(path: &Path) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(path.to_string_lossy().as_bytes());
|
||||
let result = hasher.finalize();
|
||||
// Take first 8 bytes (16 hex chars) for a shorter but still unique identifier
|
||||
hex::encode(&result[..8])
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
/// The source from which the user enabled an LSP server.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum LspEnablementSource {
|
||||
#[serde(rename = "init_flow")]
|
||||
InitFlow,
|
||||
#[serde(rename = "footer_button")]
|
||||
FooterButton,
|
||||
#[serde(rename = "settings")]
|
||||
Settings,
|
||||
}
|
||||
|
||||
/// The control action the user performed on an LSP server.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum LspControlActionType {
|
||||
#[serde(rename = "open_logs")]
|
||||
OpenLogs,
|
||||
#[serde(rename = "restart")]
|
||||
Restart,
|
||||
#[serde(rename = "stop")]
|
||||
Stop,
|
||||
#[serde(rename = "start")]
|
||||
Start,
|
||||
#[serde(rename = "restart_all")]
|
||||
RestartAll,
|
||||
#[serde(rename = "stop_all")]
|
||||
StopAll,
|
||||
}
|
||||
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub enum LspTelemetryEvent {
|
||||
/// User enabled an LSP server for a workspace.
|
||||
ServerEnabled {
|
||||
server_type: String,
|
||||
source: LspEnablementSource,
|
||||
needed_install: bool,
|
||||
},
|
||||
/// User skipped LSP enablement during /init.
|
||||
ServerEnablementSkipped,
|
||||
/// An LSP server installation finished (success or failure).
|
||||
ServerInstallCompleted { server_type: String, success: bool },
|
||||
/// User removed an LSP server.
|
||||
ServerRemoved {
|
||||
server_type: String,
|
||||
source: LspEnablementSource,
|
||||
},
|
||||
/// Hover tooltip displayed with content.
|
||||
HoverShown {
|
||||
server_type: String,
|
||||
had_content: bool,
|
||||
had_diagnostics: bool,
|
||||
},
|
||||
/// User triggered goto definition.
|
||||
GotoDefinition {
|
||||
server_type: String,
|
||||
had_result: bool,
|
||||
},
|
||||
/// Find references card displayed.
|
||||
FindReferencesShown {
|
||||
server_type: String,
|
||||
num_references: usize,
|
||||
},
|
||||
/// User performed an LSP control action from the footer menu.
|
||||
ControlAction {
|
||||
action: LspControlActionType,
|
||||
server_type: Option<String>,
|
||||
},
|
||||
/// Server successfully started and is available.
|
||||
ServerStarted { server_type: String },
|
||||
/// Server failed to start.
|
||||
ServerFailed { server_type: String },
|
||||
}
|
||||
|
||||
impl TelemetryEvent for LspTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
LspTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
LspTelemetryEvent::ServerEnabled {
|
||||
server_type,
|
||||
source,
|
||||
needed_install,
|
||||
} => Some(json!({
|
||||
"server_type": server_type,
|
||||
"source": source,
|
||||
"needed_install": needed_install,
|
||||
})),
|
||||
LspTelemetryEvent::ServerEnablementSkipped => None,
|
||||
LspTelemetryEvent::ServerInstallCompleted {
|
||||
server_type,
|
||||
success,
|
||||
} => Some(json!({
|
||||
"server_type": server_type,
|
||||
"success": success,
|
||||
})),
|
||||
LspTelemetryEvent::ServerRemoved {
|
||||
server_type,
|
||||
source,
|
||||
} => Some(json!({
|
||||
"server_type": server_type,
|
||||
"source": source,
|
||||
})),
|
||||
LspTelemetryEvent::HoverShown {
|
||||
server_type,
|
||||
had_content,
|
||||
had_diagnostics,
|
||||
} => Some(json!({
|
||||
"server_type": server_type,
|
||||
"had_content": had_content,
|
||||
"had_diagnostics": had_diagnostics,
|
||||
})),
|
||||
LspTelemetryEvent::GotoDefinition {
|
||||
server_type,
|
||||
had_result,
|
||||
} => Some(json!({
|
||||
"server_type": server_type,
|
||||
"had_result": had_result,
|
||||
})),
|
||||
LspTelemetryEvent::FindReferencesShown {
|
||||
server_type,
|
||||
num_references,
|
||||
} => Some(json!({
|
||||
"server_type": server_type,
|
||||
"num_references": num_references,
|
||||
})),
|
||||
LspTelemetryEvent::ControlAction {
|
||||
action,
|
||||
server_type,
|
||||
} => Some(json!({
|
||||
"action": action,
|
||||
"server_type": server_type,
|
||||
})),
|
||||
LspTelemetryEvent::ServerStarted { server_type } => Some(json!({
|
||||
"server_type": server_type,
|
||||
})),
|
||||
LspTelemetryEvent::ServerFailed { server_type } => Some(json!({
|
||||
"server_type": server_type,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
LspTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
LspTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for LspTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ServerEnabled => "Lsp.ServerEnabled",
|
||||
Self::ServerEnablementSkipped => "Lsp.ServerEnablementSkipped",
|
||||
Self::ServerInstallCompleted => "Lsp.ServerInstallCompleted",
|
||||
Self::ServerRemoved => "Lsp.ServerRemoved",
|
||||
Self::HoverShown => "Lsp.HoverShown",
|
||||
Self::GotoDefinition => "Lsp.GotoDefinition",
|
||||
Self::FindReferencesShown => "Lsp.FindReferencesShown",
|
||||
Self::ControlAction => "Lsp.ControlAction",
|
||||
Self::ServerStarted => "Lsp.ServerStarted",
|
||||
Self::ServerFailed => "Lsp.ServerFailed",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ServerEnabled => "User enabled an LSP server for a workspace",
|
||||
Self::ServerEnablementSkipped => "User skipped LSP enablement during /init",
|
||||
Self::ServerInstallCompleted => "An LSP server installation finished",
|
||||
Self::ServerRemoved => "User removed an LSP server",
|
||||
Self::HoverShown => "Hover tooltip displayed with LSP content or diagnostics",
|
||||
Self::GotoDefinition => "User triggered goto definition via LSP",
|
||||
Self::FindReferencesShown => "Find references card displayed via LSP",
|
||||
Self::ControlAction => "User performed an LSP control action from the footer menu",
|
||||
Self::ServerStarted => "LSP server successfully started and is available",
|
||||
Self::ServerFailed => "LSP server failed to start",
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
EnablementState::Always
|
||||
}
|
||||
}
|
||||
|
||||
warp_core::register_telemetry_event!(LspTelemetryEvent);
|
||||
@@ -0,0 +1,157 @@
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::AddAssign;
|
||||
use warp_util::file::FileSaveError;
|
||||
use warpui::elements::DropTargetData;
|
||||
use warpui::AppContext;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod find_references_view;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod language_server_extension;
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "local_code_editor.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "local_code_editor_wasm.rs")]
|
||||
pub mod local_code_editor;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use local_code_editor::ShowFindReferencesCard;
|
||||
pub mod diff_viewer;
|
||||
pub mod editor;
|
||||
pub mod editor_management;
|
||||
pub mod global_buffer_model;
|
||||
pub mod inline_diff;
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub mod language_server_shutdown_manager;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod lsp_logs;
|
||||
pub mod lsp_telemetry;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub enum ImmediateSaveError {
|
||||
#[error("No FileId")]
|
||||
NoFileId,
|
||||
#[error("failed to save file: {0:#}")]
|
||||
FailedToSave(#[from] FileSaveError),
|
||||
#[error("There is no file tab currently selected")]
|
||||
NoActiveFileTab,
|
||||
}
|
||||
|
||||
/// Trait to determine whether we should show the comment editor based on state held
|
||||
/// by the parent of the [`CodeEditorView`].
|
||||
pub trait ShowCommentEditorProvider: Debug + 'static {
|
||||
/// Returns whether the comment editor should be shown given the location of the line where
|
||||
/// the editor would be shown.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn should_show_comment_editor(&self, editor_line_location: RectF, app: &AppContext) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoopCommentEditorProvider;
|
||||
|
||||
impl ShowCommentEditorProvider for NoopCommentEditorProvider {
|
||||
fn should_show_comment_editor(&self, _editor_line_location: RectF, _app: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait to determine whether we should show the find references card based on state held
|
||||
/// by the parent of the [`CodeEditorView`].
|
||||
pub trait ShowFindReferencesCardProvider: Debug + 'static {
|
||||
/// Returns whether the find references card should be shown given the location of the anchor
|
||||
/// point where the card would be positioned.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn should_show_find_references_card(
|
||||
&self,
|
||||
card_anchor_location: RectF,
|
||||
app: &AppContext,
|
||||
) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NoopFindReferencesCardProvider;
|
||||
|
||||
impl ShowFindReferencesCardProvider for NoopFindReferencesCardProvider {
|
||||
fn should_show_find_references_card(
|
||||
&self,
|
||||
_card_anchor_location: RectF,
|
||||
_app: &AppContext,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
#[derive(Debug)]
|
||||
pub enum SaveStatus {
|
||||
/// Save completed immediately and successfully.
|
||||
SavedImmediately,
|
||||
/// Save operation is in progress asynchronously (e.g., save-as dialog).
|
||||
AsyncSaveInProgress,
|
||||
/// Save failed with an error.
|
||||
Failed(#[allow(unused)] ImmediateSaveError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
pub enum SaveOutcome {
|
||||
Canceled,
|
||||
Failed,
|
||||
Succeeded,
|
||||
}
|
||||
|
||||
pub mod file_tree;
|
||||
pub mod footer;
|
||||
mod icon;
|
||||
|
||||
pub mod active_file;
|
||||
pub mod opened_files;
|
||||
pub use icon::icon_from_file_path;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "view.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "wasm.rs")]
|
||||
pub mod view;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
self::view::init(app);
|
||||
self::file_tree::init(app);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
self::find_references_view::init(app);
|
||||
}
|
||||
|
||||
/// The diff that results from editing a file.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct DiffResult {
|
||||
/// The changes in unified diff format.
|
||||
pub unified_diff: String,
|
||||
/// Number of lines added.
|
||||
pub lines_added: usize,
|
||||
/// Number of lines removed.
|
||||
pub lines_removed: usize,
|
||||
}
|
||||
|
||||
impl AddAssign<&DiffResult> for DiffResult {
|
||||
fn add_assign(&mut self, other: &DiffResult) {
|
||||
self.lines_added += other.lines_added;
|
||||
self.lines_removed += other.lines_removed;
|
||||
|
||||
// There's not a standardized multi-file diff format, but concatenating the diffs is enough
|
||||
// for our needs: https://en.wikipedia.org/wiki/Diff#Extensions
|
||||
if !self.unified_diff.is_empty() && !other.unified_diff.is_empty() {
|
||||
self.unified_diff.push('\n');
|
||||
}
|
||||
self.unified_diff.push_str(&other.unified_diff);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
pub struct EditorTabBarDropTargetData {
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl DropTargetData for EditorTabBarDropTargetData {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Module containing the definition of [`OpenedFilesModel`],
|
||||
//! which tracks files that have been opened, organized by repository.
|
||||
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use instant::Instant;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct OpenedFilesInRepo(HashMap<PathBuf, Instant>);
|
||||
|
||||
impl OpenedFilesInRepo {
|
||||
pub fn get(&self, file_path: &PathBuf) -> Option<&Instant> {
|
||||
self.0.get(file_path)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &Instant)> {
|
||||
self.0.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Model that tracks files that have been opened, organized by repository.
|
||||
/// Maps repository paths to files and when they were last opened.
|
||||
#[derive(Default)]
|
||||
pub struct OpenedFilesModel {
|
||||
opened_files: HashMap<PathBuf, OpenedFilesInRepo>,
|
||||
}
|
||||
|
||||
impl Entity for OpenedFilesModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for OpenedFilesModel {}
|
||||
|
||||
impl OpenedFilesModel {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Get all opened files for a specific repository.
|
||||
pub fn opened_files_for_repo(&self, repo_path: &PathBuf) -> Option<&OpenedFilesInRepo> {
|
||||
self.opened_files.get(repo_path)
|
||||
}
|
||||
|
||||
/// Record that a file has been opened in a repository. If the `file_path` is not within the `repo_path`,
|
||||
/// then the file is not recorded.
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn file_opened(
|
||||
&mut self,
|
||||
repo_path: PathBuf,
|
||||
file_path: PathBuf,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let opened_at = Instant::now();
|
||||
|
||||
// Convert absolute file path to relative path from repo root
|
||||
let Ok(relative_file_path) = file_path.strip_prefix(&repo_path) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.opened_files
|
||||
.entry(repo_path.clone())
|
||||
.or_default()
|
||||
.0
|
||||
.insert(relative_file_path.into(), opened_at);
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::{
|
||||
elements::{DraggableState, Empty, MouseStateHandle},
|
||||
AppContext, Element, Entity, ModelHandle, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use super::{editor_management::CodeSource, local_code_editor::LocalCodeEditorView};
|
||||
use crate::pane_group::{
|
||||
focus_state::PaneFocusHandle,
|
||||
pane::view::{HeaderContent, HeaderRenderContext},
|
||||
BackingView, CodePane, PaneConfiguration, PaneEvent,
|
||||
};
|
||||
use ai::diff_validation::DiffDelta;
|
||||
|
||||
// Keybinding constants - exported so AI document view can reuse
|
||||
pub const SAVE_FILE_BINDING_NAME: &str = "code_view:save";
|
||||
pub const SAVE_FILE_BINDING_DESCRIPTION: &str = "Save file";
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn is_supported_code_file(_path: impl AsRef<Path>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub fn is_binary_file(_path: impl AsRef<Path>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn init(_app: &mut AppContext) {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CodeViewAction {
|
||||
RemoveTabAtIndex { index: usize },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(unused)]
|
||||
pub enum CodeViewEvent {
|
||||
Pane(PaneEvent),
|
||||
TabChanged {
|
||||
file_path: Option<PathBuf>,
|
||||
tab_index: usize,
|
||||
},
|
||||
FileOpened {
|
||||
file_path: PathBuf,
|
||||
tab_index: usize,
|
||||
},
|
||||
RunTabConfigSkill {
|
||||
path: PathBuf,
|
||||
},
|
||||
OpenLspLogs {
|
||||
log_path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PendingSaveIntent {
|
||||
Save,
|
||||
Discard,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Debug, Clone)]
|
||||
enum TabBarDragPosition {
|
||||
BeforeTab,
|
||||
AfterTab,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Default, Clone)]
|
||||
struct TabDataMouseStateHandles {
|
||||
tab_handle: MouseStateHandle,
|
||||
close_handle: MouseStateHandle,
|
||||
accept_mouse_state: MouseStateHandle,
|
||||
reject_mouse_state: MouseStateHandle,
|
||||
tab_draggable_state: DraggableState,
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Clone)]
|
||||
pub struct TabData {
|
||||
path: Option<PathBuf>,
|
||||
editor_view: ViewHandle<LocalCodeEditorView>,
|
||||
mouse_state_handles: TabDataMouseStateHandles,
|
||||
drag_position: Option<TabBarDragPosition>,
|
||||
}
|
||||
|
||||
impl TabData {
|
||||
pub fn path(&self) -> Option<PathBuf> {
|
||||
self.path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CodeView {
|
||||
pane_configuration: ModelHandle<PaneConfiguration>,
|
||||
source: CodeSource,
|
||||
}
|
||||
|
||||
impl CodeView {
|
||||
pub fn new(
|
||||
source: CodeSource,
|
||||
_line_col: Option<LineAndColumnArg>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new(""));
|
||||
|
||||
Self {
|
||||
pane_configuration,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tab_at(&self, _index: usize) -> Option<&TabData> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn tab_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
pub fn active_tab_index(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
pub fn source(&self) -> &CodeSource {
|
||||
&self.source
|
||||
}
|
||||
|
||||
pub fn open_or_focus_existing(
|
||||
&mut self,
|
||||
path: Option<PathBuf>,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(path) = path {
|
||||
self.open_local(None, path, line_col, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_local(
|
||||
&mut self,
|
||||
_diffs: Option<Vec<DiffDelta>>,
|
||||
_path: impl Into<PathBuf>,
|
||||
_line_col: Option<LineAndColumnArg>,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
pub fn local_path(&self, _ctx: &AppContext) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn focus(&self, _ctx: &mut ViewContext<Self>) {}
|
||||
|
||||
pub fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
|
||||
self.pane_configuration.clone()
|
||||
}
|
||||
|
||||
pub fn contains_unsaved_changes(&self, _ctx: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn active_tab_has_unsaved_changes(&self, _ctx: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn close_overlays(&mut self, _ctx: &mut ViewContext<Self>) {
|
||||
// Not yet implemented
|
||||
}
|
||||
|
||||
pub fn remove_tab_for_move(
|
||||
&mut self,
|
||||
_index: usize,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) -> Option<CodePane> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CodeView {
|
||||
type Event = CodeViewEvent;
|
||||
}
|
||||
|
||||
impl View for CodeView {
|
||||
fn ui_name() -> &'static str {
|
||||
"CodeView"
|
||||
}
|
||||
|
||||
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
|
||||
Empty::new().finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for CodeView {
|
||||
type Action = CodeViewAction;
|
||||
|
||||
fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext<Self>) {}
|
||||
}
|
||||
|
||||
impl BackingView for CodeView {
|
||||
type PaneHeaderOverflowMenuAction = CodeViewAction;
|
||||
type CustomAction = ();
|
||||
type AssociatedData = ();
|
||||
|
||||
fn handle_pane_header_overflow_menu_action(
|
||||
&mut self,
|
||||
_action: &Self::PaneHeaderOverflowMenuAction,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
fn close(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(CodeViewEvent::Pane(PaneEvent::Close));
|
||||
}
|
||||
|
||||
fn focus_contents(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
fn handle_custom_action(
|
||||
&mut self,
|
||||
_custom_action: &Self::CustomAction,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
}
|
||||
|
||||
fn render_header_content(
|
||||
&self,
|
||||
_ctx: &HeaderRenderContext<'_>,
|
||||
app: &AppContext,
|
||||
) -> HeaderContent {
|
||||
HeaderContent::simple(self.pane_configuration.as_ref(app).title())
|
||||
}
|
||||
|
||||
fn set_focus_handle(&mut self, _handle: PaneFocusHandle, _ctx: &mut ViewContext<Self>) {}
|
||||
}
|
||||
Reference in New Issue
Block a user