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