first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+15 -24
View File
@@ -1,26 +1,17 @@
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use crate::{
ai::document::ai_document_model::AIDocumentId,
cloud_object::{
breadcrumbs::ContainingObject,
model::{
persistence::{CloudModel, CloudModelEvent},
view::{CloudViewModel, Editor, EditorState},
},
CloudObject, Owner, Space,
},
drive::sharing::{ContentEditability, SharingAccessLevel},
notebooks::CloudNotebook,
server::{
cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
},
ids::{ClientId, SyncId},
},
};
use super::{CloudNotebookModel, NotebookId};
use crate::ai::document::ai_document_model::AIDocumentId;
use crate::cloud_object::breadcrumbs::ContainingObject;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::model::view::{CloudViewModel, Editor, EditorState};
use crate::cloud_object::{CloudObject, Owner, Space};
use crate::drive::sharing::{ContentEditability, SharingAccessLevel};
use crate::notebooks::CloudNotebook;
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
};
use crate::server::ids::{ClientId, SyncId};
#[derive(Default, Clone)]
pub enum ActiveNotebook {
@@ -68,12 +59,12 @@ impl ActiveNotebookData {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, event, ctx| {
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
me.handle_update_manager_event(event, ctx);
});
let cloud_model = CloudModel::handle(ctx);
ctx.subscribe_to_model(&cloud_model, |me, event, ctx| {
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| {
me.handle_cloud_model_event(event, ctx);
});
@@ -226,7 +217,7 @@ impl ActiveNotebookData {
// create a new client id
let new_id = ClientId::default();
// Set the active notebook to be an uncommited notebook
// Set the active notebook to be an uncommitted notebook
self.active_notebook = ActiveNotebook::NewNotebook(Box::new(CloudNotebook::new_local(
CloudNotebookModel::default(),
owner,
@@ -311,7 +302,7 @@ impl ActiveNotebookData {
/// Checks whether or not this notebook has edit conflicts that would
/// results in the conflict resolution banner being shown. We check both
/// if a conflicting object has been recieved from the server, and that there
/// if a conflicting object has been received from the server, and that there
/// are no pending content changes on the notebook.
///
/// We need to check the pending content changes because of a race condition where
+14 -17
View File
@@ -1,24 +1,21 @@
//! Shared context menu implementation for notebooks.
use galaxy_core::context_flag::ContextFlag;
use galaxyui::{
elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack},
keymap::Trigger,
presenter::ChildView,
Action, Element, EventContext, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_geometry::vector::Vector2F;
use galaxy_core::context_flag::ContextFlag;
use galaxyui::elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack};
use galaxyui::keymap::Trigger;
use galaxyui::presenter::ChildView;
use galaxyui::{Action, Element, EventContext, TypedActionView, View, ViewContext, ViewHandle};
use crate::{
editor::EditorView,
menu::{self, Menu, MenuItem, MenuItemFields},
pane_group::{focus_state::PaneFocusHandle, PaneEvent, SplitPaneState},
util::bindings::{keybinding_name_to_display_string, trigger_to_keystroke, CustomAction},
};
use super::{
editor::{keys::custom_action_to_display, view::RichTextEditorView},
telemetry::ActionEntrypoint,
use super::editor::keys::custom_action_to_display;
use super::editor::view::RichTextEditorView;
use super::telemetry::ActionEntrypoint;
use crate::editor::EditorView;
use crate::menu::{self, Menu, MenuItem, MenuItemFields};
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::{PaneEvent, SplitPaneState};
use crate::util::bindings::{
keybinding_name_to_display_string, trigger_to_keystroke, CustomAction,
};
#[cfg(test)]
+19 -19
View File
@@ -3,31 +3,31 @@ use galaxy_editor::model::CoreEditorModel;
use galaxyui::{platform::WindowStyle, App};
use pathfinder_geometry::vector::vec2f;
use string_offset::ByteOffset;
use crate::search::files::model::FileSearchModel;
use galaxyui::platform::WindowStyle;
use galaxyui::App;
use super::MenuSource;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::model::view::CloudViewModel;
use crate::editor::InteractionState;
use crate::network::NetworkStatus;
use crate::notebooks::editor::keys::NotebookKeybindings;
use crate::notebooks::notebook::NotebookView;
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusState};
use crate::pane_group::{BackingView as _, PaneId};
use crate::search::files::model::FileSearchModel;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::keys::TerminalKeybindings;
use crate::{
cloud_object::model::{persistence::CloudModel, view::CloudViewModel},
editor::InteractionState,
network::NetworkStatus,
notebooks::{editor::keys::NotebookKeybindings, notebook::NotebookView},
server::{
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
sync_queue::SyncQueue,
},
settings_view::keybindings::KeybindingChangedNotifier,
test_util::settings::initialize_settings_for_tests,
workspace::ActiveSession,
workspaces::{
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
},
GlobalResourceHandles, GlobalResourceHandlesProvider,
};
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspace::ActiveSession;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_profiles::UserProfiles;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
@@ -1,41 +1,32 @@
use std::sync::Arc;
use galaxy_editor::content::text::BufferBlockItem;
use galaxyui::{
elements::{
AnchorPair, Border, Container, CornerRadius, MouseStateHandle, OffsetPositioning,
OffsetType, PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Stack,
XAxisAnchor, YAxisAnchor,
},
presenter::ChildView,
ui_components::{
button::ButtonTooltipPosition,
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, SingletonEntity, ViewContext, ViewHandle,
};
use serde::{Deserialize, Serialize};
use crate::{
appearance::Appearance,
cloud_object::{model::persistence::CloudModel, ObjectIdType, Space},
drive::CloudObjectTypeAndId,
menu::{self, Menu, MenuItemFields},
notebooks::telemetry::EmbeddedObjectInfo,
search::notebook_embedding::{
searcher::EmbeddingSearchItemAction,
view::{EmbeddingSearchEvent, EmbeddingSearchMenu},
},
server::ids::SyncId,
themes::theme::Fill,
ui_components::{buttons::icon_button, icons::Icon},
use galaxy_editor::content::text::BufferBlockItem;
use galaxyui::elements::{
AnchorPair, Border, Container, CornerRadius, MouseStateHandle, OffsetPositioning, OffsetType,
PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Stack, XAxisAnchor,
YAxisAnchor,
};
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::button::ButtonTooltipPosition;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext, ViewHandle};
use super::{
embedded_item::EmbeddedWorkflow,
view::{EditorViewAction, EditorViewEvent, RichTextEditorView},
BlockType,
};
use super::embedded_item::EmbeddedWorkflow;
use super::view::{EditorViewAction, EditorViewEvent, RichTextEditorView};
use super::BlockType;
use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{ObjectIdType, Space};
use crate::drive::CloudObjectTypeAndId;
use crate::menu::{self, Menu, MenuItemFields};
use crate::notebooks::telemetry::EmbeddedObjectInfo;
use crate::search::notebook_embedding::searcher::EmbeddingSearchItemAction;
use crate::search::notebook_embedding::view::{EmbeddingSearchEvent, EmbeddingSearchMenu};
use crate::server::ids::SyncId;
use crate::themes::theme::Fill;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
/// The saved position ID for the block insertion button.
const BLOCK_INSERT_BUTTON_ID: &str = "notebook_block_insertion_button";
+29 -34
View File
@@ -1,45 +1,40 @@
use std::{collections::HashMap, ops::Range, sync::Arc};
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::{
content::{markdown::MarkdownStyle, text::TextStylesWithMetadata},
editor::EmbeddedItemModel,
extract_block,
render::{
element::{CursorData, CursorDisplayType, RenderContext, RenderableBlock},
layout::TextLayout,
model::{
viewport::ViewportItem, BlockItem, BlockSpacing, BrokenBlockEmbedding, EmbeddedItem,
EmbeddedItemHTMLRepresentation, EmbeddedItemRichFormat, LaidOutEmbeddedItem,
ParagraphStyles, RenderState, EMBEDDED_ITEM_FIRST_LINE_HEIGHT,
},
BLOCK_FOOTER_HEIGHT,
},
};
use galaxyui::{
elements::{Border, Empty},
SingletonEntity,
};
use galaxyui::{
elements::{ConstrainedBox, CornerRadius, Margin, Padding, Radius},
text_layout::TextFrame,
units::{IntoPixels, Pixels},
AppContext, Element, LayoutContext, SizeConstraint,
};
use itertools::Itertools;
use markdown_parser::html_parser::WARP_EMBED_ATTRIBUTE_NAME;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use serde_yaml::Mapping;
use string_offset::ByteOffset;
use crate::{
cloud_object::{model::persistence::CloudModel, CloudObject},
drive::{cloud_object_styling::warp_drive_icon_color, DriveObjectType},
server::ids::{HashableId, ToServerId},
ui_components::icons::Icon,
workflows::{workflow::Workflow, CloudWorkflow, WorkflowId},
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::content::markdown::MarkdownStyle;
use galaxy_editor::content::text::TextStylesWithMetadata;
use galaxy_editor::editor::EmbeddedItemModel;
use galaxy_editor::extract_block;
use galaxy_editor::render::element::{CursorData, CursorDisplayType, RenderContext, RenderableBlock};
use galaxy_editor::render::layout::TextLayout;
use galaxy_editor::render::model::viewport::ViewportItem;
use galaxy_editor::render::model::{
BlockItem, BlockSpacing, BrokenBlockEmbedding, EmbeddedItem, EmbeddedItemHTMLRepresentation,
EmbeddedItemRichFormat, LaidOutEmbeddedItem, ParagraphStyles, RenderState,
EMBEDDED_ITEM_FIRST_LINE_HEIGHT,
};
use galaxy_editor::render::BLOCK_FOOTER_HEIGHT;
use galaxyui::elements::{Border, ConstrainedBox, CornerRadius, Empty, Margin, Padding, Radius};
use galaxyui::text_layout::TextFrame;
use galaxyui::units::{IntoPixels, Pixels};
use galaxyui::{AppContext, Element, LayoutContext, SingletonEntity, SizeConstraint};
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::CloudObject;
use crate::drive::cloud_object_styling::warp_drive_icon_color;
use crate::drive::DriveObjectType;
use crate::server::ids::{HashableId, ToServerId};
use crate::ui_components::icons::Icon;
use crate::workflows::workflow::Workflow;
use crate::workflows::{CloudWorkflow, WorkflowId};
// Spacing for the embedded workflow card.
const EMBED_WORKFLOW_SPACING: BlockSpacing = BlockSpacing {
+39 -40
View File
@@ -1,48 +1,47 @@
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
use std::borrow::Cow;
use std::mem;
use std::ops::Range;
use std::sync::Arc;
use galaxy_completer::signatures::CommandRegistry;
use galaxy_editor::{
content::{anchor::Anchor, buffer::Buffer, selection_model::BufferSelectionModel},
editor::EmbeddedItemModel,
};
use galaxy_util::user_input::UserInput;
use galaxyui::{
elements::{
Align, Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
MouseStateHandle, ParentElement, Shrinkable,
},
platform::Cursor,
ui_components::{button::ButtonVariant, components::UiComponent},
AppContext, Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity,
};
use string_offset::{ByteOffset, CharOffset};
use crate::{
appearance::Appearance,
cloud_object::{model::persistence::CloudModel, CloudObject},
completer::SessionAgnosticContext,
notebooks::{
styles::block_footer_action_button,
telemetry::{ActionEntrypoint, BlockInfo},
},
server::ids::{HashableId, ToServerId},
settings::FontSettings,
terminal::input::decorations::{parse_current_commands_and_tokens, ParsedTokensSnapshot},
themes::theme::AnsiColorIdentifier,
ui_components::icons::Icon,
util::bindings::CustomAction,
workflows::{CloudWorkflow, WorkflowId},
use galaxy_completer::signatures::CommandRegistry;
use galaxy_editor::content::anchor::Anchor;
use galaxy_editor::content::buffer::Buffer;
use galaxy_editor::content::selection_model::BufferSelectionModel;
use galaxy_editor::editor::EmbeddedItemModel;
use galaxy_util::user_input::UserInput;
use galaxyui::elements::{
Align, Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment, MouseStateHandle,
ParentElement, Shrinkable,
};
use galaxyui::platform::Cursor;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::UiComponent;
use galaxyui::{AppContext, Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity};
use super::{
embedded_item::EmbeddedWorkflow,
keys::{custom_action_to_display, NotebookKeybindings},
model::ChildModelHandle,
notebook_command::{parsed_token_to_color_style_ranges, transform_ansi_color_to_solid_color},
rich_text_styles,
view::EditorViewAction,
NotebookWorkflow,
use super::embedded_item::EmbeddedWorkflow;
use super::keys::{custom_action_to_display, NotebookKeybindings};
use super::model::ChildModelHandle;
use super::notebook_command::{
parsed_token_to_color_style_ranges, transform_ansi_color_to_solid_color,
};
use super::view::EditorViewAction;
use super::{rich_text_styles, NotebookWorkflow};
use crate::appearance::Appearance;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::CloudObject;
use crate::completer::SessionAgnosticContext;
use crate::notebooks::styles::block_footer_action_button;
use crate::notebooks::telemetry::{ActionEntrypoint, BlockInfo};
use crate::server::ids::{HashableId, ToServerId};
use crate::settings::FontSettings;
use crate::terminal::input::decorations::{
parse_current_commands_and_tokens, ParsedTokensSnapshot,
};
use crate::themes::theme::AnsiColorIdentifier;
use crate::ui_components::icons::Icon;
use crate::util::bindings::CustomAction;
use crate::workflows::{CloudWorkflow, WorkflowId};
#[derive(Default)]
struct MouseStateHandles {
+24 -32
View File
@@ -1,43 +1,35 @@
use std::{fmt::Write, time::Duration};
use std::fmt::Write;
use std::time::Duration;
use async_channel::Sender;
use galaxy_editor::{
render::model::{AutoScrollMode, Decoration},
search::{SearchEvent, Searcher},
use pathfinder_geometry::vector::vec2f;
use galaxy_core::r#async::debounce;
use galaxy_editor::render::model::{AutoScrollMode, Decoration};
use galaxy_editor::search::{SearchEvent, Searcher};
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
use galaxyui::elements::{
Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Flex, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, Rect, Shrinkable, Stack,
};
use galaxyui::platform::Cursor;
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::ui_components::toggle_button::ToggleButton;
use galaxyui::{
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
elements::{
Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Flex, MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor,
PositionedElementOffsetBounds, Radius, Rect, Shrinkable, Stack,
},
platform::Cursor,
presenter::ChildView,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
toggle_button::ToggleButton,
},
AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity,
TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use crate::{
appearance::Appearance,
debounce::debounce,
editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions},
ui_components::icons::Icon,
view_components::find::{
CASE_SENSITIVE_LABEL, CASE_SENSITIVE_TOOLTIP, FIND_BAR_WIDTH, REGEX_TOGGLE_LABEL,
REGEX_TOGGLE_TOOLTIP,
},
};
use super::{
model::NotebooksEditorModel,
view::{EditorViewEvent, RichTextEditorView},
use super::model::NotebooksEditorModel;
use super::view::{EditorViewEvent, RichTextEditorView};
use crate::appearance::Appearance;
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions};
use crate::ui_components::icons::Icon;
use crate::view_components::find::{
CASE_SENSITIVE_LABEL, CASE_SENSITIVE_TOOLTIP, FIND_BAR_WIDTH, REGEX_TOGGLE_LABEL,
REGEX_TOGGLE_TOOLTIP,
};
/// View for the find bar within a notebook.
+5 -4
View File
@@ -1,10 +1,10 @@
//! Utilities for notebook keybindings.
use galaxyui::{Entity, ModelContext, SingletonEntity};
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
use crate::{
settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier},
util::bindings::{custom_tag_to_keystroke, keybinding_name_to_display_string, CustomAction},
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
use crate::util::bindings::{
custom_tag_to_keystroke, keybinding_name_to_display_string, CustomAction,
};
pub const RUN_COMMANDS_KEYBINDING_NAME: &str = "editor_view:run_commands";
@@ -40,6 +40,7 @@ impl NotebookKeybindings {
fn handle_keybinding_change(
&mut self,
_: ModelHandle<KeybindingChangedNotifier>,
event: &KeybindingChangedEvent,
ctx: &mut ModelContext<Self>,
) {
+14 -19
View File
@@ -1,29 +1,24 @@
use galaxy_editor::{
editor::NavigationKey, model::RichTextEditorModel, render::model::RenderState,
use galaxy_editor::editor::NavigationKey;
use galaxy_editor::model::RichTextEditorModel;
use galaxy_editor::render::model::RenderState;
use galaxyui::elements::{
AnchorPair, Container, Flex, MouseStateHandle, OffsetPositioning, OffsetType, ParentElement,
PositionedElementOffsetBounds, PositioningAxis, XAxisAnchor, YAxisAnchor,
};
use galaxyui::fonts::Weight;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
AnchorPair, Container, Flex, MouseStateHandle, OffsetPositioning, OffsetType,
ParentElement, PositionedElementOffsetBounds, PositioningAxis, XAxisAnchor, YAxisAnchor,
},
fonts::Weight,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, BlurContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle,
};
use crate::{
appearance::Appearance,
editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
},
};
use super::model::NotebooksEditorModel;
use crate::appearance::Appearance;
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
const EDITOR_WIDTH: f32 = 368.;
const EDITOR_VERTICAL_PADDING: f32 = 12.;
+41 -41
View File
@@ -2,32 +2,29 @@
use std::sync::Arc;
use galaxy_core::ui::{builder::CHECK_SVG_PATH, theme::color::internal_colors};
use galaxy_editor::{
content::text::{
BlockHeaderSize, BlockType as ContentBlockType, BufferBlockStyle, CodeBlockType,
},
render::model::{
BrokenLinkStyle, CheckBoxStyle, EmbeddedItem, HorizontalRuleStyle, InlineCodeStyle,
ParagraphStyles, RichTextStyles, TableStyle, PARAGRAPH_MIN_HEIGHT,
},
};
use galaxy_util::user_input::UserInput;
use galaxyui::{
elements::Border, fonts::FamilyId, ui_components::checkbox::HOVER_BACKGROUND_COLOR,
};
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
use pathfinder_color::ColorU;
use crate::{
appearance::Appearance,
notebooks::editor::embedded_item::EmbeddedWorkflow,
settings::{derived_notebook_font_size, FontSettings},
themes::theme::Fill,
ui_components::icons::Icon,
util::color::{ContrastingColor, MinimumAllowedContrast},
workflows::{CloudWorkflow, WorkflowSource, WorkflowType},
use galaxy_core::ui::builder::CHECK_SVG_PATH;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_editor::content::text::{
BlockHeaderSize, BlockType as ContentBlockType, BufferBlockStyle, CodeBlockType,
};
use galaxy_editor::render::model::{
BrokenLinkStyle, CheckBoxStyle, EmbeddedItem, HorizontalRuleStyle, InlineCodeStyle,
ParagraphStyles, RichTextStyles, TableStyle,
};
use galaxy_util::user_input::UserInput;
use galaxyui::elements::{Border, ListIndentLevel};
use galaxyui::fonts::FamilyId;
use galaxyui::ui_components::checkbox::HOVER_BACKGROUND_COLOR;
use crate::appearance::Appearance;
use crate::notebooks::editor::embedded_item::EmbeddedWorkflow;
use crate::settings::{derived_notebook_font_size, FontSettings};
use crate::themes::theme::Fill;
use crate::ui_components::icons::Icon;
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
use crate::workflows::{CloudWorkflow, WorkflowSource, WorkflowType};
mod block_insertion_menu;
mod embedded_item;
@@ -42,9 +39,7 @@ mod omnibar;
pub mod view;
pub use block_insertion_menu::BlockInsertionSource;
use galaxyui::elements::ListIndentLevel;
const NOTEBOOK_LINE_HEIGHT_RATIO: f32 = 1.6;
const NOTEBOOK_LINE_HEIGHT_RATIO: f32 = 1.5;
const NOTEBOOK_BASELINE_RATIO: f32 = 0.7;
#[derive(Clone, Copy)]
@@ -204,26 +199,31 @@ pub(crate) fn markdown_table_style(
/// Build [`RichTextStyles`] based on the current [`Appearance`].
pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -> RichTextStyles {
let line_height_ratio = NOTEBOOK_LINE_HEIGHT_RATIO;
let baseline_ratio = NOTEBOOK_BASELINE_RATIO;
let theme = appearance.theme();
let inline_font_color: ColorU = theme.terminal_colors().normal.red.into();
let font_size = derived_notebook_font_size(font_settings);
let base_text = ParagraphStyles {
font_size,
font_weight: Default::default(),
line_height_ratio,
font_family: appearance.ui_font_family(),
text_color: theme.main_text_color(theme.background()).into_solid(),
baseline_ratio,
fixed_width_tab_size: None,
};
RichTextStyles {
base_text: ParagraphStyles {
font_size,
font_weight: Default::default(),
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
font_family: appearance.ui_font_family(),
text_color: theme.main_text_color(theme.background()).into_solid(),
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
fixed_width_tab_size: None,
},
base_text,
code_text: ParagraphStyles {
font_family: appearance.monospace_font_family(),
font_size,
font_weight: Default::default(),
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
line_height_ratio,
text_color: theme.main_text_color(theme.background()).into_solid(),
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
baseline_ratio,
fixed_width_tab_size: Some(4),
},
code_background: theme.background().into(),
@@ -231,10 +231,10 @@ pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -
embedding_text: ParagraphStyles {
font_size,
font_weight: Default::default(),
line_height_ratio: NOTEBOOK_LINE_HEIGHT_RATIO,
line_height_ratio,
font_family: appearance.monospace_font_family(),
text_color: theme.main_text_color(theme.surface_2()).into_solid(),
baseline_ratio: NOTEBOOK_BASELINE_RATIO,
baseline_ratio,
fixed_width_tab_size: Some(4),
},
code_border: Border::all(1.).with_border_fill(theme.surface_3()),
@@ -270,8 +270,8 @@ pub fn rich_text_styles(appearance: &Appearance, font_settings: &FontSettings) -
},
block_spacings: Default::default(),
show_placeholder_text_on_empty_block: true,
minimum_paragraph_height: Some(PARAGRAPH_MIN_HEIGHT),
cursor_width: 1.,
minimum_paragraph_height: Some(base_text.line_height()),
cursor_width: 3.,
highlight_urls: true,
table_style: markdown_table_style(appearance, appearance.ui_font_family(), font_size),
}
+183 -63
View File
@@ -1,62 +1,57 @@
use base64::{prelude::BASE64_STANDARD, Engine as _};
use std::{any::Any, borrow::Cow, collections::HashMap, ops::Range, time::Duration};
use std::any::Any;
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::time::Duration;
use galaxyui::{
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
clipboard::ClipboardContent,
AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, WindowId,
};
use base64::prelude::BASE64_STANDARD;
use base64::Engine as _;
use itertools::Itertools;
use lazy_static::lazy_static;
use markdown_parser::FormattedText;
use mermaid_to_svg::MermaidTheme;
use num_traits::SaturatingSub;
use regex::Regex;
use string_offset::CharOffset;
use url::Url;
use vec1::{vec1, Vec1};
use crate::{
cloud_object::model::persistence::{CloudModel, CloudModelEvent},
debounce::debounce,
editor::InteractionState,
notebooks::telemetry::BlockInfo,
};
use crate::{
notebooks::editor::interaction_state_model::InteractionStateModelEvent,
terminal::ShellLaunchData,
};
use galaxy_core::features::FeatureFlag;
use galaxy_core::r#async::debounce;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_editor::{
content::{buffer::ShouldAutoscroll, selection_model::BufferSelectionModel},
model::BufferUpdateWrapper,
render::model::{BlockItem, StyleUpdateAction},
use galaxy_editor::content::buffer::{
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction, EditOrigin,
SelectionOffsets, ShouldAutoscroll,
};
use galaxy_editor::{
content::{
buffer::{
AutoScrollBehavior, Buffer, BufferEditAction, BufferEvent, BufferSelectAction,
EditOrigin, SelectionOffsets,
},
text::{
BlockHeaderSize, BlockType, BufferBlockItem, BufferBlockStyle, BufferTextStyle,
CodeBlockType, IndentBehavior, IndentUnit, TextStyles, TextStylesWithMetadata,
},
},
model::{CoreEditorModel, RichTextEditorModel},
render::model::{AutoScrollMode, RenderEvent, RenderState, RichTextStyles},
search::Searcher,
selection::{SelectionMode, SelectionModel, TextDirection, TextUnit},
use galaxy_editor::content::selection_model::BufferSelectionModel;
use galaxy_editor::content::text::{
BlockHeaderSize, BlockType, BufferBlockItem, BufferBlockStyle, BufferTextStyle, CodeBlockType,
IndentBehavior, IndentUnit, TextStyles, TextStylesWithMetadata,
};
use galaxy_editor::model::{BufferUpdateWrapper, CoreEditorModel, RichTextEditorModel};
use galaxy_editor::render::model::{
AutoScrollMode, BlockItem, RenderEvent, RenderState, RichTextStyles, StyleUpdateAction,
};
use galaxy_editor::search::Searcher;
use galaxy_editor::selection::{SelectionMode, SelectionModel, TextDirection, TextUnit};
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::ListIndentLevel;
use string_offset::CharOffset;
use super::{
super::telemetry::SelectionMode as TelemetrySelectionMode, embedding_model::NotebookEmbed,
interaction_state_model::InteractionStateModel, notebook_command::NotebookCommand,
NotebookWorkflow,
use galaxyui::{
AppContext, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, WindowId,
};
use super::super::telemetry::SelectionMode as TelemetrySelectionMode;
use super::embedding_model::NotebookEmbed;
use super::interaction_state_model::InteractionStateModel;
use super::notebook_command::NotebookCommand;
use super::NotebookWorkflow;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::editor::InteractionState;
use crate::notebooks::editor::interaction_state_model::InteractionStateModelEvent;
use crate::notebooks::file::MarkdownDisplayMode;
use crate::notebooks::telemetry::BlockInfo;
use crate::terminal::ShellLaunchData;
const DEBOUNCED_RESIZE_PERIOD: Duration = Duration::from_millis(5);
lazy_static! {
@@ -111,6 +106,7 @@ pub struct NotebooksEditorModel {
resize_tx: async_channel::Sender<()>,
/// Context used to generate clickable file path links for notebooks.
file_link_resolution_context: Option<FileLinkResolutionContext>,
default_mermaid_display_mode: MarkdownDisplayMode,
}
#[derive(Clone)]
@@ -158,15 +154,6 @@ impl NotebooksEditorModel {
&& FeatureFlag::EditableMarkdownMermaid.is_enabled()
}
fn render_mermaid_diagrams_in_state(state: &InteractionState) -> bool {
FeatureFlag::MarkdownMermaid.is_enabled()
&& (matches!(state, InteractionState::Selectable)
|| (Self::editable_markdown_mermaid_enabled()
&& matches!(
state,
InteractionState::Editable | InteractionState::EditableWithInvalidSelection
)))
}
pub fn new(
text_styles: RichTextStyles,
rte_window_id: WindowId,
@@ -189,7 +176,7 @@ impl NotebooksEditorModel {
Buffer::new(Box::new(notebook_tab_indentation))
.with_embedded_item_conversion(super::notebook_embedded_item_conversion)
});
ctx.subscribe_to_model(&content, |me, event, ctx| {
ctx.subscribe_to_model(&content, |me, _, event, ctx| {
me.handle_content_model_event(event, ctx);
});
@@ -216,7 +203,7 @@ impl NotebooksEditorModel {
);
let cloud_model = CloudModel::handle(ctx);
ctx.subscribe_to_model(&cloud_model, |me, event, ctx| {
ctx.subscribe_to_model(&cloud_model, |me, _, event, ctx| {
me.handle_cloud_model_event(event, ctx)
});
@@ -239,6 +226,7 @@ impl NotebooksEditorModel {
rte_window_id,
resize_tx,
file_link_resolution_context: None,
default_mermaid_display_mode: MarkdownDisplayMode::Raw,
}
}
@@ -285,6 +273,33 @@ impl NotebooksEditorModel {
self.file_link_resolution_context = file_link_resolution_context;
}
pub fn set_default_mermaid_display_mode(
&mut self,
mode: MarkdownDisplayMode,
ctx: &mut ModelContext<Self>,
) {
if self.default_mermaid_display_mode == mode {
return;
}
self.default_mermaid_display_mode = mode;
for command in self
.child_models
.model_handles::<NotebookCommand>()
.collect_vec()
{
command.update(ctx, |command, _| {
command.mermaid_display_mode = mode;
});
}
if self.sync_mermaid_render_offsets(ctx) {
self.rebuild_layout(ctx);
} else {
ctx.notify();
}
}
/// Create a new model for searching the editor.
pub fn new_search(&self, ctx: &mut ModelContext<Self>) -> ModelHandle<Searcher> {
let buffer = self.content.clone();
@@ -299,7 +314,12 @@ impl NotebooksEditorModel {
<Self as RichTextEditorModel>::update_to_new_markdown(self, markdown, ctx);
}
fn handle_render_model_event(&mut self, event: &RenderEvent, ctx: &mut ModelContext<Self>) {
fn handle_render_model_event(
&mut self,
_: ModelHandle<RenderState>,
event: &RenderEvent,
ctx: &mut ModelContext<Self>,
) {
// Ignore render events until bound to a real window, and when the window is closed.
let Some(window_id) = self.rte_window_id else {
return;
@@ -320,8 +340,12 @@ impl NotebooksEditorModel {
self.content.clone(),
self.selection_model.clone(),
window_id,
self.default_mermaid_display_mode,
ctx,
);
if self.sync_mermaid_render_offsets(ctx) {
self.rebuild_layout(ctx);
}
}
_ => (),
}
@@ -329,6 +353,7 @@ impl NotebooksEditorModel {
fn handle_interaction_state_model_event(
&mut self,
_: ModelHandle<InteractionStateModel>,
event: &InteractionStateModelEvent,
ctx: &mut ModelContext<Self>,
) {
@@ -342,15 +367,51 @@ impl NotebooksEditorModel {
show_final_trailing_newline_when_non_empty,
);
});
let render_mermaid_diagrams = Self::render_mermaid_diagrams_in_state(new_state);
let relayout_needed = self.render_state.update(ctx, |render_state, _| {
render_state.set_render_mermaid_diagrams(render_mermaid_diagrams)
});
if relayout_needed {
}
/// Set the Mermaid display mode (Raw or Rendered) for the block at the given offset.
/// `block_offset` is the buffer's 0-indexed block marker position (i.e. `outline.start`).
/// Updates `mermaid_render_offsets` in the render state and triggers relayout if changed.
pub fn set_mermaid_render_mode(
&mut self,
block_offset: CharOffset,
mode: MarkdownDisplayMode,
ctx: &mut ModelContext<Self>,
) {
if let Some(command) = self.child_models.model_at::<NotebookCommand>(block_offset) {
command.update(ctx, |cmd, _| {
cmd.mermaid_display_mode = mode;
});
}
if self.sync_mermaid_render_offsets(ctx) {
self.rebuild_layout(ctx);
}
}
fn sync_mermaid_render_offsets(&mut self, ctx: &mut ModelContext<Self>) -> bool {
let new_offsets: HashSet<CharOffset> = self
.child_models
.model_handles::<NotebookCommand>()
.filter_map(|handle| {
if matches!(
handle.as_ref(ctx).mermaid_display_mode,
MarkdownDisplayMode::Rendered
) {
handle
.as_ref(ctx)
.start_offset(ctx)
.map(|o| o + CharOffset::from(1))
} else {
None
}
})
.collect();
self.render_state
.update(ctx, |rs, _| rs.set_mermaid_render_offsets(new_offsets))
}
fn handle_content_model_event(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
if let Some(window_id) = self.rte_window_id {
if !ctx.is_window_open(window_id) {
@@ -1187,7 +1248,16 @@ impl NotebooksEditorModel {
BlockType::Text(BufferBlockStyle::CodeBlock {
code_block_type: CodeBlockType::Mermaid,
})
) && Self::editable_markdown_mermaid_enabled()
) && self
.child_models
.model_at::<NotebookCommand>(block_start)
.is_some_and(|command| {
matches!(
command.as_ref(ctx).mermaid_display_mode,
MarkdownDisplayMode::Rendered
)
})
&& Self::editable_markdown_mermaid_enabled()
&& matches!(
self.interaction_state(ctx),
InteractionState::Selectable
@@ -1257,6 +1327,53 @@ impl NotebooksEditorModel {
self.content.as_ref(app).link_url_at_offset(offset)
}
pub fn scroll_to_matching_header(
&mut self,
fragment: &str,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(range) = self.find_matching_header(fragment, ctx) else {
return false;
};
self.render_state.update(ctx, |render_state, _| {
render_state
.request_autoscroll_to(AutoScrollMode::PositionOffsetInViewportCenter(range.start));
});
true
}
fn find_matching_header(&self, fragment: &str, ctx: &AppContext) -> Option<Range<CharOffset>> {
let target = fragment.strip_prefix('#')?;
if target.is_empty() {
return None;
}
let target = urlencoding::decode(target).ok()?;
let target = target.trim().to_lowercase();
if target.is_empty() {
return None;
}
let content = self.content.as_ref(ctx);
for outline in content.outline_blocks() {
if !matches!(
&outline.block_type,
BlockType::Text(BufferBlockStyle::Header { .. })
) {
continue;
}
let heading = content
.text_in_range(outline.start + 1..outline.end)
.into_string();
if heading.trim().to_lowercase() == target {
return Some(outline.start..outline.end);
}
}
None
}
/// Whether or not there's an active command block selection.
pub fn has_command_selection(&self, ctx: &AppContext) -> bool {
self.child_models
@@ -2041,6 +2158,7 @@ impl ChildModels {
content: ModelHandle<Buffer>,
selection_model: ModelHandle<BufferSelectionModel>,
rte_window_id: WindowId,
default_mermaid_display_mode: MarkdownDisplayMode,
ctx: &mut ModelContext<T>,
) {
// Resolve each existing model to its current offsets in the buffer, filtering out models
@@ -2124,7 +2242,7 @@ impl ChildModels {
outline.end
);
let new_model = ctx.add_model(|ctx| {
NotebookCommand::new(
let mut command = NotebookCommand::new(
outline.start,
outline.end,
interaction_state.clone(),
@@ -2132,7 +2250,9 @@ impl ChildModels {
selection_model.clone(),
rte_window_id,
ctx,
)
);
command.mermaid_display_mode = default_mermaid_display_mode;
command
});
self.models.insert(outline.start, Box::new(new_model));
+312 -38
View File
@@ -2,31 +2,6 @@ use std::collections::HashSet;
use std::ops::Range;
use std::sync::Arc;
use super::super::rich_text_styles;
use super::NotebooksEditorModel;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions, ServerWorkflow};
use crate::editor::InteractionState;
use crate::notebooks::editor::keys::NotebookKeybindings;
use crate::notebooks::editor::model::DEBOUNCED_RESIZE_PERIOD;
use crate::notebooks::editor::notebook_command::NotebookCommand;
use crate::notebooks::editor::view::{RichTextEditorConfig, RichTextEditorView};
use crate::notebooks::link::{NotebookLinks, SessionSource};
use crate::search::files::model::FileSearchModel;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::settings::FontSettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::keys::TerminalKeybindings;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workflows::workflow::Workflow;
use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId};
use crate::workspace::ActiveSession;
use crate::UserWorkspaces;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
use chrono::Utc;
use futures::prelude::*;
use galaxy_core::features::FeatureFlag;
@@ -52,6 +27,38 @@ use markdown_parser::{
use pathfinder_geometry::vector::Vector2F;
use string_offset::CharOffset;
use vec1::vec1;
use galaxy_editor::render::model::{BlockItem, RenderEvent};
use galaxyui::r#async::{block_on, FutureId, Timer};
use galaxyui::{
AddSingletonModel, App, AppContext, Element, Entity, ModelHandle, SingletonEntity,
TypedActionView, View, ViewHandle,
};
use super::super::rich_text_styles;
use super::NotebooksEditorModel;
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions, ServerWorkflow};
use crate::editor::InteractionState;
use crate::notebooks::editor::keys::NotebookKeybindings;
use crate::notebooks::editor::model::DEBOUNCED_RESIZE_PERIOD;
use crate::notebooks::editor::notebook_command::NotebookCommand;
use crate::notebooks::editor::view::{RichTextEditorConfig, RichTextEditorView};
use crate::notebooks::file::MarkdownDisplayMode;
use crate::notebooks::link::{NotebookLinks, SessionSource};
use crate::search::files::model::FileSearchModel;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::settings::FontSettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::keys::TerminalKeybindings;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workflows::workflow::Workflow;
use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId};
use crate::workspace::ActiveSession;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces};
/// Container for a [`RichTextEditorView`] in unit tests.
struct TestView {
@@ -498,6 +505,86 @@ fn test_inline_markdown_double_leading_underscore_not_italic() {
})
}
#[test]
fn test_find_matching_header_simple() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("- [Goal](#goal)\n\n## Goal\nBody", &mut app, true);
editor.read(&app, |editor, ctx| {
let range = editor
.find_matching_header("#goal", ctx)
.expect("Fragment should match heading");
let heading = editor
.content
.as_ref(ctx)
.text_in_range(range.start + 1..range.end)
.into_string();
assert_eq!(heading, "Goal");
});
})
}
#[test]
fn test_find_matching_header_case_insensitive() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("## My Bold Goal\nBody", &mut app, true);
editor.read(&app, |editor, ctx| {
assert!(editor.find_matching_header("#my bold goal", ctx).is_some());
assert!(editor.find_matching_header("#MY BOLD GOAL", ctx).is_some());
});
})
}
#[test]
fn test_find_matching_header_percent_decoded() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("## Hello World\nBody", &mut app, true);
editor.read(&app, |editor, ctx| {
assert!(editor.find_matching_header("#Hello%20World", ctx).is_some());
});
})
}
#[test]
fn test_find_matching_header_returns_first_match() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("## Goal\nFirst\n\n## Goal\nSecond", &mut app, true);
editor.read(&app, |editor, ctx| {
let range = editor
.find_matching_header("#goal", ctx)
.expect("Should match first heading");
let heading_text = editor
.content
.as_ref(ctx)
.text_in_range(range.start + 1..range.end)
.into_string();
assert_eq!(heading_text, "Goal");
});
})
}
#[test]
fn test_find_matching_header_missing_returns_none() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let editor = model_from_markdown("## Goal\nBody", &mut app, true);
editor.read(&app, |editor, ctx| {
assert!(editor.find_matching_header("#nonexistent", ctx).is_none());
assert!(editor.find_matching_header("#", ctx).is_none());
assert!(editor.find_matching_header("", ctx).is_none());
});
})
}
#[test]
fn test_cursor_bias_editing() {
App::test((), |mut app| async move {
@@ -606,6 +693,101 @@ fn test_plain_text_pasting() {
});
}
#[test]
fn test_delete_inside_raw_mermaid_block_edits_text_without_removing_block() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
let markdown = "Text
```mermaid
graph TD
A --> B
```
More text";
let original_char_count = markdown.chars().count();
let model_handle = model_from_markdown(markdown, &mut app, true);
model_handle.update(&mut app, |model, ctx| {
model.set_interaction_state(InteractionState::Editable, ctx);
});
layout_model(&mut app, &model_handle).await;
let cursor_offset = CharOffset::from(
markdown
.find("graph TD")
.expect("Mermaid source should exist")
+ 3,
);
model_handle.update(&mut app, |model, ctx| {
model.cursor_at(cursor_offset, ctx);
assert!(!model.has_command_selection(ctx));
model.backspace(ctx);
});
layout_model(&mut app, &model_handle).await;
model_handle.read(&app, |model, ctx| {
let updated_markdown = model.markdown(ctx);
let updated_mermaid_command = model
.child_models
.model_handles::<NotebookCommand>()
.exactly_one()
.ok()
.expect("Mermaid command should still exist after backspace");
let updated_mermaid_command = updated_mermaid_command.as_ref(ctx);
let updated_mermaid_range = updated_mermaid_command
.start_offset(ctx)
.expect("Mermaid command should still have a start offset")
..updated_mermaid_command
.end_offset(ctx)
.expect("Mermaid command should still have an end offset");
let cursor = model.selection.as_ref(ctx).cursors(ctx)[0];
assert!(updated_markdown.contains("```mermaid"));
assert!(updated_markdown.contains("More text"));
assert_eq!(updated_markdown.chars().count(), original_char_count - 1);
assert!(model.selection_is_single_cursor(ctx));
assert!(cursor > updated_mermaid_range.start && cursor < updated_mermaid_range.end);
assert!(!model.has_command_selection(ctx));
});
model_handle.update(&mut app, |model, ctx| model.undo(ctx));
layout_model(&mut app, &model_handle).await;
model_handle.update(&mut app, |model, ctx| {
model.cursor_at(cursor_offset, ctx);
assert!(!model.has_command_selection(ctx));
model.delete(TextDirection::Forwards, TextUnit::Character, false, ctx);
});
layout_model(&mut app, &model_handle).await;
model_handle.read(&app, |model, ctx| {
let updated_markdown = model.markdown(ctx);
let updated_mermaid_command = model
.child_models
.model_handles::<NotebookCommand>()
.exactly_one()
.ok()
.expect("Mermaid command should still exist after delete");
let updated_mermaid_command = updated_mermaid_command.as_ref(ctx);
let updated_mermaid_range = updated_mermaid_command
.start_offset(ctx)
.expect("Mermaid command should still have a start offset")
..updated_mermaid_command
.end_offset(ctx)
.expect("Mermaid command should still have an end offset");
let cursor = model.selection.as_ref(ctx).cursors(ctx)[0];
assert!(updated_markdown.contains("```mermaid"));
assert!(updated_markdown.contains("More text"));
assert_eq!(updated_markdown.chars().count(), original_char_count - 1);
assert!(model.selection_is_single_cursor(ctx));
assert!(cursor > updated_mermaid_range.start && cursor < updated_mermaid_range.end);
assert!(!model.has_command_selection(ctx));
});
});
}
#[test]
fn test_pasting_link_on_selected_text() {
App::test((), |mut app| async move {
@@ -1277,7 +1459,7 @@ fn test_debounced_resizes() {
let render_state = app.read(|ctx| model_handle.as_ref(ctx).render_state().clone());
let model2 = render_state.clone();
let _observer = app.add_model::<Observer, _>(move |ctx| {
ctx.subscribe_to_model(&model2, move |_, event, _| {
ctx.subscribe_to_model(&model2, move |_, _, event, _| {
block_on(events_tx.send(*event)).unwrap();
});
Observer {}
@@ -1673,17 +1855,17 @@ fn mock_server_workflow(id: i64, app: &mut App) {
current_editor_uid: None,
};
let workflow = ServerWorkflow {
id: SyncId::ServerId(workflow_id.into()),
metadata: server_metadata,
permissions: ServerPermissions {
let workflow = ServerWorkflow::new(
SyncId::ServerId(workflow_id.into()),
CloudWorkflowModel::new(Workflow::new(format!("w{id}"), format!("c{id}"))),
server_metadata,
ServerPermissions {
space: Owner::mock_current_user(),
guests: Vec::new(),
permissions_last_updated_ts: ts.into(),
anyone_link_sharing: None,
},
model: CloudWorkflowModel::new(Workflow::new(format!("w{id}"), format!("c{id}"))),
};
);
CloudModel::handle(app).update(app, |cloud_model, _| {
cloud_model.add_object(sync_id, CloudWorkflow::new_from_server(workflow));
@@ -2099,7 +2281,12 @@ fn test_adjacent_delete_with_rendered_mermaid_block_is_atomic() {
initialize_deps(&mut app);
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
let markdown = "Text\n```mermaid\ngraph TD\nA --> B\n```\nMore text";
let markdown = "Text
```mermaid
graph TD
A --> B
```
More text";
let model_handle = model_from_markdown(markdown, &mut app, true);
model_handle.update(&mut app, |model, ctx| {
@@ -2107,6 +2294,11 @@ fn test_adjacent_delete_with_rendered_mermaid_block_is_atomic() {
});
layout_model(&mut app, &model_handle).await;
model_handle.update(&mut app, |model, ctx| {
model.set_mermaid_render_mode(CharOffset::from(5), MarkdownDisplayMode::Rendered, ctx);
});
layout_model(&mut app, &model_handle).await;
let mermaid_command = command_models(&model_handle, &mut app)
.into_iter()
.exactly_one()
@@ -2133,6 +2325,16 @@ fn test_adjacent_delete_with_rendered_mermaid_block_is_atomic() {
model_handle.update(&mut app, |model, ctx| model.undo(ctx));
layout_model(&mut app, &model_handle).await;
model_handle.update(&mut app, |model, ctx| {
model.set_mermaid_render_mode(CharOffset::from(5), MarkdownDisplayMode::Rendered, ctx);
});
layout_model(&mut app, &model_handle).await;
let mermaid_command = command_models(&model_handle, &mut app)
.into_iter()
.exactly_one()
.expect("Mermaid command should exist after undo");
let mermaid_range = command_range(&mermaid_command, &mut app);
model_handle.update(&mut app, |model, ctx| {
model.cursor_at(mermaid_range.start, ctx);
@@ -2159,7 +2361,12 @@ fn test_backspace_with_cursor_inside_rendered_mermaid_block_is_atomic() {
initialize_deps(&mut app);
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
let markdown = "Text\n```mermaid\ngraph TD\nA --> B\n```\nMore text";
let markdown = "Text
```mermaid
graph TD
A --> B
```
More text";
let model_handle = model_from_markdown(markdown, &mut app, true);
model_handle.update(&mut app, |model, ctx| {
@@ -2167,6 +2374,11 @@ fn test_backspace_with_cursor_inside_rendered_mermaid_block_is_atomic() {
});
layout_model(&mut app, &model_handle).await;
model_handle.update(&mut app, |model, ctx| {
model.set_mermaid_render_mode(CharOffset::from(5), MarkdownDisplayMode::Rendered, ctx);
});
layout_model(&mut app, &model_handle).await;
let mermaid_command = command_models(&model_handle, &mut app)
.into_iter()
.exactly_one()
@@ -2212,6 +2424,13 @@ fn test_move_up_from_below_rendered_mermaid_block_lands_on_block_start() {
});
layout_model(&mut app, &model_handle).await;
// Blocks default to Raw; explicitly enable Rendered mode so the navigation
// code treats the block as an atomic rendered unit.
model_handle.update(&mut app, |model, ctx| {
model.set_mermaid_render_mode(CharOffset::from(7), MarkdownDisplayMode::Rendered, ctx);
});
layout_model(&mut app, &model_handle).await;
let mermaid_command = command_models(&model_handle, &mut app)
.into_iter()
.exactly_one()
@@ -2248,6 +2467,13 @@ fn test_shift_select_across_rendered_mermaid_block_is_reversible_from_below() {
});
layout_model(&mut app, &model_handle).await;
// Blocks default to Raw; explicitly enable Rendered mode so selection
// normalization treats the block as an atomic rendered unit.
model_handle.update(&mut app, |model, ctx| {
model.set_mermaid_render_mode(CharOffset::from(7), MarkdownDisplayMode::Rendered, ctx);
});
layout_model(&mut app, &model_handle).await;
let mermaid_command = command_models(&model_handle, &mut app)
.into_iter()
.exactly_one()
@@ -2297,6 +2523,13 @@ fn test_move_down_from_rendered_mermaid_block_start_returns_below_block() {
});
layout_model(&mut app, &model_handle).await;
// Blocks default to Raw; explicitly enable Rendered mode so navigation
// treats the block as an atomic rendered unit.
model_handle.update(&mut app, |model, ctx| {
model.set_mermaid_render_mode(CharOffset::from(7), MarkdownDisplayMode::Rendered, ctx);
});
layout_model(&mut app, &model_handle).await;
let mermaid_command = command_models(&model_handle, &mut app)
.into_iter()
.exactly_one()
@@ -2482,7 +2715,10 @@ fn test_copy_selection_with_markdown_image_omits_image_clipboard_data() {
}
#[test]
fn test_mermaid_rendering_respects_feature_flag_when_selectable() {
fn test_mermaid_feature_flag_disables_rendering_and_toggle() {
// With the flag disabled, Mermaid blocks never render as diagrams.
// With the flag enabled, blocks default to Raw mode — no auto-rendering.
// Diagram rendering only happens when the user explicitly selects Rendered.
App::test((), |mut app| async move {
initialize_deps(&mut app);
let markdown = "```mermaid\ngraph TD\nA --> B\n```";
@@ -2516,7 +2752,46 @@ fn test_mermaid_rendering_respects_feature_flag_when_selectable() {
});
layout_model(&mut app, &model_handle).await;
let enabled_is_mermaid_diagram = model_handle.read(&app, |model, ctx| {
// Even with the flag enabled, blocks default to Raw mode — not auto-rendered.
let enabled_defaults_to_raw = model_handle.read(&app, |model, ctx| {
!matches!(
model
.render_state
.as_ref(ctx)
.content()
.block_at_height(0.)
.map(|item| item.item),
Some(BlockItem::MermaidDiagram { .. })
)
});
assert!(enabled_defaults_to_raw);
});
}
#[test]
fn test_default_mermaid_display_mode_renders_initial_mermaid_blocks() {
App::test((), |mut app| async move {
initialize_deps(&mut app);
let _enabled = FeatureFlag::MarkdownMermaid.override_enabled(true);
let markdown = "```mermaid\ngraph TD\nA --> B\n```";
let model_handle = model_from_markdown(markdown, &mut app, true);
model_handle.update(&mut app, |model, ctx| {
model.set_default_mermaid_display_mode(MarkdownDisplayMode::Rendered, ctx);
model.set_interaction_state(InteractionState::Selectable, ctx);
});
layout_model(&mut app, &model_handle).await;
layout_model(&mut app, &model_handle).await;
let command = command_models(&model_handle, &mut app)
.into_iter()
.exactly_one()
.expect("Mermaid command should exist");
command.read(&app, |command, _| {
assert_eq!(command.mermaid_display_mode, MarkdownDisplayMode::Rendered);
});
let is_mermaid_diagram = model_handle.read(&app, |model, ctx| {
matches!(
model
.render_state
@@ -2527,10 +2802,9 @@ fn test_mermaid_rendering_respects_feature_flag_when_selectable() {
Some(BlockItem::MermaidDiagram { .. })
)
});
assert!(enabled_is_mermaid_diagram);
assert!(is_mermaid_diagram);
});
}
#[test]
fn test_dont_invalidate_command_selection() {
App::test((), |mut app| async move {
+229 -78
View File
@@ -1,78 +1,75 @@
use std::{borrow::Cow, mem, ops::Range, sync::Arc};
use std::borrow::Cow;
use std::mem;
use std::ops::Range;
use std::sync::Arc;
use async_channel::Sender;
use galaxy_completer::signatures::CommandRegistry;
use galaxy_editor::{
content::{
anchor::Anchor,
buffer::{Buffer, BufferEvent, EditOrigin},
selection_model::BufferSelectionModel,
text::{
BlockType, BufferBlockStyle, CodeBlockType, CODE_BLOCK_DEFAULT_DISPLAY_LANG,
CODE_BLOCK_SHELL_DISPLAY_LANG,
},
},
editor::RunnableCommandModel,
};
use itertools::Itertools;
use lazy_static::lazy_static;
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
use pathfinder_color::ColorU;
use string_offset::{ByteOffset, CharOffset};
use syntect::{
easy::HighlightLines,
highlighting::{self, Theme, ThemeSet},
parsing::SyntaxSet,
util::LinesWithEndings,
use syntect::easy::HighlightLines;
use syntect::highlighting::{self, Theme, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
use galaxy_completer::signatures::CommandRegistry;
use galaxy_core::r#async::debounce;
use galaxy_editor::content::anchor::Anchor;
use galaxy_editor::content::buffer::{Buffer, BufferEvent, EditOrigin};
use galaxy_editor::content::mermaid_diagram::mermaid_asset_source;
use galaxy_editor::content::selection_model::BufferSelectionModel;
use galaxy_editor::content::text::{
BlockType, BufferBlockStyle, CodeBlockType, CODE_BLOCK_DEFAULT_DISPLAY_LANG,
CODE_BLOCK_SHELL_DISPLAY_LANG,
};
use galaxy_editor::editor::RunnableCommandModel;
use galaxy_util::user_input::UserInput;
use galaxyui::{elements::Align, r#async::SpawnedFutureHandle, AppContext};
use galaxyui::elements::{
Align, Border, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::fonts::Properties;
use galaxyui::platform::Cursor;
use galaxyui::presenter::ChildView;
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
elements::{
Border, Container, CrossAxisAlignment, Empty, Flex, MainAxisAlignment, MouseStateHandle,
ParentElement, Shrinkable, Text,
},
fonts::Properties,
presenter::ChildView,
Element, Entity, ModelAsRef, ModelContext, ModelHandle, SingletonEntity, ViewHandle,
WeakModelHandle, WindowId,
};
use markdown_parser::markdown_parser::CODE_BLOCK_DEFAULT_MARKDOWN_LANG;
use crate::{
appearance::Appearance,
completer::SessionAgnosticContext,
debounce::debounce,
drive::workflows::arguments::ArgumentsState,
editor::InteractionState,
notebooks::{
styles::block_footer_action_button,
telemetry::{ActionEntrypoint, BlockInfo},
},
settings::FontSettings,
terminal::input::{
decorations::{parse_current_commands_and_tokens, ParsedTokenData, ParsedTokensSnapshot},
DEBOUNCE_INPUT_DECORATION_PERIOD,
},
themes::theme::{AnsiColorIdentifier, AnsiColors},
ui_components::icons::Icon,
util::{
bindings::CustomAction,
color::{ContrastingColor, MinimumAllowedContrast},
},
view_components::{Dropdown, DropdownItem},
workflows::{workflow::Workflow, WorkflowType},
Assets,
AppContext, AssetProvider as _, Element, Entity, ModelAsRef, ModelContext, ModelHandle,
SingletonEntity, ViewHandle, WeakModelHandle, WindowId,
};
use super::{
interaction_state_model::InteractionStateModel,
keys::{custom_action_to_display, NotebookKeybindings},
model::ChildModelHandle,
rich_text_styles,
view::EditorViewAction,
NotebookWorkflow,
use super::interaction_state_model::InteractionStateModel;
use super::keys::{custom_action_to_display, NotebookKeybindings};
use super::model::ChildModelHandle;
use super::view::EditorViewAction;
use super::{rich_text_styles, NotebookWorkflow};
use crate::appearance::Appearance;
use crate::completer::SessionAgnosticContext;
use crate::drive::workflows::arguments::ArgumentsState;
use crate::editor::InteractionState;
use crate::features::FeatureFlag;
use crate::menu::MenuItemFields;
use crate::notebooks::file::MarkdownDisplayMode;
use crate::notebooks::styles::block_footer_action_button;
use crate::notebooks::telemetry::{ActionEntrypoint, BlockInfo};
use crate::settings::FontSettings;
use crate::terminal::input::decorations::{
parse_current_commands_and_tokens, ParsedTokenData, ParsedTokensSnapshot,
};
use crate::terminal::input::DEBOUNCE_INPUT_DECORATION_PERIOD;
use crate::themes::theme::{AnsiColorIdentifier, AnsiColors};
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
use crate::util::bindings::CustomAction;
use crate::util::color::{ContrastingColor, MinimumAllowedContrast};
use crate::view_components::dropdown::DropdownAction;
use crate::view_components::Dropdown;
use crate::workflows::workflow::Workflow;
use crate::workflows::WorkflowType;
use crate::workspace::WorkspaceAction;
use crate::ASSETS;
lazy_static! {
static ref SUPPORTED_LANGUAGES: &'static [&'static str] = &[
@@ -95,6 +92,9 @@ lazy_static! {
struct MouseStateHandles {
insert_button_state: MouseStateHandle,
copy_button_state: MouseStateHandle,
mermaid_raw_button_state: MouseStateHandle,
mermaid_rendered_button_state: MouseStateHandle,
mermaid_fullscreen_button_state: MouseStateHandle,
}
struct CachedHighlightKey {
@@ -118,6 +118,31 @@ struct CodeHighlightResult {
colors: Vec<(Range<ByteOffset>, AnsiColorIdentifier)>,
}
enum CodeBlockDropdownIcon {
Image(Icon),
Tinted(Icon),
}
fn code_block_dropdown_icon(code_block_type: &CodeBlockType) -> CodeBlockDropdownIcon {
match code_block_type {
CodeBlockType::Mermaid => CodeBlockDropdownIcon::Image(Icon::MermaidLang),
CodeBlockType::Code { lang } => match lang.as_str() {
"Go" => CodeBlockDropdownIcon::Image(Icon::GoLang),
"C++" => CodeBlockDropdownIcon::Image(Icon::CppLang),
"JavaScript" => CodeBlockDropdownIcon::Image(Icon::JavaScriptLang),
"Python" => CodeBlockDropdownIcon::Image(Icon::PythonLang),
"Rust" => CodeBlockDropdownIcon::Image(Icon::RustLang),
"SQL" => CodeBlockDropdownIcon::Image(Icon::SqlLang),
"JSON" => CodeBlockDropdownIcon::Image(Icon::JsonLang),
"PHP" => CodeBlockDropdownIcon::Image(Icon::PhpLang),
"Kotlin" => CodeBlockDropdownIcon::Image(Icon::KotlinLang),
"PowerShell" => CodeBlockDropdownIcon::Tinted(Icon::Powershell),
_ => CodeBlockDropdownIcon::Tinted(Icon::Code1),
},
CodeBlockType::Shell => CodeBlockDropdownIcon::Tinted(Icon::Terminal),
}
}
/// Runnable command behavior for notebooks.
pub struct NotebookCommand {
start: Anchor,
@@ -128,6 +153,8 @@ pub struct NotebookCommand {
mouse_state_handles: MouseStateHandles,
is_selected: bool,
block_type_dropdown: ViewHandle<Dropdown<EditorViewAction>>,
/// Display mode for this Mermaid block (Raw or Rendered).
pub mermaid_display_mode: MarkdownDisplayMode,
#[cfg_attr(test, allow(dead_code))]
debounce_highlighting_tx: Sender<()>,
@@ -162,21 +189,24 @@ impl NotebookCommand {
let block_type_dropdown = ctx.add_typed_action_view(rte_window_id, |ctx| {
let mut dropdown = Dropdown::new(ctx);
dropdown.set_top_bar_max_width(68.);
dropdown.set_menu_width(68., ctx);
dropdown.set_top_bar_max_width(96.);
dropdown.set_menu_width(160., ctx);
dropdown.add_items(
CodeBlockType::all()
.map(|code_block_type| {
DropdownItem::new(
code_block_type.to_string().as_str(),
dropdown.set_rich_items(
CodeBlockType::all().map(|code_block_type| {
let mut item = MenuItemFields::new(code_block_type.to_string())
.with_on_select_action(DropdownAction::select_action_and_close(
EditorViewAction::CodeBlockTypeSelectedAtOffset {
code_block_type,
code_block_type: code_block_type.clone(),
start_anchor: start.clone(),
},
)
})
.collect(),
));
item = match code_block_dropdown_icon(&code_block_type) {
CodeBlockDropdownIcon::Image(icon) => item.with_image_icon(icon.into()),
CodeBlockDropdownIcon::Tinted(icon) => item.with_icon(icon),
};
item.into_item()
}),
ctx,
);
@@ -193,9 +223,8 @@ impl NotebookCommand {
let syntax_config = {
let ps = SyntaxSet::load_defaults_newlines();
if let Some(asset) = Assets::get("bundled/syntax_theme/base16.tmTheme") {
let binary = asset.data;
let mut cursor = std::io::Cursor::new(binary);
if let Ok(asset) = ASSETS.get("bundled/syntax_theme/base16.tmTheme") {
let mut cursor = std::io::Cursor::new(asset);
match ThemeSet::load_from_reader(&mut cursor) {
Ok(theme) => Some((ps, theme)),
Err(e) => {
@@ -226,6 +255,7 @@ impl NotebookCommand {
mouse_state_handles: Default::default(),
is_selected: false,
block_type_dropdown,
mermaid_display_mode: MarkdownDisplayMode::Raw,
syntax_highlighting_handle: None,
cached_highlight_delta: None,
debounce_highlighting_tx,
@@ -437,7 +467,12 @@ impl NotebookCommand {
});
}
fn on_buffer_content_updated(&mut self, event: &BufferEvent, ctx: &mut ModelContext<Self>) {
fn on_buffer_content_updated(
&mut self,
_: ModelHandle<Buffer>,
event: &BufferEvent,
ctx: &mut ModelContext<Self>,
) {
// If the buffer changes, check to see if we should update the dropdown
match event {
BufferEvent::ContentChanged { origin, delta, .. }
@@ -618,6 +653,122 @@ impl RunnableCommandModel for NotebookCommand {
.finish(),
)
}
if matches!(block_style, CodeBlockType::Mermaid)
&& FeatureFlag::MarkdownMermaid.is_enabled()
{
let is_raw = matches!(self.mermaid_display_mode, MarkdownDisplayMode::Raw);
let start_anchor_raw = self.start.clone();
let start_anchor_rendered = self.start.clone();
let tooltip_builder_raw = appearance.ui_builder().clone();
let tooltip_builder_rendered = appearance.ui_builder().clone();
let active_highlight = UiComponentStyles {
background: Some(appearance.theme().surface_3().into()),
..Default::default()
};
let raw_button = icon_button(
appearance,
Icon::Code1,
is_raw,
self.mouse_state_handles.mermaid_raw_button_state.clone(),
)
.with_active_styles(active_highlight)
.with_tooltip(move || {
tooltip_builder_raw
.tool_tip("Raw".to_string())
.build()
.finish()
})
.build()
.with_cursor(Cursor::Arrow)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EditorViewAction::MermaidDisplayModeSelected {
start_anchor: start_anchor_raw.clone(),
mode: MarkdownDisplayMode::Raw,
});
})
.finish();
let rendered_button = icon_button(
appearance,
Icon::Dataflow04,
!is_raw,
self.mouse_state_handles
.mermaid_rendered_button_state
.clone(),
)
.with_active_styles(active_highlight)
.with_tooltip(move || {
tooltip_builder_rendered
.tool_tip("Rendered".to_string())
.build()
.finish()
})
.build()
.with_cursor(Cursor::Arrow)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(EditorViewAction::MermaidDisplayModeSelected {
start_anchor: start_anchor_rendered.clone(),
mode: MarkdownDisplayMode::Rendered,
});
})
.finish();
footer.add_child(
Container::new(
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(2.)
.with_child(raw_button)
.with_child(rendered_button)
.finish(),
)
.with_uniform_padding(2.)
.with_margin_left(8.)
.with_background(appearance.theme().background())
.with_border(Border::all(1.).with_border_fill(appearance.theme().surface_3()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
);
if !is_raw {
let fullscreen_model = self.handle.clone();
footer.add_child(
Align::new(
block_footer_action_button(
appearance,
Icon::Maximize,
self.mouse_state_handles
.mermaid_fullscreen_button_state
.clone(),
"Open full screen",
None,
)
.on_click(move |ctx, app, _| {
if let Some(command_model) = fullscreen_model.upgrade(app) {
if let Some(source) = command_model.as_ref(app).command(app) {
if !source.trim().is_empty() {
ctx.dispatch_typed_action(WorkspaceAction::OpenLightbox {
images: vec![LightboxImage {
source: LightboxImageSource::Resolved {
asset_source: mermaid_asset_source(&source),
},
description: None,
}],
initial_index: 0,
});
}
}
}
})
.finish(),
)
.right()
.finish(),
);
}
}
footer.add_child(Shrinkable::new(1.0, Empty::new().finish()).finish());
footer.add_child(
Align::new(
+25 -29
View File
@@ -1,41 +1,37 @@
//! Implementation for the omnibar - a floating menu for editor interactions
//! like formatting and changing block types.
use galaxy_editor::{
content::text::{
BlockType as ContentBlockType, BufferBlockStyle, BufferTextStyle, TextStyles,
TextStylesWithMetadata,
},
model::RichTextEditorModel,
render::model::RenderState,
use itertools::Itertools;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use galaxy_editor::content::text::{
BlockType as ContentBlockType, BufferBlockStyle, BufferTextStyle, TextStyles,
TextStylesWithMetadata,
};
use galaxy_editor::model::RichTextEditorModel;
use galaxy_editor::render::model::RenderState;
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
use galaxyui::elements::{
AnchorPair, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Flex, MainAxisSize,
MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, Point,
PositionedElementOffsetBounds, PositioningAxis, Radius, Rect, XAxisAnchor, YAxisAnchor,
};
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
elements::{
AnchorPair, Border, ConstrainedBox, Container, CornerRadius, DropShadow, Flex,
MainAxisSize, MouseStateHandle, OffsetPositioning, OffsetType, ParentElement, Point,
PositionedElementOffsetBounds, PositioningAxis, Radius, Rect, XAxisAnchor, YAxisAnchor,
},
presenter::ChildView,
ui_components::components::{Coords, UiComponent, UiComponentStyles},
AppContext, Element, Entity, ModelHandle, SingletonEntity, SizeConstraint, TypedActionView,
View, ViewContext, ViewHandle,
};
use itertools::Itertools;
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use crate::{
appearance::Appearance,
menu::MenuVariant,
ui_components::{buttons::icon_button, icons::Icon},
view_components::{CompactDropdown, CompactDropdownEvent, CompactDropdownItem},
};
use super::{
model::{NotebooksEditorModel, RichTextEditorModelEvent},
view::EditorViewAction,
BlockType,
};
use super::model::{NotebooksEditorModel, RichTextEditorModelEvent};
use super::view::EditorViewAction;
use super::BlockType;
use crate::appearance::Appearance;
use crate::menu::MenuVariant;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
use crate::view_components::{CompactDropdown, CompactDropdownEvent, CompactDropdownItem};
const OMNIBAR_HEIGHT: f32 = 32.;
const OMNIBAR_PADDING: f32 = 4.;
@@ -377,7 +373,7 @@ impl View for Omnibar {
}
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum OmnibarAction {
/// Toggle bold styling on the selected text.
BoldSelection,
+229 -107
View File
@@ -1,96 +1,81 @@
use std::{
collections::HashSet,
ops::Range,
path::PathBuf,
sync::atomic::{AtomicBool, Ordering},
};
use std::collections::HashSet;
use std::ops::Range;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use galaxy_editor::{
content::{
anchor::Anchor,
text::{BufferTextStyle, CodeBlockType, TextStyles},
version::BufferVersion,
},
editor::{EmbeddedItemModel, NavigationKey, RunnableCommandModel, TextDecoration},
model::{CoreEditorModel, RichTextEditorModel},
render::{
element::{
DisplayOptions, DisplayStateHandle, RichTextAction, RichTextElement,
VerticalExpansionBehavior,
},
model::{BlockItem, HitTestBlockType, Location, RenderState},
},
selection::{TextDirection, TextUnit},
};
use markdown_parser::{parse_html, parse_markdown, FormattedText};
use pathfinder_geometry::vector::vec2f;
use string_offset::CharOffset;
use galaxy_util::{path::LineAndColumnArg, user_input::UserInput};
use galaxy_editor::content::anchor::Anchor;
use galaxy_editor::content::text::{BufferTextStyle, CodeBlockType, TextStyles};
use galaxy_editor::content::version::BufferVersion;
use galaxy_editor::editor::{EmbeddedItemModel, NavigationKey, RunnableCommandModel, TextDecoration};
use galaxy_editor::model::{CoreEditorModel, RichTextEditorModel};
use galaxy_editor::render::element::{
DisplayOptions, DisplayStateHandle, RichTextAction, RichTextElement, VerticalExpansionBehavior,
};
use galaxy_editor::render::model::{BlockItem, HitTestBlockType, Location, RenderState};
use galaxy_editor::selection::{TextDirection, TextUnit};
use galaxy_util::path::LineAndColumnArg;
use galaxy_util::user_input::UserInput;
use galaxyui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole};
use galaxyui::actions::StandardAction;
use galaxyui::assets::asset_cache::{AssetCache, AssetHandle, AssetState};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
AnchorPair, Axis, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
Dismiss, Fill, Flex, Hoverable, Icon, MouseStateHandle, OffsetPositioning, OffsetType,
ParentAnchor, ParentElement, PositionedElementOffsetBounds, PositioningAxis, Radius,
ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Stack, XAxisAnchor,
YAxisAnchor,
};
use galaxyui::event::ModifiersState;
use galaxyui::fonts::{FallbackFontEvent, FallbackFontModel};
use galaxyui::image_cache::ImageType;
use galaxyui::keymap::{EditableBinding, FixedBinding, PerPlatformKeystroke};
use galaxyui::platform::{Cursor, OperatingSystem};
use galaxyui::presenter::ChildView;
use galaxyui::r#async::SpawnedFutureHandle;
#[cfg(feature = "local_fs")]
use galaxyui::text::word_boundaries::WordBoundariesPolicy;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::units::Pixels;
use galaxyui::windowing::WindowManager;
use galaxyui::{
accessibility::{AccessibilityContent, ActionAccessibilityContent, GalaxyA11yRole},
assets::asset_cache::{AssetCache, AssetHandle, AssetState},
clipboard::ClipboardContent,
elements::{
AnchorPair, Axis, Border, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
Dismiss, Fill, Flex, Icon, MouseStateHandle, OffsetPositioning, OffsetType, ParentAnchor,
ParentElement, PositionedElementOffsetBounds, PositioningAxis, Radius, ScrollStateHandle,
Scrollable, ScrollableElement, ScrollbarWidth, Stack, XAxisAnchor, YAxisAnchor,
},
event::ModifiersState,
fonts::{FallbackFontEvent, FallbackFontModel},
image_cache::ImageType,
keymap::{EditableBinding, FixedBinding},
platform::{Cursor, OperatingSystem},
presenter::ChildView,
r#async::SpawnedFutureHandle,
ui_components::{
button::ButtonVariant,
components::{Coords, UiComponent, UiComponentStyles},
},
units::Pixels,
windowing, AppContext, BlurContext, CursorInfo, Element, Entity, FocusContext, ModelHandle,
SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
};
use galaxyui::{actions::StandardAction, elements::Hoverable};
use galaxyui::{keymap::PerPlatformKeystroke, windowing::WindowManager};
use crate::{
appearance::Appearance,
cmd_or_ctrl_shift,
editor::InteractionState,
features::FeatureFlag,
notebooks::{
editor::{find_bar::FindBarAction, model::word_unit},
link::{LinkTarget, NotebookLinks, ResolveError},
telemetry::{ActionEntrypoint, BlockInfo, EmbeddedObjectInfo, SelectionMode},
},
server::ids::SyncId,
settings::{AppEditorSettings, FontSettings, SelectionSettings},
terminal::{grid_renderer::URL_COLOR, links::directly_open_link_keybinding_string},
ui_components::icons::ICON_DIMENSIONS,
util::{
bindings::CustomAction,
tooltips::{render_tooltip, should_show_open_in_warp_link, TooltipLink, TooltipRedaction},
},
view_components::DismissibleToast,
};
use super::block_insertion_menu::{BlockInsertionMenuState, BlockInsertionSource};
use super::find_bar::{FindBar, FindBarEvent, FindBarState};
use super::keys::NotebookKeybindings;
use super::link_editor::{LinkEditor, LinkEditorEvent};
use super::model::{NotebooksEditorModel, RichTextEditorModelEvent};
use super::omnibar::{Omnibar, OmnibarEvent};
use super::{rich_text_styles, BlockType, NotebookWorkflow};
use crate::appearance::Appearance;
use crate::cmd_or_ctrl_shift;
use crate::editor::InteractionState;
use crate::features::FeatureFlag;
use crate::notebooks::editor::find_bar::FindBarAction;
use crate::notebooks::editor::model::word_unit;
use crate::notebooks::file::MarkdownDisplayMode;
use crate::notebooks::link::{LinkTarget, NotebookLinks, ResolveError};
use crate::notebooks::telemetry::{ActionEntrypoint, BlockInfo, EmbeddedObjectInfo, SelectionMode};
use crate::server::ids::SyncId;
use crate::settings::{AppEditorSettings, FontSettings, SelectionSettings};
use crate::terminal::grid_renderer::URL_COLOR;
use crate::terminal::links::directly_open_link_keybinding_string;
use crate::ui_components::icons::ICON_DIMENSIONS;
use crate::util::bindings::CustomAction;
#[cfg(feature = "local_fs")]
use crate::util::link_detection::{detect_file_paths, get_word_range_at_offset, DetectedLinkType};
#[cfg(feature = "local_fs")]
use galaxyui::text::word_boundaries::WordBoundariesPolicy;
use super::{
block_insertion_menu::{BlockInsertionMenuState, BlockInsertionSource},
find_bar::{FindBar, FindBarEvent, FindBarState},
keys::NotebookKeybindings,
link_editor::{LinkEditor, LinkEditorEvent},
model::{NotebooksEditorModel, RichTextEditorModelEvent},
omnibar::{Omnibar, OmnibarEvent},
rich_text_styles, BlockType, NotebookWorkflow,
use crate::util::tooltips::{
render_tooltip, should_show_open_in_warp_link, TooltipLink, TooltipRedaction,
};
use crate::view_components::DismissibleToast;
use crate::workspace::WorkspaceAction;
#[cfg(test)]
#[path = "view_tests.rs"]
@@ -465,7 +450,7 @@ pub fn init(app: &mut AppContext) {
.with_key_binding("ctrl-f"),
EditableBinding::new(
// This doesn't reuse the move_to_line_start naming from the terminal input editor to
// distinguish between soft-wrappped line and hard-wrapped line (paragraph) movement.
// distinguish between soft-wrapped line and hard-wrapped line (paragraph) movement.
"editor_view:move_to_paragraph_start",
"Move to start of paragraph",
EditorViewAction::MoveToParagraphStart,
@@ -884,6 +869,14 @@ pub enum EditorViewAction {
line_and_column_num: Option<LineAndColumnArg>,
force_open_in_warp: bool,
},
/// Signal from a Mermaid toggle view that the user changed the display mode for a block.
MermaidDisplayModeSelected {
start_anchor: Anchor,
mode: MarkdownDisplayMode,
},
OpenMermaidDiagramLightbox {
block_start: CharOffset,
},
}
impl EditorViewAction {
@@ -911,6 +904,34 @@ impl EditorViewAction {
}
}
#[derive(Default)]
struct LayoutAffectingAssetLoads {
loading: HashSet<AssetHandle>,
loaded_needs_relayout: bool,
}
enum LayoutAffectingAssetLoad {
Loading(AssetHandle),
LoadedNeedsRelayout,
}
fn mermaid_diagram_needs_loaded_layout(
config: &warp_editor::render::model::ImageBlockConfig,
image: &ImageType,
) -> bool {
let ImageType::Svg { svg } = image else {
return false;
};
let intrinsic_size = svg.size();
let intrinsic_width = intrinsic_size.width();
let intrinsic_height = intrinsic_size.height();
if intrinsic_width <= 0. || intrinsic_height <= 0. {
return false;
}
let expected_height = config.width.as_f32() * intrinsic_height / intrinsic_width;
(config.height.as_f32() - expected_height).abs() > 0.5
}
pub enum EditorViewEvent {
Edited,
Focused,
@@ -962,7 +983,7 @@ struct MouseStateHandles {
secondary_link_mouse_handle: MouseStateHandle,
}
// Represents the states of an ongoing mouse event. Note that these states are mutally exclusive:
// Represents the states of an ongoing mouse event. Note that these states are mutually exclusive:
// If one is selecting, they couldn't be initiating task list toggling at the same time.
enum OngoingMouseEvent {
Selecting,
@@ -1110,7 +1131,7 @@ impl RichTextEditorView {
// Ensure that we re-render when the rendering model changes.
ctx.observe(&model.as_ref(ctx).render_state().clone(), |me, _, ctx| {
me.watch_visible_layout_affecting_asset_loads(ctx);
me.watch_layout_affecting_asset_loads(ctx);
ctx.notify();
});
@@ -1298,37 +1319,69 @@ impl RichTextEditorView {
}
}
fn visible_layout_affecting_asset_loads(
&self,
ctx: &ViewContext<Self>,
) -> HashSet<AssetHandle> {
fn layout_affecting_asset_loads(&self, ctx: &ViewContext<Self>) -> LayoutAffectingAssetLoads {
let render_state = self.model.as_ref(ctx).render_state().clone();
let render_state = render_state.as_ref(ctx);
let viewport = render_state.viewport();
let asset_cache = AssetCache::as_ref(ctx);
let mut loads = LayoutAffectingAssetLoads::default();
render_state
.content()
.viewport_items(viewport.height(), viewport.width(), viewport.scroll_top())
.filter_map(|(_, block)| Self::layout_affecting_asset_load(block, asset_cache))
.collect()
.block_items()
.filter_map(|block| Self::layout_affecting_asset_load(block, asset_cache))
.for_each(|load| match load {
LayoutAffectingAssetLoad::Loading(handle) => {
loads.loading.insert(handle);
}
LayoutAffectingAssetLoad::LoadedNeedsRelayout => {
loads.loaded_needs_relayout = true;
}
});
loads
}
fn layout_affecting_asset_load(
block: &BlockItem,
asset_cache: &AssetCache,
) -> Option<AssetHandle> {
let BlockItem::MermaidDiagram { asset_source, .. } = block else {
return None;
};
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loading { handle } => Some(handle),
AssetState::Loaded { .. } | AssetState::Evicted | AssetState::FailedToLoad(_) => None,
) -> Option<LayoutAffectingAssetLoad> {
match block {
BlockItem::MermaidDiagram {
asset_source,
config,
..
} => match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loading { handle } => Some(LayoutAffectingAssetLoad::Loading(handle)),
AssetState::Loaded { data } => {
mermaid_diagram_needs_loaded_layout(config, data.as_ref())
.then_some(LayoutAffectingAssetLoad::LoadedNeedsRelayout)
}
AssetState::Evicted | AssetState::FailedToLoad(_) => None,
},
// Mermaid-labeled code blocks that are currently rendered as code (because the
// Mermaid source has not yet been verified as parseable) also need to be watched
// so we can re-run layout when the asset load resolves.
BlockItem::RunnableCodeBlock {
pending_mermaid_asset: Some(asset_source),
..
} => match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loading { handle } => Some(LayoutAffectingAssetLoad::Loading(handle)),
AssetState::Loaded { .. } | AssetState::Evicted | AssetState::FailedToLoad(_) => {
None
}
},
_ => None,
}
}
fn watch_visible_layout_affecting_asset_loads(&mut self, ctx: &mut ViewContext<Self>) {
for handle in self.visible_layout_affecting_asset_loads(ctx) {
fn watch_layout_affecting_asset_loads(&mut self, ctx: &mut ViewContext<Self>) {
let loads = self.layout_affecting_asset_loads(ctx);
if loads.loaded_needs_relayout {
self.model.update(ctx, |model, ctx| {
model.rebuild_layout(ctx);
});
}
for handle in loads.loading {
if self
.pending_layout_affecting_asset_loads
.insert(handle.clone())
@@ -1338,8 +1391,9 @@ impl RichTextEditorView {
ctx.spawn(future, move |me, (), ctx| {
me.pending_layout_affecting_asset_loads.remove(&handle);
me.model.update(ctx, |model, ctx| {
if matches!(model.interaction_state(ctx), InteractionState::Selectable)
{
if Self::should_rebuild_layout_after_layout_affecting_asset_load(
model.interaction_state(ctx),
) {
model.rebuild_layout(ctx);
}
});
@@ -1352,6 +1406,15 @@ impl RichTextEditorView {
}
}
fn should_rebuild_layout_after_layout_affecting_asset_load(state: InteractionState) -> bool {
matches!(
state,
InteractionState::Selectable
| InteractionState::Editable
| InteractionState::EditableWithInvalidSelection
)
}
/// Handles changes to the lifecycle state.
fn handle_windowing_state_event(
&mut self,
@@ -1815,6 +1878,26 @@ impl RichTextEditorView {
self.model
.update(ctx, |model, ctx| model.select_command_at(block_start, ctx))
}
fn open_mermaid_lightbox(&self, block_start: CharOffset, ctx: &mut ViewContext<Self>) {
let asset_source = {
let render_state = self.model.as_ref(ctx).render_state().clone();
let content = render_state.as_ref(ctx).content();
let block = content.block_at_offset(block_start);
block.and_then(|b| match b.item {
BlockItem::MermaidDiagram { asset_source, .. } => Some(asset_source.clone()),
_ => None,
})
};
if let Some(asset_source) = asset_source {
ctx.dispatch_typed_action(&WorkspaceAction::OpenLightbox {
images: vec![ui_components::lightbox::LightboxImage {
source: ui_components::lightbox::LightboxImageSource::Resolved { asset_source },
description: None,
}],
initial_index: 0,
});
}
}
fn maybe_open_file_or_url(
&mut self,
@@ -1880,6 +1963,18 @@ impl RichTextEditorView {
};
if let Some(url) = url {
if url.starts_with('#')
&& (cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable))
{
let scrolled = self
.model
.update(ctx, |model, ctx| model.scroll_to_matching_header(&url, ctx));
if scrolled {
self.open_link = None;
ctx.notify();
return;
}
}
// In read-only comment chips (Selectable), open the link directly on
// click instead of showing a tooltip.
if cmd || matches!(self.interaction_state(ctx), InteractionState::Selectable) {
@@ -2960,6 +3055,20 @@ impl TypedActionView for RichTextEditorView {
} => self.model.update(ctx, |model, ctx| {
model.update_code_block_type_at_offset(code_block_type, start_anchor.clone(), ctx)
}),
MermaidDisplayModeSelected { start_anchor, mode } => {
let offset = self
.model
.as_ref(ctx)
.buffer_selection_model()
.as_ref(ctx)
.resolve_anchor(start_anchor);
if let Some(offset) = offset {
let mode = *mode;
self.model.update(ctx, |model, ctx| {
model.set_mermaid_render_mode(offset, mode, ctx);
});
}
}
CopyTextToClipboard {
text,
block,
@@ -2980,6 +3089,9 @@ impl TypedActionView for RichTextEditorView {
.model
.update(ctx, |model, ctx| model.remove_embedding_at(*offset, ctx)),
MiddleClickPaste => self.middle_click_paste(ctx),
OpenMermaidDiagramLightbox { block_start } => {
self.open_mermaid_lightbox(*block_start, ctx);
}
OpenFile {
path,
line_and_column_num,
@@ -3226,6 +3338,8 @@ impl TypedActionView for RichTextEditorView {
| EditorViewAction::RunWorkflow(_)
| EditorViewAction::RemoveEmbeddingAt(_)
| EditorViewAction::OpenFile { .. }
| EditorViewAction::MermaidDisplayModeSelected { .. }
| EditorViewAction::OpenMermaidDiagramLightbox { .. }
| EditorViewAction::VimUserTyped(_) => ActionAccessibilityContent::Empty,
}
}
@@ -3362,10 +3476,7 @@ impl RichTextAction<RichTextEditorView> for EditorViewAction {
Location::Block {
start_offset,
end_offset,
block_type:
HitTestBlockType::Code
| HitTestBlockType::MermaidDiagram
| HitTestBlockType::Embedding,
block_type: HitTestBlockType::MermaidDiagram,
..
} => match click_count {
1 if modifiers.shift => view
@@ -3381,6 +3492,17 @@ impl RichTextAction<RichTextEditorView> for EditorViewAction {
.or(Some(EditorViewAction::SelectBlock {
block_start: start_offset,
})),
1 => Some(EditorViewAction::OpenMermaidDiagramLightbox {
block_start: start_offset,
}),
2 => Some(EditorViewAction::ExitCommandSelection),
_ => None,
},
Location::Block {
start_offset,
block_type: HitTestBlockType::Code | HitTestBlockType::Embedding,
..
} => match click_count {
1 => Some(EditorViewAction::SelectBlock {
block_start: start_offset,
}),
+279 -21
View File
@@ -1,44 +1,51 @@
use crate::features::FeatureFlag;
use std::path::PathBuf;
use std::sync::Arc;
use async_channel::TryRecvError;
use galaxy_editor::render::{
element::RichTextAction,
model::{HitTestBlockType, Location, RenderEvent},
use parking_lot::Mutex;
use string_offset::CharOffset;
use tempfile::tempdir;
use galaxy_editor::content::mermaid_diagram::mermaid_asset_source;
use galaxy_editor::render::element::RichTextAction;
use galaxy_editor::render::model::{
BlockItem, BlockSpacing, HitTestBlockType, ImageBlockConfig, Location, RenderEvent,
};
use galaxy_util::user_input::UserInput;
use std::sync::Arc;
use string_offset::CharOffset;
use galaxyui::assets::asset_cache::{AssetCache, AssetState};
use galaxyui::event::ModifiersState;
use galaxyui::image_cache::ImageType;
use galaxyui::platform::WindowStyle;
use galaxyui::presenter::ChildView;
use galaxyui::r#async::block_on;
use galaxyui::units::Pixels;
use galaxyui::windowing::WindowManager;
use galaxyui::{
platform::WindowStyle, presenter::ChildView, App, Element, Entity, View, ViewHandle,
};
use galaxyui::{SingletonEntity, TypedActionView, WindowId};
use galaxyui::{App, Element, Entity, SingletonEntity, TypedActionView, View, ViewHandle, WindowId};
use super::{EditorViewAction, RichTextEditorConfig, RichTextEditorView};
use super::{EditorViewAction, LayoutAffectingAssetLoad, RichTextEditorConfig, RichTextEditorView};
use crate::appearance::Appearance;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::editor::InteractionState;
use crate::features::FeatureFlag;
use crate::notebooks::editor::keys::NotebookKeybindings;
use crate::notebooks::editor::link_editor::LinkEditorAction;
use crate::notebooks::editor::model::NotebooksEditorModel;
use crate::notebooks::editor::rich_text_styles;
use crate::notebooks::link::{NotebookLinks, SessionSource};
use crate::notebooks::file::MarkdownDisplayMode;
use crate::notebooks::link::{LinkEvent, NotebookLinks, SessionSource};
use crate::search::files::model::FileSearchModel;
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::settings::FontSettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::auth::AuthStateProvider;
use crate::terminal::keys::TerminalKeybindings;
use crate::terminal::model::session::Session;
use crate::terminal::shell::ShellType;
use crate::terminal::ShellLaunchData;
use crate::test_util::assert_eventually;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspace::ActiveSession;
use crate::UserWorkspaces;
use crate::{
cloud_object::model::persistence::CloudModel, search::files::model::FileSearchModel,
GlobalResourceHandles, GlobalResourceHandlesProvider,
};
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider, UserWorkspaces};
/// Container for a [`RichTextEditorView`] in unit tests.
struct TestView {
@@ -136,6 +143,25 @@ async fn reset_editor_with_markdown(
.await;
}
fn link_offset(
editor: &RichTextEditorView,
link_url: &str,
ctx: &warpui::AppContext,
) -> CharOffset {
let max_offset = editor.markdown(ctx).chars().count();
(0..=max_offset)
.map(CharOffset::from)
.find(|offset| {
editor
.model
.as_ref(ctx)
.link_url_at(*offset, ctx)
.as_deref()
== Some(link_url)
})
.expect("Expected link URL to exist in editor")
}
fn rendered_mermaid_block_range(
editor: &RichTextEditorView,
ctx: &galaxyui::AppContext,
@@ -159,6 +185,68 @@ fn rendered_mermaid_block_range(
None
}
#[test]
fn test_loaded_mermaid_diagram_with_placeholder_height_needs_relayout() {
App::test((), |app| async move {
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let contents = "graph TD\nA[Start] --> B[Finish]\n";
let asset_source = mermaid_asset_source(contents);
let pending = app.read(|ctx| {
let asset_cache = AssetCache::as_ref(ctx);
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
AssetState::Loading { handle } => handle.when_loaded(asset_cache),
AssetState::Loaded { .. } => None,
AssetState::Evicted => panic!("Mermaid asset should not be evicted during test"),
AssetState::FailedToLoad(err) => {
panic!("Mermaid asset should load successfully: {err}")
}
}
});
if let Some(future) = pending {
future.await;
}
app.read(|ctx| {
let config = ImageBlockConfig {
width: Pixels::new(640.),
height: Pixels::new(120.),
spacing: BlockSpacing::default(),
};
let block = BlockItem::MermaidDiagram {
content_length: CharOffset::from(contents.chars().count()),
asset_source,
config,
};
let asset_cache = AssetCache::as_ref(ctx);
assert!(matches!(
RichTextEditorView::layout_affecting_asset_load(&block, asset_cache),
Some(LayoutAffectingAssetLoad::LoadedNeedsRelayout)
));
});
})
}
#[test]
fn layout_affecting_asset_loads_rebuild_selectable_and_editable_layouts() {
assert!(
RichTextEditorView::should_rebuild_layout_after_layout_affecting_asset_load(
InteractionState::Selectable,
)
);
assert!(
RichTextEditorView::should_rebuild_layout_after_layout_affecting_asset_load(
InteractionState::Editable,
)
);
assert!(
RichTextEditorView::should_rebuild_layout_after_layout_affecting_asset_load(
InteractionState::EditableWithInvalidSelection,
)
);
}
#[test]
fn test_focus() {
App::test((), |mut app| async move {
@@ -277,6 +365,22 @@ fn test_omnibar_is_hidden_for_rendered_mermaid_selection() {
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
// Blocks default to Raw; explicitly enable Rendered mode for the Mermaid block.
let render_state = editor_view.read(&app, |editor, ctx| {
editor.model.as_ref(ctx).render_state().clone()
});
editor_view.update(&mut app, |editor, ctx| {
editor.model.update(ctx, |model, ctx| {
model.set_mermaid_render_mode(
CharOffset::from(7),
MarkdownDisplayMode::Rendered,
ctx,
);
});
});
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
.await;
editor_view.update(&mut app, |editor, ctx| {
let mermaid_block_range =
rendered_mermaid_block_range(editor, ctx).expect("Expected rendered Mermaid block");
@@ -300,6 +404,22 @@ fn test_shift_click_on_rendered_mermaid_dispatches_selection_update_to_block_bou
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
// Blocks default to Raw; explicitly enable Rendered mode for the Mermaid block.
let render_state = editor_view.read(&app, |editor, ctx| {
editor.model.as_ref(ctx).render_state().clone()
});
editor_view.update(&mut app, |editor, ctx| {
editor.model.update(ctx, |model, ctx| {
model.set_mermaid_render_mode(
CharOffset::from(7),
MarkdownDisplayMode::Rendered,
ctx,
);
});
});
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
.await;
editor_view.update(&mut app, |editor, ctx| {
editor.selection_start(CharOffset::from(2), false, ctx);
editor.selection_end(ctx);
@@ -380,6 +500,22 @@ fn test_drag_on_rendered_mermaid_dispatches_selection_update_to_block_boundary()
let markdown = "Before\n```mermaid\ngraph TD\nA --> B\n```\nAfter";
reset_editor_with_markdown(&mut app, &editor_view, markdown).await;
// Blocks default to Raw; explicitly enable Rendered mode for the Mermaid block.
let render_state = editor_view.read(&app, |editor, ctx| {
editor.model.as_ref(ctx).render_state().clone()
});
editor_view.update(&mut app, |editor, ctx| {
editor.model.update(ctx, |model, ctx| {
model.set_mermaid_render_mode(
CharOffset::from(7),
MarkdownDisplayMode::Rendered,
ctx,
);
});
});
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
.await;
editor_view.update(&mut app, |editor, ctx| {
editor.selection_start(CharOffset::from(2), false, ctx);
});
@@ -524,6 +660,128 @@ fn test_link_editing() {
});
}
#[test]
fn test_editable_markdown_anchor_click_opens_link_tooltip() {
App::test((), |mut app| async move {
let (_, editor_view, _) = initialize_editor(&mut app);
reset_editor_with_markdown(&mut app, &editor_view, "- [Goal](#goal)\n\n## Goal").await;
let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#goal", ctx));
editor_view.update(&mut app, |editor, ctx| {
editor.handle_action(
&EditorViewAction::MaybeOpenFileOrUrl {
offset,
link_in_text: None,
cmd: false,
},
ctx,
);
});
editor_view.read(&app, |editor, _ctx| {
let open_link = editor
.open_link
.as_ref()
.expect("Editable anchor click should show the link tooltip");
assert_eq!(open_link.url, "#goal");
assert!(open_link.editable);
});
});
}
#[test]
fn test_cmd_click_markdown_anchor_navigates_without_link_tooltip() {
App::test((), |mut app| async move {
let (_, editor_view, _) = initialize_editor(&mut app);
reset_editor_with_markdown(&mut app, &editor_view, "- [Goal](#goal)\n\n## Goal").await;
let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#goal", ctx));
editor_view.update(&mut app, |editor, ctx| {
editor.handle_action(
&EditorViewAction::MaybeOpenFileOrUrl {
offset,
link_in_text: None,
cmd: true,
},
ctx,
);
});
editor_view.read(&app, |editor, _ctx| {
assert!(
editor.open_link.is_none(),
"Cmd-click anchor navigation should not show the link tooltip"
);
});
});
}
#[test]
fn test_cmd_click_missing_markdown_anchor_falls_back_to_link_resolution() {
App::test((), |mut app| async move {
let (window_id, editor_view, _) = initialize_editor(&mut app);
let base = tempdir().expect("Expected temp dir");
let fallback_path = base.path().join("#missing.png");
std::fs::File::create(&fallback_path).expect("Expected fallback file");
let session = Arc::new(Session::test().with_shell_launch_data(
ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/bash"),
shell_type: ShellType::Bash,
},
));
ActiveSession::handle(&app).update(&mut app, |active_session, ctx| {
active_session.set_session_for_test(
window_id,
session.clone(),
Some(base.path()),
None,
ctx,
);
});
reset_editor_with_markdown(
&mut app,
&editor_view,
"- [Missing](#missing.png)\n\n## Goal",
)
.await;
let events = Arc::new(Mutex::new(Vec::<LinkEvent>::new()));
let links = editor_view.read(&app, |editor, _ctx| editor.links.clone());
{
let events = events.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&links, move |_, event, _| {
events.lock().push(event.clone());
})
});
}
let offset = editor_view.read(&app, |editor, ctx| link_offset(editor, "#missing.png", ctx));
editor_view.update(&mut app, |editor, ctx| {
editor.handle_action(
&EditorViewAction::MaybeOpenFileOrUrl {
offset,
link_in_text: None,
cmd: true,
},
ctx,
);
});
assert_eventually!(
events.lock().iter().any(|event| {
matches!(
event,
LinkEvent::OpenFileWithTarget { path, .. } if path == &fallback_path
)
}),
"Missing anchor click should fall back to link resolution: {:?}",
events.lock().clone()
);
});
}
#[test]
fn test_run_command_from_text_selection() {
// This tests that, starting from a text selection, we can still run a command.
+323 -120
View File
@@ -1,77 +1,66 @@
use std::{
mem,
path::{Path, PathBuf},
sync::Arc,
};
use std::mem;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use galaxy_util::path::user_friendly_path;
#[cfg(feature = "local_fs")]
use galaxyui::clipboard::ClipboardContent;
use galaxyui::{
accessibility::{AccessibilityContent, GalaxyA11yRole},
elements::{
Align, Container, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, SavePosition, Shrinkable,
Stack, Text,
},
keymap::EditableBinding,
presenter::ChildView,
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
#[cfg(feature = "local_fs")]
use crate::notebooks::post_process_notebook;
use crate::{
appearance::Appearance,
cmd_or_ctrl_shift,
editor::InteractionState,
menu::{MenuItem, MenuItemFields},
notebooks::editor::{model::NotebooksEditorModel, rich_text_styles},
pane_group::{
focus_state::PaneFocusHandle,
pane::view,
pane::view::header::components::{
render_pane_header_buttons, render_pane_header_title_text, render_three_column_header,
CenteredHeaderEdgeWidth,
},
BackingView, PaneConfiguration, PaneEvent,
},
safe_warn, send_telemetry_from_ctx,
server::telemetry::{NotebookActionEvent, NotebookTelemetryMetadata, TelemetryEvent},
settings::FontSettings,
terminal::model::session::Session,
ui_components::icons::Icon,
view_components::{MarkdownToggleEvent, MarkdownToggleView},
workflows::{WorkflowSource, WorkflowType},
workspace::ActiveSession,
};
use super::{
context_menu::{show_rich_editor_context_menu, ContextMenuAction, ContextMenuState},
editor::view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView},
link::{NotebookLinks, SessionSource},
styles,
telemetry::NotebookTelemetryAction,
NotebookLocation,
};
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeSource;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::FileTarget;
#[cfg(not(target_family = "wasm"))]
use remote_server::manager::RemoteServerManager;
use galaxy_core::ui::icons::ICON_DIMENSIONS;
use galaxy_editor::model::CoreEditorModel;
#[cfg(feature = "local_fs")]
use galaxy_files::{FileModel, FileModelEvent};
#[cfg(feature = "local_fs")]
use galaxy_util::file::FileId;
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
use galaxy_util::path::user_friendly_path;
use galaxy_util::remote_path::RemotePath;
use galaxyui::accessibility::{AccessibilityContent, WarpA11yRole};
#[cfg(feature = "local_fs")]
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
Align, Container, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Flex,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, SavePosition, Shrinkable,
Stack, Text,
};
use galaxyui::keymap::EditableBinding;
use galaxyui::presenter::ChildView;
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use super::context_menu::{show_rich_editor_context_menu, ContextMenuAction, ContextMenuState};
use super::editor::view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView};
use super::link::{NotebookLinks, SessionSource};
use super::telemetry::NotebookTelemetryAction;
use super::{styles, NotebookLocation};
use crate::appearance::Appearance;
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeSource;
use crate::editor::InteractionState;
use crate::menu::{MenuItem, MenuItemFields};
use crate::notebooks::editor::model::NotebooksEditorModel;
use crate::notebooks::editor::rich_text_styles;
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::pane::view;
use crate::pane_group::pane::view::header::components::{
render_pane_header_buttons, render_pane_header_title_text, render_three_column_header,
CenteredHeaderEdgeWidth,
};
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
use crate::server::telemetry::{NotebookActionEvent, NotebookTelemetryMetadata, TelemetryEvent};
use crate::settings::FontSettings;
use crate::terminal::model::session::Session;
use crate::ui_components::icons::Icon;
pub use crate::util::openable_file_type::is_markdown_file;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::FileTarget;
use crate::view_components::{MarkdownToggleEvent, MarkdownToggleView};
use crate::workflows::{WorkflowSource, WorkflowType};
use crate::workspace::ActiveSession;
use crate::{cmd_or_ctrl_shift, safe_warn, send_telemetry_from_ctx};
/// Display mode for markdown files shown via the header segmented control.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -105,6 +94,8 @@ pub struct FileNotebookView {
/// This is preserved so we can restore it when toggling between raw and rendered Markdown.
#[cfg(feature = "local_fs")]
code_source: Option<CodeSource>,
/// Persistent hover state for the header title tooltip.
header_title_mouse_state: MouseStateHandle,
}
#[derive(Debug, Clone)]
@@ -144,6 +135,7 @@ pub enum FileNotebookAction {
OpenAsCode,
ContextMenu(ContextMenuAction),
ToggleMarkdownDisplayMode(MarkdownDisplayMode),
ToggleMaximized,
}
impl From<ContextMenuAction> for FileNotebookAction {
@@ -153,33 +145,36 @@ impl From<ContextMenuAction> for FileNotebookAction {
}
/// Information about the notebook's backing file.
// TODO: This should probably build on the `galaxy_files` abstractions.
#[derive(Debug, Clone)]
enum SourceFile {
Local {
/// The full path to the open file - for now, _only_ local files are supported.
///
/// See [this comment](https://docs.google.com/document/d/18h7VzSAl6r5a94CovShlpPSahYqECX9WZliLjUqUsko/edit?disco=AAAA5Y1THuk);
/// we cannot use [`PathBuf`] to represent non-local paths.
local_path: PathBuf,
/// A file identified by a local or remote path.
FileBased {
path: LocalOrRemotePath,
/// The session context for this file. Only meaningful for local paths;
/// remote paths carry their own host information.
session: Option<Arc<Session>>,
},
Static {
title: String,
},
/// Static content provided inline (not backed by a file on disk).
Static { title: String },
}
impl SourceFile {
fn local_path(&self) -> Option<&Path> {
/// Returns the path for this source, if it is file-based.
fn path(&self) -> Option<&LocalOrRemotePath> {
match self {
SourceFile::Local { local_path, .. } => Some(local_path.as_path()),
SourceFile::FileBased { path, .. } => Some(path),
SourceFile::Static { .. } => None,
}
}
/// Returns the local path, if this is a local file-based source.
fn local_path(&self) -> Option<&Path> {
self.path().and_then(|p| p.to_local_path())
}
fn display_name(&self) -> String {
match self {
SourceFile::Local { local_path, .. } => local_path.display().to_string(),
SourceFile::FileBased { path, .. } => path.display_path(),
SourceFile::Static { title } => title.clone(),
}
}
@@ -194,6 +189,11 @@ enum FileState {
}
impl FileState {
/// Returns the path for the open file, if it is file-based.
fn path(&self) -> Option<&LocalOrRemotePath> {
self.source().and_then(|src| src.path())
}
/// The path to the open file, if it exists and is local.
fn local_path(&self) -> Option<&Path> {
self.source().and_then(|src| src.local_path())
@@ -244,7 +244,9 @@ impl FileNotebookView {
let editor_model = ctx.add_model(|ctx| {
let styles = rich_text_styles(Appearance::as_ref(ctx), FontSettings::as_ref(ctx));
NotebooksEditorModel::new(styles, window_id, ctx)
let mut model = NotebooksEditorModel::new(styles, window_id, ctx);
model.set_default_mermaid_display_mode(MarkdownDisplayMode::Rendered, ctx);
model
});
let editor = ctx.add_typed_action_view(|ctx| {
let mut view = RichTextEditorView::new(
@@ -294,6 +296,7 @@ impl FileNotebookView {
display_mode_segmented_control,
#[cfg(feature = "local_fs")]
code_source: None,
header_title_mouse_state: Default::default(),
}
}
@@ -368,6 +371,36 @@ impl FileNotebookView {
ctx.notify();
}
/// Open a file from a local or remote path.
///
/// For local paths, uses `FileModel` to load and watch for changes.
/// The `session` is used to resolve display names and link context for
/// local paths. If `None` for a local path, the view falls back to the
/// active local session when one becomes available. For remote paths the
/// session is ignored because the `RemotePath` already carries host info.
/// For remote paths, fetches content via the remote server.
pub fn open(
&mut self,
path: LocalOrRemotePath,
session: Option<Arc<Session>>,
ctx: &mut ViewContext<Self>,
) {
match path {
LocalOrRemotePath::Local(local_path) => {
// For local paths, try to resolve a local session if none was provided.
let session = session.or_else(|| {
ActiveSession::as_ref(ctx)
.session(ctx.window_id())
.filter(|s| s.is_local())
});
self.open_local(local_path, session, ctx);
}
LocalOrRemotePath::Remote(remote_path) => {
self.open_remote(remote_path, ctx);
}
}
}
/// Asynchronously open a local file, watching for local file changes.
pub fn open_local(
&mut self,
@@ -388,8 +421,8 @@ impl FileNotebookView {
});
}
self.file_state = FileState::Loading(SourceFile::Local {
local_path: local_path.clone(),
self.file_state = FileState::Loading(SourceFile::FileBased {
path: LocalOrRemotePath::Local(local_path.clone()),
session: session.clone(),
});
@@ -415,8 +448,7 @@ impl FileNotebookView {
}
match event {
FileModelEvent::FileLoaded { content, .. } => {
let cleaned = post_process_notebook(content);
me.set_content(&cleaned, ctx);
me.set_content(content, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::OpenNotebook(me.open_telemetry_metadata(ctx)),
ctx
@@ -425,8 +457,8 @@ impl FileNotebookView {
// Record the canonical path instead of the input path when available.
if let Some(canonical_path) = file_model.as_ref(ctx).file_path(file_id)
{
me.file_state = FileState::Loaded(SourceFile::Local {
local_path: canonical_path,
me.file_state = FileState::Loaded(SourceFile::FileBased {
path: LocalOrRemotePath::Local(canonical_path),
session: session.clone(),
});
}
@@ -457,8 +489,7 @@ impl FileNotebookView {
ctx.notify();
}
FileModelEvent::FileUpdated { content, .. } => {
let cleaned = post_process_notebook(content);
me.set_content(&cleaned, ctx);
me.set_content(content, ctx);
}
FileModelEvent::FileSaved { .. } | FileModelEvent::FailedToSave { .. } => {}
}
@@ -473,8 +504,8 @@ impl FileNotebookView {
safe: ("Local filesystem access is not available in this build"),
full: ("Local filesystem access is not available in this build (feature \"local_fs\" disabled)")
);
self.file_state = FileState::Error(SourceFile::Local {
local_path,
self.file_state = FileState::Error(SourceFile::FileBased {
path: LocalOrRemotePath::Local(local_path),
session,
});
ctx.notify();
@@ -524,32 +555,136 @@ impl FileNotebookView {
fn reload_file(&mut self, ctx: &mut ViewContext<Self>) {
// We can take the file state here because either it's (a) already NoFile or (b) about to
// be replaced with a loading state.
let (local_path, session) = match mem::replace(&mut self.file_state, FileState::NoFile) {
let (path, session) = match mem::replace(&mut self.file_state, FileState::NoFile) {
FileState::NoFile => return,
FileState::Loading(source) | FileState::Error(source) | FileState::Loaded(source) => {
match source {
SourceFile::Local {
local_path,
session,
} => (local_path, session),
SourceFile::FileBased { path, session } => (path, session),
SourceFile::Static { .. } => return,
}
}
};
self.open_local(local_path, session, ctx);
self.open(path, session, ctx);
}
/// Open a remote file by fetching its content from the remote server.
fn open_remote(&mut self, remote_path: RemotePath, ctx: &mut ViewContext<Self>) {
let path_str = remote_path.path.as_str().to_string();
let display_name = remote_path
.path
.file_name()
.unwrap_or(path_str.as_str())
.to_string();
self.pane_configuration.update(ctx, |pane_config, ctx| {
pane_config.set_title(display_name, ctx);
});
let lor_path = LocalOrRemotePath::Remote(remote_path.clone());
self.file_state = FileState::Loading(SourceFile::FileBased {
path: lor_path,
session: None,
});
let host_id = remote_path.host_id.clone();
let manager = remote_server::manager::RemoteServerManager::handle(ctx);
// Subscribe to host connect/disconnect events so the disconnection
// banner appears/disappears when the remote session state changes.
let watched_host_id = host_id.clone();
ctx.subscribe_to_model(&manager, move |_me, _handle, event, ctx| {
use remote_server::manager::RemoteServerManagerEvent;
match event {
RemoteServerManagerEvent::HostDisconnected { host_id }
| RemoteServerManagerEvent::HostConnected { host_id }
if *host_id == watched_host_id =>
{
ctx.notify();
}
_ => {}
}
});
let request = remote_server::proto::ReadFileContextRequest {
files: vec![remote_server::proto::ReadFileContextFile {
path: path_str,
line_ranges: vec![],
}],
max_file_bytes: None,
max_batch_bytes: None,
};
let handle = manager.as_ref(ctx).host_request_handle(&host_id);
ctx.spawn(
async move { handle.read_file_context(request).await },
move |me, result, ctx| match result {
Ok(response) => {
if let Some(file_ctx) = response.file_contexts.first() {
let text = match &file_ctx.content {
Some(
remote_server::proto::file_context_proto::Content::TextContent(
text,
),
) => text.as_str(),
_ => "",
};
me.set_content(text, ctx);
me.file_state = match mem::replace(&mut me.file_state, FileState::NoFile) {
FileState::Loading(source) => FileState::Loaded(source),
other => other,
};
me.pane_configuration.update(ctx, |pane_config, ctx| {
pane_config.refresh_pane_header_overflow_menu_items(ctx);
});
ctx.notify();
ctx.emit(FileNotebookEvent::FileLoaded);
} else if let Some(failed) = response.failed_files.first() {
let error_msg = failed
.error
.as_ref()
.map(|e| e.message.as_str())
.unwrap_or("unknown error");
safe_warn!(
safe: ("Failed to read remote markdown file"),
full: ("Failed to read remote markdown file: {error_msg}")
);
me.file_state = match mem::replace(&mut me.file_state, FileState::NoFile) {
FileState::Loading(source) => FileState::Error(source),
other => other,
};
ctx.notify();
}
}
Err(err) => {
safe_warn!(
safe: ("Remote server error reading markdown file"),
full: ("Remote server error reading markdown file: {err}")
);
me.file_state = match mem::replace(&mut me.file_state, FileState::NoFile) {
FileState::Loading(source) => FileState::Error(source),
other => other,
};
ctx.notify();
}
},
);
}
#[cfg(feature = "local_fs")]
fn open_as_code(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(path) = self.local_path() {
if let Some(path) = self.file_state.path().cloned() {
// Emit an event to the pane group to handle the replacement
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
path: path.clone(),
path,
source: self.code_source.clone(),
}));
}
}
/// The path to the currently-open file (local or remote), if any.
pub fn path(&self) -> Option<&LocalOrRemotePath> {
self.file_state.path()
}
/// The path to the currently-open file, if it is local.
pub fn local_path(&self) -> Option<PathBuf> {
self.file_state.local_path().map(Path::to_path_buf)
@@ -564,19 +699,13 @@ impl FileNotebookView {
self.links.clone()
}
#[cfg(feature = "local_fs")]
fn is_markdown_file(&self) -> bool {
self.file_state
.local_path()
.map(is_markdown_file)
.path()
.map(|p| is_markdown_file(Path::new(&p.display_path())))
.unwrap_or(false)
}
#[cfg(not(feature = "local_fs"))]
fn is_markdown_file(&self) -> bool {
false
}
fn update_editor_display_mode(&mut self, ctx: &mut ViewContext<Self>) {
match self.markdown_display_mode {
MarkdownDisplayMode::Rendered => {
@@ -787,13 +916,36 @@ impl FileNotebookView {
.finish()
}
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
/// Returns `true` when this notebook is backed by a remote file whose
/// host no longer has any connected session.
#[cfg(not(target_family = "wasm"))]
fn is_remote_disconnected(&self, app: &AppContext) -> bool {
let Some(LocalOrRemotePath::Remote(remote_path)) = self.file_state.path() else {
return false;
};
RemoteServerManager::as_ref(app)
.client_for_host(&remote_path.host_id)
.is_none()
}
fn render_body(&self, appearance: &Appearance, _app: &AppContext) -> Box<dyn Element> {
let body = match &self.file_state {
FileState::NoFile => self.render_no_file(appearance),
FileState::Loading(source) => self.render_loading(source, appearance),
FileState::Error(source) => self.render_error(source, appearance),
FileState::Loaded(_) => ChildView::new(&self.editor).finish(),
};
#[cfg(not(target_family = "wasm"))]
if matches!(self.file_state, FileState::Loaded(_)) && self.is_remote_disconnected(_app) {
let banner =
crate::code::local_code_editor::render_remote_disconnected_banner(appearance);
let mut col = Flex::column();
col.add_child(banner);
col.add_child(Shrinkable::new(1., styles::wrap_body(body)).finish());
return col.finish();
}
styles::wrap_body(body)
}
}
@@ -820,7 +972,7 @@ impl View for FileNotebookView {
let column = Flex::column().with_children([
self.render_title(appearance, font_settings),
Shrinkable::new(1., self.render_body(appearance)).finish(),
Shrinkable::new(1., self.render_body(appearance, app)).finish(),
]);
let mut stack = Stack::new().with_child(column.finish());
@@ -864,24 +1016,30 @@ impl TypedActionView for FileNotebookView {
FileNotebookAction::ReloadFile => self.reload_file(ctx),
#[cfg(feature = "local_fs")]
FileNotebookAction::CopyFilePath => {
if let Some(path) = self.local_path() {
if let Some(path) = self.file_state.path() {
ctx.clipboard()
.write(ClipboardContent::plain_text(path.display().to_string()));
.write(ClipboardContent::plain_text(path.display_path()));
}
}
#[cfg(feature = "local_fs")]
FileNotebookAction::OpenInEditor => {
if let Some(path) = self.local_path() {
if let Some(local_path) = self.local_path() {
use crate::util::file::external_editor::EditorSettings;
use crate::util::openable_file_type::resolve_file_target;
// Resolve target and emit event - workspace will handle all cases
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target(&path, settings, None);
let target = resolve_file_target(&local_path, settings, None);
ctx.emit(FileNotebookEvent::OpenFileWithTarget {
path,
path: local_path,
target,
line_col: None,
});
} else if let Some(path) = self.file_state.path().cloned() {
// For remote files, open as a code editor pane.
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
path,
source: None,
}));
}
}
#[cfg(feature = "local_fs")]
@@ -907,7 +1065,7 @@ impl TypedActionView for FileNotebookView {
MarkdownDisplayMode::Raw => {
#[cfg(feature = "local_fs")]
{
if let Some(path) = self.local_path() {
if let Some(path) = self.file_state.path().cloned() {
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
path,
source: self.code_source.clone(),
@@ -917,6 +1075,12 @@ impl TypedActionView for FileNotebookView {
}
}
}
FileNotebookAction::ToggleMaximized => {
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ToggleMaximized));
self.pane_configuration.update(ctx, |pane_config, ctx| {
pane_config.refresh_pane_header_overflow_menu_items(ctx);
});
}
}
}
}
@@ -936,14 +1100,20 @@ impl BackingView for FileNotebookView {
fn pane_header_overflow_menu_items(
&self,
_ctx: &AppContext,
ctx: &AppContext,
) -> Vec<MenuItem<FileNotebookAction>> {
let mut actions = vec![];
if let Some(SourceFile::Local {
local_path: _local_path,
..
}) = self.file_state.source()
{
// Mirror the toggle that `CodeView` (the Raw markdown mode) exposes, so the Rendered
// markdown pane's overflow menu offers the same "Maximize pane" / "Minimize pane" entry.
let is_maximized = self
.focus_handle
.as_ref()
.is_some_and(|h| h.is_maximized(ctx));
let mut actions = vec![MenuItemFields::toggle_pane_action(is_maximized)
.with_on_select_action(FileNotebookAction::ToggleMaximized)
.into_item()];
if let Some(SourceFile::FileBased { .. }) = self.file_state.source() {
actions.push(MenuItem::Separator);
actions.push(
MenuItemFields::new("Refresh file")
.with_on_select_action(FileNotebookAction::ReloadFile)
@@ -1027,10 +1197,43 @@ impl BackingView for FileNotebookView {
galaxyui::text_layout::ClipConfig::start(),
);
// Wrap the title in a hoverable tooltip showing the full file path.
let title_element: Box<dyn Element> =
if let Some(display_path) = self.file_state.path().map(|p| p.display_path()) {
use warpui::elements::{
ChildAnchor, Hoverable, OffsetPositioning, ParentAnchor,
ParentOffsetBounds, Stack,
};
Hoverable::new(self.header_title_mouse_state.clone(), move |hover_state| {
let mut stack = Stack::new();
stack.add_child(title_text);
if hover_state.is_hovered() {
let tooltip = appearance
.ui_builder()
.tool_tip(display_path.clone())
.build()
.finish();
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::Unbounded,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
stack.finish()
})
.finish()
} else {
title_text
};
view::HeaderContent::Custom {
element: render_three_column_header(
Empty::new().finish(),
title_text,
title_element,
right_row.finish(),
CenteredHeaderEdgeWidth {
min: buttons_width,
+80 -30
View File
@@ -1,36 +1,39 @@
use std::{path::Path, sync::Arc};
use std::path::Path;
use std::sync::Arc;
use pathfinder_geometry::vector::vec2f;
use galaxy_core::ui::appearance::Appearance;
#[cfg(feature = "local_fs")]
use galaxy_files::FileModel;
use galaxyui::{platform::WindowStyle, App, SingletonEntity, View};
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::watcher::DirectoryWatcher;
#[cfg(feature = "local_fs")]
use repo_metadata::RepoMetadataModel;
use repo_metadata::{repositories::DetectedRepositories, watcher::DirectoryWatcher};
use string_offset::CharOffset;
use galaxy_core::features::FeatureFlag;
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::render::model::BlockItem;
#[cfg(feature = "local_fs")]
use galaxy_files::FileModel;
use galaxyui::platform::WindowStyle;
use galaxyui::{App, SingletonEntity, View};
use super::{FileNotebookView, FileState, MarkdownDisplayMode, SourceFile};
use crate::auth::auth_manager::AuthManager;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::persistence::CloudModel;
use crate::notebooks::context_menu::MenuSource;
use crate::notebooks::editor::keys::NotebookKeybindings;
use crate::notebooks::file::is_markdown_file;
use crate::search::files::model::FileSearchModel;
use crate::server::server_api::team::MockTeamClient;
use crate::server::server_api::workspace::MockWorkspaceClient;
use crate::server::server_api::ServerApiProvider;
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::keys::TerminalKeybindings;
use crate::{
auth::{auth_manager::AuthManager, AuthStateProvider},
cloud_object::model::persistence::CloudModel,
notebooks::{editor::keys::NotebookKeybindings, file::is_markdown_file},
search::files::model::FileSearchModel,
server::server_api::ServerApiProvider,
settings_view::keybindings::KeybindingChangedNotifier,
terminal::model::session::Session,
test_util::settings::initialize_settings_for_tests,
workspace::ActiveSession,
workspaces::user_workspaces::UserWorkspaces,
GlobalResourceHandles, GlobalResourceHandlesProvider,
};
use crate::notebooks::context_menu::MenuSource;
use super::{FileNotebookView, FileState};
use crate::terminal::model::session::Session;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspace::ActiveSession;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
fn init_app(app: &mut App) {
initialize_settings_for_tests(app);
@@ -123,10 +126,10 @@ fn test_load_before_session() {
.update(&mut app, |file_notebook, ctx| {
file_notebook.open_local("../README.md", None, ctx);
match &file_notebook.file_state {
FileState::Loading(source) => {
assert_eq!(source.local_path(), Some(Path::new("../README.md")))
FileState::Loading(SourceFile::FileBased { path, .. }) => {
assert_eq!(path.to_local_path(), Some(Path::new("../README.md")))
}
other => panic!("Expected FileState::Loading, got {other:?}"),
other => panic!("Expected FileState::Loading(FileBased), got {other:?}"),
}
let file_id = file_notebook
@@ -148,10 +151,10 @@ fn test_load_before_session() {
assert!(view.location.is_none());
match &view.file_state {
FileState::Loaded(source) => {
assert_eq!(source.local_path(), Some(expected_path.as_path()));
FileState::Loaded(SourceFile::FileBased { path, .. }) => {
assert_eq!(path.to_local_path(), Some(expected_path.as_path()));
}
other => panic!("Expected FileState::Loaded, got {other:?}"),
other => panic!("Expected FileState::Loaded(FileBased), got {other:?}"),
};
});
@@ -195,6 +198,53 @@ fn test_load_static() {
});
}
#[test]
fn test_file_notebook_mermaid_blocks_default_to_rendered() {
App::test((), |mut app| async move {
init_app(&mut app);
let _flag = FeatureFlag::MarkdownMermaid.override_enabled(true);
let _editable_flag = FeatureFlag::EditableMarkdownMermaid.override_enabled(true);
let (_, handle) = app.add_window(WindowStyle::NotStealFocus, FileNotebookView::new);
handle.update(&mut app, |file_notebook, ctx| {
file_notebook.open_static("Test Title", "```mermaid\ngraph TD\nA --> B\n```", ctx);
});
let render_state = handle.read(&app, |view, ctx| {
view.editor
.as_ref(ctx)
.model()
.as_ref(ctx)
.render_state()
.clone()
});
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
.await;
app.read(|ctx| render_state.as_ref(ctx).layout_complete())
.await;
handle.read(&app, |view, ctx| {
let editor = view.editor.as_ref(ctx);
let model = editor.model().as_ref(ctx);
let command = model
.notebook_command_for_block(CharOffset::zero())
.expect("Mermaid command should exist");
assert_eq!(
command.as_ref(ctx).mermaid_display_mode,
MarkdownDisplayMode::Rendered
);
assert!(matches!(
model
.render_state()
.as_ref(ctx)
.content()
.block_at_height(0.)
.map(|item| item.item),
Some(BlockItem::MermaidDiagram { .. })
));
});
});
}
#[test]
fn test_markdown_file_detection() {
assert!(is_markdown_file("README.md"));
+69 -36
View File
@@ -1,33 +1,26 @@
//! Link-opening behavior for notebooks.
use std::{
borrow::Cow,
fmt,
future::{self, Future},
net::IpAddr,
path::{Path, PathBuf},
sync::Arc,
};
use std::borrow::Cow;
use std::fmt;
use std::future::{self, Future};
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use futures_util::future::Either;
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
use galaxyui::{
r#async::SpawnedFutureHandle, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity,
WindowId,
};
use url::Url;
use galaxy_util::path::{CleanPathResult, LineAndColumnArg};
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity, WindowId};
use super::file::is_markdown_file;
use crate::drive::OpenWarpDriveObjectArgs;
use crate::terminal::model::session::Session;
use crate::uri::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink};
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::EditorSettings;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{is_supported_image_file, resolve_file_target, FileTarget};
use crate::{
drive::OpenGalaxyDriveObjectArgs,
terminal::model::session::Session,
uri::parse_url_paths::{get_item_data_from_warp_link, WarpWebLink},
workspace::ActiveSession,
};
use super::file::is_markdown_file;
use crate::workspace::ActiveSession;
#[cfg(test)]
#[path = "link_tests.rs"]
@@ -221,7 +214,7 @@ impl NotebookLinks {
match self.session_source.base_directory(ctx) {
Some(directory) => directory.join(clean_path),
None => {
return Either::Right(future::ready(Err(ResolveError::MissingContext)))
return Either::Right(future::ready(Err(ResolveError::MissingContext)));
}
}
} else {
@@ -260,7 +253,7 @@ impl NotebookLinks {
/// Open a resolved link:
/// * URLs are opened in the web browser or system-default application.
/// * Markdown files are opened in Warp (if the `FileNotebooks` feature flag is enabled).
/// * Markdown files are opened according to the user's Markdown Viewer preference.
/// * Other files are opened in the configured editor or system-default application.
pub fn open(&self, link: LinkTarget, ctx: &mut ModelContext<Self>) {
match link {
@@ -275,11 +268,27 @@ impl NotebookLinks {
}
LinkTarget::LocalFile {
path,
line_and_column,
session,
is_markdown: true,
..
} => {
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
#[cfg(not(feature = "local_fs"))]
let _ = line_and_column;
#[cfg(feature = "local_fs")]
{
let settings = EditorSettings::as_ref(ctx);
if *settings.prefer_markdown_viewer {
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
} else {
open_file(path, line_and_column, ctx);
}
}
#[cfg(not(feature = "local_fs"))]
{
ctx.emit(LinkEvent::OpenFileNotebook { path, session });
}
}
LinkTarget::LocalFile {
path,
@@ -343,6 +352,11 @@ impl NotebookLinks {
}
/// Open a file respecting user's editor settings.
///
/// For targets that would be handed to the OS default handler (`SystemGeneric` /
/// `SystemDefault`), we reveal the file in Finder / Explorer instead of opening it.
/// This prevents a malicious markdown link from triggering arbitrary code execution
/// via an executable disguised as a local file (e.g. an extensionless shell script).
// The `line_and_column` argument is unused when there is no local filesystem.
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
fn open_file(
@@ -352,17 +366,36 @@ fn open_file(
) {
#[cfg(feature = "local_fs")]
{
let target = if is_supported_image_file(&path) {
FileTarget::SystemGeneric
} else {
let settings = EditorSettings::as_ref(ctx);
resolve_file_target(&path, settings, None)
};
ctx.emit(LinkEvent::OpenFileWithTarget {
path,
target,
line_col: line_and_column,
});
// Images are safe to open with the system default viewer.
if is_supported_image_file(&path) {
ctx.emit(LinkEvent::OpenFileWithTarget {
path,
target: FileTarget::SystemGeneric,
line_col: line_and_column,
});
return;
}
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target(&path, settings, None);
match target {
// Safe targets: open in a viewer/editor that won't execute the file.
FileTarget::MarkdownViewer(_)
| FileTarget::CodeEditor(_)
| FileTarget::ExternalEditor(_)
| FileTarget::EnvEditor => {
ctx.emit(LinkEvent::OpenFileWithTarget {
path,
target,
line_col: line_and_column,
});
}
// Dangerous targets: the OS default handler could execute the file.
// Reveal in Finder / Explorer instead.
FileTarget::SystemGeneric | FileTarget::SystemDefault => {
ctx.open_file_path_in_explorer(&path);
}
}
}
#[cfg(not(feature = "local_fs"))]
ctx.open_file_path(&path);
+101 -13
View File
@@ -1,24 +1,25 @@
use std::{
io::ErrorKind,
path::{Path, PathBuf},
sync::Arc,
};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use galaxy_util::path::LineAndColumnArg;
use galaxyui::{App, ModelHandle, WindowId};
use lazy_static::lazy_static;
use parking_lot::Mutex;
use settings::Setting as _;
use tempfile::tempdir;
use url::Url;
use crate::{
notebooks::{file::is_markdown_file, link::LinkEvent},
terminal::{model::session::Session, shell::ShellType},
util::openable_file_type::FileTarget,
workspace::ActiveSession,
};
use galaxyui::{App, ModelHandle, SingletonEntity, WindowId};
use super::{LinkTarget, NotebookLinks, ResolveError, SessionSource};
use crate::notebooks::file::is_markdown_file;
use crate::notebooks::link::LinkEvent;
use crate::terminal::model::session::Session;
use crate::terminal::shell::ShellType;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::util::file::external_editor::EditorSettings;
use crate::util::openable_file_type::FileTarget;
use crate::workspace::ActiveSession;
fn url(s: &str) -> LinkTarget {
LinkTarget::Url(Url::parse(s).expect("Invalid URL"))
@@ -60,6 +61,8 @@ lazy_static! {
/// Initialize the app and link resolver. For test purposes, we only care about the base
/// directory's value, not how it was obtained.
fn init_link_model(app: &mut App, base_directory: Option<&Path>) -> ModelHandle<NotebookLinks> {
initialize_settings_for_tests(app);
let window_id = WindowId::new();
let source = match base_directory {
Some(dir) => SessionSource::Target {
@@ -208,6 +211,43 @@ fn test_open_local_image_uses_system_generic_target() {
});
}
#[test]
fn test_open_extensionless_non_text_file_does_not_emit_open_event() {
// Regression test: an extensionless file (e.g. a disguised executable) is classified as
// binary by `is_file_openable_in_warp`, which previously routed it to `SystemGeneric` and
// ultimately `NSWorkspace.openURL` — allowing arbitrary code execution. After the fix,
// such files are revealed in Finder / Explorer instead of opened, so no `OpenFileWithTarget`
// event should be emitted.
App::test((), |mut app| async move {
let base = tempdir().unwrap();
let base_path = base.path();
let malicious_path = base_path.join("abc");
touch(&malicious_path).await;
let links = init_link_model(&mut app, Some(base_path));
let events = Arc::new(Mutex::new(vec![]));
{
let events = events.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&links, move |_, event, _| {
events.lock().push(event.clone());
})
});
}
links.update(&mut app, |links, ctx| {
links.open(local_file(&malicious_path), ctx);
});
let events = events.lock();
assert!(
events.is_empty(),
"Expected no LinkEvent to be emitted for an extensionless non-text file, \
but got: {events:?}"
);
});
}
#[test]
fn test_resolve_valid_url() {
App::test((), |mut app| async move {
@@ -359,7 +399,7 @@ fn test_resolve_file_with_line() {
}
#[test]
fn test_open_markdown_file() {
fn test_open_markdown_file_uses_viewer_when_preferred() {
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if !root.join("README.md").exists() {
root = root.parent().unwrap().to_path_buf();
@@ -398,3 +438,51 @@ fn test_open_markdown_file() {
}
});
}
#[test]
fn test_open_markdown_file_respects_disabled_viewer_preference() {
// With `prefer_markdown_viewer = false`, the markdown file would otherwise
// resolve to `FileTarget::SystemDefault`. The security fix in #25353 routes
// both `SystemDefault` and `SystemGeneric` through
// `open_file_path_in_explorer`, so no `OpenFileWithTarget` event is emitted
// — the file is revealed in Finder / Explorer instead.
let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if !root.join("README.md").exists() {
root = root.parent().unwrap().to_path_buf();
}
App::test((), |mut app| async move {
let links = init_link_model(&mut app, Some(&root));
EditorSettings::handle(&app).update(&mut app, |settings, ctx| {
settings
.prefer_markdown_viewer
.set_value(false, ctx)
.unwrap();
});
let events = Arc::new(Mutex::new(vec![]));
{
let events = events.clone();
app.update(|ctx| {
ctx.subscribe_to_model(&links, move |_, event, _| {
events.lock().push(event.clone());
})
});
}
links
.update(&mut app, |links, ctx| {
let future = links.resolve_and_open("./README.md", ctx);
ctx.await_spawned_future(future.future_id())
})
.await;
let events = events.lock();
assert!(
events.is_empty(),
"Expected no LinkEvent when markdown viewer is disabled (file is \
revealed in explorer instead), but got: {events:?}"
);
});
}
+24 -23
View File
@@ -1,31 +1,26 @@
use std::collections::{hash_map::Entry, HashMap};
use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::sync::Arc;
use futures_util::stream::AbortHandle;
use galaxyui::{
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle,
WindowId,
};
use markdown_parser::markdown_parser::parse_markdown_to_raw_text;
use crate::{
cloud_object::{
model::persistence::{CloudModel, CloudModelEvent},
Owner,
},
drive::OpenGalaxyDriveObjectSettings,
pane_group::{NotebookPane, PaneContent},
safe_debug, safe_warn,
server::{
cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
},
ids::SyncId,
},
workspace::PaneViewLocator,
use galaxyui::r#async::SpawnedFutureHandle;
use galaxyui::{
Entity, EntityId, ModelContext, ModelHandle, SingletonEntity, WeakViewHandle, WindowId,
};
use super::{notebook::NotebookView, CloudNotebook};
use super::notebook::NotebookView;
use super::CloudNotebook;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::Owner;
use crate::drive::OpenWarpDriveObjectSettings;
use crate::pane_group::{NotebookPane, PaneContent};
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
};
use crate::server::ids::SyncId;
use crate::workspace::PaneViewLocator;
use crate::{safe_debug, safe_warn};
#[cfg(test)]
#[path = "manager_tests.rs"]
@@ -146,7 +141,12 @@ impl NotebookManager {
}
}
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
fn handle_cloud_model_event(
&mut self,
_: ModelHandle<CloudModel>,
event: &CloudModelEvent,
ctx: &mut ModelContext<Self>,
) {
if let CloudModelEvent::ObjectUpdated { type_and_id, .. } = event {
if let Some(notebook_id) = type_and_id.as_notebook_id() {
self.update_raw_text_for_notebook(notebook_id, ctx);
@@ -292,6 +292,7 @@ impl NotebookManager {
fn handle_update_manager_event(
&mut self,
_: ModelHandle<UpdateManager>,
event: &UpdateManagerEvent,
ctx: &mut ModelContext<Self>,
) {
+29 -32
View File
@@ -1,40 +1,37 @@
use std::sync::mpsc;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{
platform::WindowStyle, AddSingletonModel, App, EntityId, ModelHandle, ViewContext, ViewHandle,
};
use crate::{
ai::blocklist::BlocklistAIHistoryModel,
auth::{auth_manager::AuthManager, AuthStateProvider},
cloud_object::{
model::{actions::ObjectActions, persistence::CloudModel, view::CloudViewModel},
Owner,
},
network::NetworkStatus,
notebooks::{editor::keys::NotebookKeybindings, notebook::NotebookView},
pane_group::NotebookPane,
persistence::ModelEvent,
search::files::model::FileSearchModel,
server::{
cloud_objects::update_manager::UpdateManager, server_api::ServerApiProvider,
sync_queue::SyncQueue, telemetry::context_provider::AppTelemetryContextProvider,
},
settings::PrivacySettings,
settings_view::keybindings::KeybindingChangedNotifier,
terminal::{
keys::TerminalKeybindings, shared_session::permissions_manager::SessionPermissionsManager,
},
test_util::settings::initialize_settings_for_tests,
workspace::ActiveSession,
workspaces::{
team_tester::TeamTesterStatus, user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
},
GlobalResourceHandles, GlobalResourceHandlesProvider,
};
use galaxyui::platform::WindowStyle;
use galaxyui::{AddSingletonModel, App, EntityId, ModelHandle, ViewContext, ViewHandle};
use super::NotebookManager;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::auth::auth_manager::AuthManager;
use crate::auth::AuthStateProvider;
use crate::cloud_object::model::actions::ObjectActions;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::model::view::CloudViewModel;
use crate::cloud_object::Owner;
use crate::network::NetworkStatus;
use crate::notebooks::editor::keys::NotebookKeybindings;
use crate::notebooks::notebook::NotebookView;
use crate::pane_group::NotebookPane;
use crate::persistence::ModelEvent;
use crate::search::files::model::FileSearchModel;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::settings::PrivacySettings;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::keys::TerminalKeybindings;
use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspace::ActiveSession;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_profiles::UserProfiles;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
struct TestState {
manager: ModelHandle<NotebookManager>,
+20 -82
View File
@@ -10,55 +10,24 @@ pub mod telemetry;
use std::sync::Arc;
use async_trait::async_trait;
use anyhow::Result;
use galaxyui::AppContext;
use itertools::Itertools;
use async_trait::async_trait;
pub use cloud_object_models::{CloudNotebook, CloudNotebookModel, NotebookId, SerializedNotebook};
use serde::{Deserialize, Serialize};
use crate::server::cloud_objects::update_manager::InitiatedBy;
use crate::{
ai::document::ai_document_model::AIDocumentId,
appearance::Appearance,
cloud_object::{
CloudModelType, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest,
GenericCloudObject, GenericServerObject, ObjectType, Owner, Revision, ServerCloudObject,
UpdateCloudObjectResult,
},
drive::{
items::{notebook::WarpDriveNotebook, WarpDriveItem},
CloudObjectTypeAndId,
},
persistence::ModelEvent,
server::{
ids::{ServerId, SyncId},
server_api::object::ObjectClient,
sync_queue::{QueueItem, SerializedModel},
},
use crate::appearance::Appearance;
use crate::cloud_object::{
CloudModelType, CloudObjectEventEntrypoint, CloudObjectUpsertParams, CreateCloudObjectResult,
CreateObjectRequest, GenericServerObject, ObjectType, Owner, Revision, UpdateCloudObjectResult,
};
/// Serialized representation of a notebook for sync queue
/// The AIDocumentID and ConversationID are stored here to avoid polluting the
/// generic CreateObjectRequest type.
#[derive(Serialize, Deserialize)]
pub(crate) struct SerializedNotebook {
pub(crate) data: String,
pub(crate) ai_document_id: Option<String>,
pub(crate) conversation_id: Option<String>,
}
/// `CloudNotebook` is a notebook retrieved from the server.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CloudNotebookModel {
pub title: String,
pub data: String,
pub ai_document_id: Option<AIDocumentId>,
/// This is the server-generated conversation token, not the client-side AIConversationId.
pub conversation_id: Option<String>,
}
pub type CloudNotebook = GenericCloudObject<NotebookId, CloudNotebookModel>;
use crate::drive::items::notebook::WarpDriveNotebook;
use crate::drive::items::WarpDriveItem;
use crate::drive::CloudObjectTypeAndId;
use crate::persistence::ModelEvent;
use crate::server::cloud_objects::update_manager::InitiatedBy;
use crate::server::ids::{ServerId, SyncId};
use crate::server::server_api::object::ObjectClient;
use crate::server::sync_queue::{QueueItem, SerializedModel};
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
@@ -90,14 +59,14 @@ impl CloudModelType for CloudNotebookModel {
name.clone_into(&mut self.title);
}
fn upsert_event(&self, notebook: &CloudNotebook) -> ModelEvent {
fn upsert_event(params: CloudObjectUpsertParams<Self>) -> ModelEvent {
ModelEvent::UpsertNotebook {
notebook: notebook.clone(),
notebook: CloudNotebook::from(params),
}
}
fn bulk_upsert_event(objects: &[CloudNotebook]) -> ModelEvent {
ModelEvent::UpsertNotebooks(objects.to_vec())
fn bulk_upsert_event(objects: Vec<CloudObjectUpsertParams<Self>>) -> ModelEvent {
ModelEvent::UpsertNotebooks(objects.into_iter().map(CloudNotebook::from).collect())
}
fn create_object_queue_item(
@@ -145,18 +114,6 @@ impl CloudModelType for CloudNotebookModel {
true
}
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
if let ServerCloudObject::Notebook(server_notebook) = server_cloud_object {
return Some(CloudNotebookModel {
title: server_notebook.model.title.clone(),
data: server_notebook.model.data.clone(),
ai_document_id: server_notebook.model.ai_document_id,
conversation_id: None, // conversation_id is not returned from server, just used for initial plan artifact creation
});
}
None
}
fn serialized(&self) -> SerializedModel {
let serialized = SerializedNotebook {
data: self.data.clone(),
@@ -212,17 +169,6 @@ impl CloudModelType for CloudNotebookModel {
}
}
/// This is the notebook_id in the database associated with this notebook.
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub struct NotebookId(ServerId);
crate::server_id_traits! { NotebookId, "Notebook" }
impl From<NotebookId> for SyncId {
fn from(id: NotebookId) -> Self {
Self::ServerId(id.into())
}
}
/// A notebook location. Mainly, this lets us distinguish between cloud and file-based notebooks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum NotebookLocation {
@@ -253,15 +199,6 @@ pub fn init(app: &mut AppContext) {
self::editor::view::init(app);
}
/// Post process a notebook's content read from an external system. This cleans up extra
/// whitespace, and, in the future, may filter out unsupported syntax extensions.
///
/// See CLD-944.
pub fn post_process_notebook(data: &str) -> String {
// TODO(kevin): We should not strip out newlines in the code block.
data.lines().filter(|line| !line.is_empty()).join("\n")
}
/// Translate a notebook's Markdown content into an external Markdown format.
///
/// This:
@@ -269,7 +206,8 @@ pub fn post_process_notebook(data: &str) -> String {
/// * Includes extra context for embedded objects.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
pub fn export_notebook(data: &str, ctx: &AppContext) -> anyhow::Result<String> {
use galaxy_editor::content::{buffer::Buffer, markdown::MarkdownStyle};
use galaxy_editor::content::buffer::Buffer;
use galaxy_editor::content::markdown::MarkdownStyle;
// Parse the Markdown directly rather than using [`Buffer::from_markdown`] so that we can
// report errors to the exporter.
+79 -103
View File
@@ -1,3 +1,6 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use async_channel::Sender;
use futures_util::stream::AbortHandle;
@@ -5,116 +8,89 @@ use galaxy_core::context_flag::ContextFlag;
use lazy_static::lazy_static;
use regex::Regex;
use settings::Setting as _;
use std::{sync::Arc, time::Duration};
use url::Url;
#[cfg(target_family = "wasm")]
use crate::uri::web_intent_parser::open_url_on_desktop;
use galaxy_editor::{
editor::NavigationKey,
model::{CoreEditorModel, RichTextEditorModel},
use galaxy_editor::editor::NavigationKey;
use galaxy_editor::model::{CoreEditorModel, RichTextEditorModel};
use galaxyui::accessibility::{AccessibilityContent, WarpA11yRole};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
Align, Clipped, ConstrainedBox, Container, CrossAxisAlignment, DispatchEventResult, Empty,
EventHandler, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
SavePosition, Shrinkable, Stack,
};
use galaxyui::keymap::{EditableBinding, FixedBinding};
use galaxyui::presenter::ChildView;
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
accessibility::{AccessibilityContent, GalaxyA11yRole},
clipboard::ClipboardContent,
elements::{
Align, Clipped, ConstrainedBox, Container, CrossAxisAlignment, DispatchEventResult, Empty,
EventHandler, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
SavePosition, Shrinkable, Stack,
},
keymap::{EditableBinding, FixedBinding},
presenter::ChildView,
r#async::{SpawnedFutureHandle, Timer},
ui_components::{
button::ButtonVariant,
components::{UiComponent, UiComponentStyles},
},
AppContext, BlurContext, Element, Entity, FocusContext, ModelAsRef, ModelHandle,
SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WindowId,
};
use crate::{
ai::{
blocklist::secret_redaction::find_secrets_in_text,
document::ai_document_model::AIDocumentId,
},
appearance::Appearance,
cloud_object::{
grab_edit_access_modal::{GrabEditAccessModal, GrabEditAccessModalEvent},
model::{
persistence::{CloudModel, CloudModelEvent, UpdateSource},
view::{Editor, EditorState},
},
CloudObject, CloudObjectEventEntrypoint, ObjectType, Owner, Space,
},
cmd_or_ctrl_shift,
drive::{
drive_helpers::has_feature_gated_anonymous_user_reached_notebook_limit,
export::ExportManager, items::WarpDriveItemId, sharing::ShareableObject,
CloudObjectTypeAndId, OpenGalaxyDriveObjectSettings,
},
editor::{
EditOrigin, EditorView, Event as EditorEvent, InteractionState,
PropagateAndNoOpNavigationKeys, SingleLineEditorOptions, TextColors, TextOptions,
},
features::FeatureFlag,
menu::{MenuItem, MenuItemFields},
network::{NetworkStatus, NetworkStatusEvent},
notebooks::{
editor::{model::NotebooksEditorModel, rich_text_styles},
CloudNotebook,
},
pane_group::{
focus_state::{PaneFocusHandle, PaneGroupFocusEvent},
pane::view,
BackingView, PaneConfiguration, PaneEvent,
},
report_if_error, safe_info, send_telemetry_from_ctx,
server::{
cloud_objects::update_manager::{FetchSingleObjectOption, UpdateManager},
ids::{ClientId, ServerId, SyncId},
telemetry::{
CloudObjectTelemetryMetadata, NotebookActionEvent, NotebookTelemetryMetadata,
SharingDialogSource, TelemetryCloudObjectType, TelemetryEvent,
},
},
settings::{
app_installation_detection::{UserAppInstallDetectionSettings, UserAppInstallStatus},
decrease_notebook_font_size, increase_notebook_font_size, FontSettings,
FontSettingsChangedEvent, NotebookFontSize,
},
terminal::safe_mode_settings::get_secret_obfuscation_mode,
throttle::throttle,
ui_components::icons::{self, Icon},
util::bindings::{self, CustomAction},
view_components::{DismissibleToast, ToastType},
workflows::{WorkflowSource, WorkflowType},
workspace::ToastStack,
workspaces::user_workspaces::UserWorkspaces,
};
use self::details_bar::DetailsBar;
use super::{
active_notebook_data::{
ActiveNotebook, ActiveNotebookData, ActiveNotebookDataEvent, Mode, SavingStatus,
TrashStatus,
},
context_menu::{
show_rich_editor_context_menu, show_text_editor_context_menu, ContextMenuAction,
ContextMenuState,
},
editor::{
view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView},
NotebookWorkflow,
},
link::{NotebookLinks, SessionSource},
manager::NotebookManager,
styles,
telemetry::NotebookTelemetryAction,
CloudNotebookModel, NotebookId, NotebookLocation,
use super::active_notebook_data::{
ActiveNotebook, ActiveNotebookData, ActiveNotebookDataEvent, Mode, SavingStatus, TrashStatus,
};
use super::context_menu::{
show_rich_editor_context_menu, show_text_editor_context_menu, ContextMenuAction,
ContextMenuState,
};
use super::editor::view::{EditorViewEvent, RichTextEditorConfig, RichTextEditorView};
use super::editor::NotebookWorkflow;
use super::link::{NotebookLinks, SessionSource};
use super::manager::NotebookManager;
use super::telemetry::NotebookTelemetryAction;
use super::{styles, CloudNotebookModel, NotebookId, NotebookLocation};
use crate::ai::blocklist::secret_redaction::find_secrets_in_text;
use crate::ai::document::ai_document_model::AIDocumentId;
use crate::appearance::Appearance;
use crate::cloud_object::grab_edit_access_modal::{GrabEditAccessModal, GrabEditAccessModalEvent};
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource};
use crate::cloud_object::model::view::{Editor, EditorState};
use crate::cloud_object::{CloudObject, CloudObjectEventEntrypoint, ObjectType, Owner, Space};
use crate::drive::drive_helpers::has_feature_gated_anonymous_user_reached_notebook_limit;
use crate::drive::export::ExportManager;
use crate::drive::items::WarpDriveItemId;
use crate::drive::sharing::ShareableObject;
use crate::drive::{CloudObjectTypeAndId, OpenWarpDriveObjectSettings};
use crate::editor::{
EditOrigin, EditorView, Event as EditorEvent, InteractionState, PropagateAndNoOpNavigationKeys,
SingleLineEditorOptions, TextColors, TextOptions,
};
use crate::features::FeatureFlag;
use crate::menu::{MenuItem, MenuItemFields};
use crate::network::{NetworkStatus, NetworkStatusEvent};
use crate::notebooks::editor::model::NotebooksEditorModel;
use crate::notebooks::editor::rich_text_styles;
use crate::notebooks::CloudNotebook;
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusEvent};
use crate::pane_group::pane::view;
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
use crate::server::cloud_objects::update_manager::{FetchSingleObjectOption, UpdateManager};
use crate::server::ids::{ClientId, ServerId, SyncId};
use crate::server::telemetry::{
CloudObjectTelemetryMetadata, NotebookActionEvent, NotebookTelemetryMetadata,
SharingDialogSource, TelemetryCloudObjectType, TelemetryEvent,
};
use crate::settings::app_installation_detection::{
UserAppInstallDetectionSettings, UserAppInstallStatus,
};
use crate::settings::{
decrease_notebook_font_size, increase_notebook_font_size, FontSettings,
FontSettingsChangedEvent, NotebookFontSize,
};
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
use crate::throttle::throttle;
use crate::ui_components::icons::{self, Icon};
#[cfg(target_family = "wasm")]
use crate::uri::web_intent_parser::open_url_on_desktop;
use crate::util::bindings::{self, CustomAction};
use crate::view_components::{DismissibleToast, ToastType};
use crate::workflows::{WorkflowSource, WorkflowType};
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{cmd_or_ctrl_shift, report_if_error, safe_info, send_telemetry_from_ctx};
mod details_bar;
@@ -952,7 +928,7 @@ impl NotebookView {
report_if_error!(self
.save_tx
.try_send(NotebookUpdateRequestDebounceArg {})
.context("Error enqueing content save"));
.context("Error enqueuing content save"));
self.active_notebook_data.update(ctx, |data, ctx| {
// Mark the notebook as saving as soon as there are changes to be saved. It won't be
// marked as Saved until we get a response from the server.
@@ -968,7 +944,7 @@ impl NotebookView {
report_if_error!(self
.save_tx
.try_send(NotebookUpdateRequestDebounceArg {})
.context("Error enqueing title save"));
.context("Error enqueuing title save"));
}
fn handle_input_editor_event(&mut self, event: &EditorViewEvent, ctx: &mut ViewContext<Self>) {
+18 -25
View File
@@ -1,33 +1,26 @@
//! Components for the notebook header.
use galaxy_core::features::FeatureFlag;
use galaxyui::{
elements::{
Container, CrossAxisAlignment, Flex, Highlight, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Shrinkable,
},
platform::Cursor,
ui_components::components::{UiComponent, UiComponentStyles},
AppContext, Element, SingletonEntity,
use galaxyui::elements::{
Container, CrossAxisAlignment, Flex, Highlight, MainAxisAlignment, MainAxisSize,
MouseStateHandle, ParentElement, Shrinkable,
};
use warpui::platform::Cursor;
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
use warpui::{AppContext, Element, SingletonEntity};
use crate::{
appearance::Appearance,
cloud_object::{
breadcrumbs::ContainingObject,
model::view::{Editor, EditorState},
},
drive::sharing::ContentEditability,
notebooks::{active_notebook_data::Mode, styles},
ui_components::{
breadcrumb::{render_breadcrumbs, BreadcrumbState},
buttons::{accent_icon_button, icon_button},
icons::Icon,
},
workspaces::user_profiles::UserProfiles,
};
use super::{super::active_notebook_data::ActiveNotebookData, NotebookAction, EDIT_BUTTON_MARGIN};
use super::super::active_notebook_data::ActiveNotebookData;
use super::{NotebookAction, EDIT_BUTTON_MARGIN};
use crate::appearance::Appearance;
use crate::cloud_object::breadcrumbs::ContainingObject;
use crate::cloud_object::model::view::{Editor, EditorState};
use crate::drive::sharing::ContentEditability;
use crate::notebooks::active_notebook_data::Mode;
use crate::notebooks::styles;
use crate::ui_components::breadcrumb::{render_breadcrumbs, BreadcrumbState};
use crate::ui_components::buttons::{accent_icon_button, icon_button};
use crate::ui_components::icons::Icon;
use crate::workspaces::user_profiles::UserProfiles;
/// Component to show details about a notebook:
/// * Interactive breadcrumbs for its location within Warp Drive
@@ -1,11 +1,8 @@
use galaxyui::{App, SingletonEntity};
use crate::{
auth::UserUid,
workspaces::user_profiles::{UserProfileWithUID, UserProfiles},
};
use super::editor_display_name;
use crate::auth::UserUid;
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
fn initialize_app(app: &mut App) {
app.update(crate::settings::init_and_register_user_preferences);
+47 -55
View File
@@ -2,63 +2,55 @@ use std::sync::Arc;
use chrono::{Duration, Utc};
use futures_util::future::BoxFuture;
use itertools::Itertools;
use galaxy_core::ui::appearance::Appearance;
use galaxy_editor::editor::EditorView;
use galaxyui::platform::WindowStyle;
use galaxyui::presenter::ChildView;
use galaxyui::r#async::Timer;
use galaxyui::telemetry::EventPayload;
use galaxyui::{
platform::WindowStyle, presenter::ChildView, r#async::Timer, telemetry::EventPayload,
AddSingletonModel, App, AppContext, Element, Entity, SingletonEntity, TypedActionView, View,
ViewHandle, WindowId,
};
use itertools::Itertools;
use crate::{
auth::{
auth_manager::AuthManager,
user::{TEST_USER_EMAIL, TEST_USER_UID},
AuthStateProvider, UserUid,
},
cloud_object::{
model::{
actions::ObjectActions,
persistence::CloudModel,
view::{CloudViewModel, Editor, EditorState},
},
Owner, Revision, ServerCloudObject, ServerMetadata, ServerNotebook, ServerPermissions,
},
drive::OpenGalaxyDriveObjectSettings,
editor::{DisplayPoint, EditorAction, InteractionState, SelectAction},
network::NetworkStatus,
notebooks::{
active_notebook_data::Mode,
editor::{
keys::NotebookKeybindings, notebook_command::NotebookCommand, view::EditorViewAction,
},
notebook::FocusedComponent,
CloudNotebook, CloudNotebookModel, NotebookLocation,
},
pane_group::PaneEvent,
search::files::model::FileSearchModel,
server::{
cloud_objects::update_manager::{InitialLoadResponse, UpdateManager},
ids::{ClientId, SyncId::ServerId},
server_api::ServerApiProvider,
sync_queue::{QueueItem, SyncQueue, SyncQueueEvent},
telemetry::context_provider::AppTelemetryContextProvider,
},
settings_view::keybindings::KeybindingChangedNotifier,
terminal::keys::TerminalKeybindings,
test_util::settings::initialize_settings_for_tests,
workflows::{workflow::Workflow, WorkflowSource, WorkflowType},
workspace::ActiveSession,
workspaces::{
team_tester::TeamTesterStatus,
user_profiles::{UserProfileWithUID, UserProfiles},
user_workspaces::UserWorkspaces,
},
GlobalResourceHandles, GlobalResourceHandlesProvider, PrivacySettings,
};
use super::{NotebookEvent, NotebookView, EDIT_WINDOW_DURATION, SAVE_PERIOD};
use crate::auth::auth_manager::AuthManager;
use crate::auth::user::{TEST_USER_EMAIL, TEST_USER_UID};
use crate::auth::{AuthStateProvider, UserUid};
use crate::cloud_object::model::actions::ObjectActions;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::model::view::{CloudViewModel, Editor, EditorState};
use crate::cloud_object::{
Owner, Revision, ServerCloudObject, ServerMetadata, ServerNotebook, ServerPermissions,
};
use crate::drive::OpenWarpDriveObjectSettings;
use crate::editor::{DisplayPoint, EditorAction, InteractionState, SelectAction};
use crate::network::NetworkStatus;
use crate::notebooks::active_notebook_data::Mode;
use crate::notebooks::editor::keys::NotebookKeybindings;
use crate::notebooks::editor::notebook_command::NotebookCommand;
use crate::notebooks::editor::view::EditorViewAction;
use crate::notebooks::notebook::FocusedComponent;
use crate::notebooks::{CloudNotebook, CloudNotebookModel, NotebookLocation};
use crate::pane_group::PaneEvent;
use crate::search::files::model::FileSearchModel;
use crate::server::cloud_objects::update_manager::{InitialLoadResponse, UpdateManager};
use crate::server::ids::ClientId;
use crate::server::ids::SyncId::ServerId;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::{QueueItem, SyncQueue, SyncQueueEvent};
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::terminal::keys::TerminalKeybindings;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workflows::workflow::Workflow;
use crate::workflows::{WorkflowSource, WorkflowType};
use crate::workspace::ActiveSession;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider, PrivacySettings};
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
@@ -162,15 +154,15 @@ fn cloud_notebook(title: impl Into<String>, data: impl Into<String>) -> CloudNot
/// Mock a server notebook
fn mock_server_notebook(title: impl Into<String>, data: impl Into<String>) -> ServerNotebook {
let metadata_ts = Utc::now().into();
ServerNotebook {
id: ServerId(123.into()),
model: CloudNotebookModel {
ServerNotebook::new(
ServerId(123.into()),
CloudNotebookModel {
title: title.into(),
data: data.into(),
ai_document_id: None,
conversation_id: None,
},
metadata: ServerMetadata {
ServerMetadata {
uid: 123.into(),
revision: Revision::now(),
metadata_last_updated_ts: metadata_ts,
@@ -181,13 +173,13 @@ fn mock_server_notebook(title: impl Into<String>, data: impl Into<String>) -> Se
last_editor_uid: None,
current_editor_uid: None,
},
permissions: ServerPermissions {
ServerPermissions {
space: Owner::mock_current_user(),
guests: Vec::new(),
anyone_link_sharing: None,
permissions_last_updated_ts: metadata_ts,
},
}
)
}
/// Send changed objects to [`UpdateManager`] so that tests requiring "up-to-date" metadata can run.
+12 -16
View File
@@ -1,23 +1,19 @@
//! Shared styles for notebooks.
use galaxyui::{
elements::{
Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
ParentElement,
},
fonts,
platform::Cursor,
ui_components::components::UiComponent as _,
units::{IntoPixels, Pixels},
Element,
use galaxyui::elements::{
Container, CrossAxisAlignment, Flex, Hoverable, MainAxisAlignment, MouseStateHandle,
ParentElement,
};
use warpui::platform::Cursor;
use warpui::ui_components::components::UiComponent as _;
use warpui::units::{IntoPixels, Pixels};
use warpui::{fonts, Element};
use crate::{
appearance::Appearance,
settings::{derived_notebook_font_size, FontSettings},
themes::theme::Fill,
ui_components::{buttons::icon_button, icons::Icon},
};
use crate::appearance::Appearance;
use crate::settings::{derived_notebook_font_size, FontSettings};
use crate::themes::theme::Fill;
use crate::ui_components::buttons::icon_button;
use crate::ui_components::icons::Icon;
/// Multiplier of the notebook font size for the title. At the default size, this
/// is 20px.
+2 -2
View File
@@ -2,9 +2,9 @@
use serde::{Deserialize, Serialize};
use crate::{server::ids::ServerId, workflows::WorkflowId};
use super::editor::BlockInsertionSource;
use crate::server::ids::ServerId;
use crate::workflows::WorkflowId;
/// A user action within a notebook. Some actions, like running a command, are not included here
/// because they're covered by existing telemetry.