Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+45
View File
@@ -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();
}
}
}
+162
View File
@@ -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(())
}
}
+562
View File
@@ -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;
+158
View File
@@ -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
);
});
}
+98
View File
@@ -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(()),
}
}
}
+821
View File
@@ -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;
+456
View File
@@ -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
}
}
}
}
+241
View File
@@ -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());
}
+1
View File
@@ -0,0 +1 @@
pub mod view;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
pub mod view;
+200
View File
@@ -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()
}
}
+79
View File
@@ -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)
}
}
}
}
+38
View File
@@ -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))
}
}
+22
View File
@@ -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
+333
View File
@@ -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);
});
}
}
}
}
+51
View File
@@ -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
+90
View File
@@ -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");
});
}
+937
View File
@@ -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
+286
View File
@@ -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 {}
+8
View File
@@ -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::*;
+368
View File
@@ -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);
}
}
}
}
+436
View File
@@ -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
+292
View File
@@ -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(&current_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));
}
+73
View File
@@ -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,
}
+987
View File
@@ -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));
});
});
});
}
+699
View File
@@ -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
+158
View File
@@ -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)
}
+356
View File
@@ -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 = ();
}
+630
View File
@@ -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(&section.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
+132
View File
@@ -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()
}
+33
View File
@@ -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])
}
+203
View File
@@ -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);
+157
View File
@@ -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
}
}
+69
View File
@@ -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();
}
}
+2278
View File
File diff suppressed because it is too large Load Diff
+239
View File
@@ -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>) {}
}